diff --git a/platform/darwin/src/MGLAttributionInfo.h b/platform/darwin/src/MGLAttributionInfo.h new file mode 100644 index 000000000..c0cb1578b --- /dev/null +++ b/platform/darwin/src/MGLAttributionInfo.h @@ -0,0 +1,58 @@ +#import +#import +#import + +#import "MGLTypes.h" + +NS_ASSUME_NONNULL_BEGIN + +/** + Information about an attribution statement, usually a copyright or trademark + statement, associated with a map source. + */ +@interface MGLAttributionInfo : NSObject + +/** + Parses and returns the attribution infos contained in the given HTML source + code string. + + @param htmlString The HTML source code to parse. + @param fontSize The default text size in points. + @param linkColor The default link color. + */ ++ (NS_ARRAY_OF(MGLAttributionInfo *) *)attributionInfosFromHTMLString:(NSString *)htmlString fontSize:(CGFloat)fontSize linkColor:(nullable MGLColor *)linkColor; + +- (instancetype)initWithTitle:(NSAttributedString *)title URL:(nullable NSURL *)URL; + +@property (nonatomic) NSAttributedString *title; +@property (nonatomic, nullable) NSURL *URL; +@property (nonatomic, getter=isFeedbackLink) BOOL feedbackLink; + +- (nullable NSURL *)feedbackURLAtCenterCoordinate:(CLLocationCoordinate2D)centerCoordinate zoomLevel:(double)zoomLevel; + +@end + +@interface NSMutableArray (MGLAttributionInfoAdditions) + +/** + Adds the given attribution info object to the receiver as long as it isn’t + redundant to any object already in the receiver. Any existing object that is + redundant to the given object is replaced by the given object. + + @param info The info object to add to the receiver. + @return True if the given info object was added to the receiver. + */ +- (void)growArrayByAddingAttributionInfo:(MGLAttributionInfo *)info; + +/** + Adds each of the given attribution info objects to the receiver as long as it + isn’t redundant to any object already in the receiver. Any existing object that + is redundant to the given object is replaced by the given object. + + @param infos An array of info objects to add to the receiver. + */ +- (void)growArrayByAddingAttributionInfosFromArray:(NS_ARRAY_OF(MGLAttributionInfo *) *)infos; + +@end + +NS_ASSUME_NONNULL_END diff --git a/platform/darwin/src/MGLAttributionInfo.mm b/platform/darwin/src/MGLAttributionInfo.mm new file mode 100644 index 000000000..2719aef7c --- /dev/null +++ b/platform/darwin/src/MGLAttributionInfo.mm @@ -0,0 +1,178 @@ +#import "MGLAttributionInfo.h" + +#if TARGET_OS_IPHONE + #import +#else + #import +#endif + +#import "MGLMapCamera.h" +#import "NSString+MGLAdditions.h" + +#include + +@implementation MGLAttributionInfo + ++ (NS_ARRAY_OF(MGLAttributionInfo *) *)attributionInfosFromHTMLString:(NSString *)htmlString fontSize:(CGFloat)fontSize linkColor:(nullable MGLColor *)linkColor { + NSDictionary *options = @{ + NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType, + NSCharacterEncodingDocumentAttribute: @(NSUTF8StringEncoding), + }; + // Apply a bogus, easily detectable style rule to any feedback link, since + // NSAttributedString doesn’t preserve the class attribute. + NSMutableString *css = [NSMutableString stringWithString: + @".mapbox-improve-map { -webkit-text-stroke-width: 1000px; }"]; + if (fontSize) { + [css appendFormat:@"html { font-size: %.1fpx; }", fontSize]; + } + if (linkColor) { + CGFloat red; + CGFloat green; + CGFloat blue; + CGFloat alpha; +#if !TARGET_OS_IPHONE + linkColor = [linkColor colorUsingColorSpaceName:NSCalibratedRGBColorSpace]; +#endif + [linkColor getRed:&red green:&green blue:&blue alpha:&alpha]; + [css appendFormat: + @"a:link { color: rgba(%f%%, %f%%, %f%%, %f); }", + red * 100, green * 100, blue * 100, alpha]; + } + NSString *styledHTML = [NSString stringWithFormat:@"%@", css, htmlString]; + NSData *htmlData = [styledHTML dataUsingEncoding:NSUTF8StringEncoding]; + +#if TARGET_OS_IPHONE + NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithData:htmlData + options:options + documentAttributes:nil + error:NULL]; +#else + NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithHTML:htmlData + options:options + documentAttributes:nil]; +#endif + + NSMutableArray *infos = [NSMutableArray array]; + [attributedString enumerateAttribute:NSLinkAttributeName + inRange:attributedString.mgl_wholeRange + options:0 + usingBlock: + ^(id _Nullable value, NSRange range, BOOL * _Nonnull stop) { + NSCAssert(!value || [value isKindOfClass:[NSURL class]], @"If present, URL attribute must be an NSURL."); + + // Detect feedback links by the bogus style rule applied above. + NSNumber *strokeWidth = [attributedString attribute:NSStrokeWidthAttributeName + atIndex:range.location + effectiveRange:NULL]; + BOOL isFeedbackLink = NO; + if ([strokeWidth floatValue] > 100) { + isFeedbackLink = YES; + [attributedString removeAttribute:NSStrokeWidthAttributeName range:range]; + } + + // Omit whitespace-only strings. + NSAttributedString *title = [[attributedString attributedSubstringFromRange:range] + mgl_attributedStringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (!title.length) { + return; + } + + MGLAttributionInfo *info = [[MGLAttributionInfo alloc] initWithTitle:title URL:value]; + info.feedbackLink = isFeedbackLink; + [infos addObject:info]; + }]; + return infos; +} + +- (instancetype)initWithTitle:(NSAttributedString *)title URL:(NSURL *)URL { + if (self = [super init]) { + _title = title; + _URL = URL; + } + return self; +} + +- (nullable NSURL *)feedbackURLAtCenterCoordinate:(CLLocationCoordinate2D)centerCoordinate zoomLevel:(double)zoomLevel { + if (!self.feedbackLink) { + return nil; + } + + NSURLComponents *components = [NSURLComponents componentsWithURL:self.URL resolvingAgainstBaseURL:NO]; + components.fragment = [NSString stringWithFormat:@"/%.5f/%.5f/%i", + centerCoordinate.longitude, centerCoordinate.latitude, (int)round(zoomLevel + 1)]; + return components.URL; +} + +- (BOOL)isEqual:(id)object { + return [object isKindOfClass:[self class]] && [[object title] isEqual:self.title] && [[object URL] isEqual:self.URL]; +} + +- (NSUInteger)hash { + return self.title.hash + self.URL.hash; +} + +/** + Returns whether the given attribution info object overlaps with the receiver by + its plain text title. + + @return `NSOrderedAscending` if the given object is a superset of the receiver, + `NSOrderedDescending` if it is a subset of the receiver, or `NSOrderedSame` + if there is no overlap. + */ +- (NSComparisonResult)subsetCompare:(MGLAttributionInfo *)otherInfo { + NSString *title = self.title.string; + NSString *otherTitle = otherInfo.title.string; + if ([title containsString:otherTitle]) { + return NSOrderedDescending; + } + if ([otherTitle containsString:title]) { + return NSOrderedAscending; + } + return NSOrderedSame; +} + +@end + +@implementation NSMutableArray (MGLAttributionInfoAdditions) + +- (void)growArrayByAddingAttributionInfo:(MGLAttributionInfo *)info { + __block BOOL didInsertInfo = NO; + __block BOOL shouldAddInfo = YES; + [self enumerateObjectsUsingBlock:^(MGLAttributionInfo * _Nonnull existingInfo, NSUInteger idx, BOOL * _Nonnull stop) { + switch ([info subsetCompare:existingInfo]) { + case NSOrderedDescending: + // The existing info object is a subset of the one we’re adding. + // Replace the existing object the first time we find a subset; + // remove the existing object every time after that. + if (didInsertInfo) { + [self removeObjectAtIndex:idx]; + } else { + [self replaceObjectAtIndex:idx withObject:info]; + didInsertInfo = YES; + } + break; + + case NSOrderedAscending: + // The info object we’re adding is a subset of the existing one. + // Don’t add the object and stop looking. + shouldAddInfo = NO; + *stop = YES; + break; + + default: + break; + } + }]; + if (shouldAddInfo && !didInsertInfo) { + // No overlapping infos were found, so append the info object. + [self addObject:info]; + } +} + +- (void)growArrayByAddingAttributionInfosFromArray:(NS_ARRAY_OF(MGLAttributionInfo *) *)infos { + for (MGLAttributionInfo *info in infos) { + [self growArrayByAddingAttributionInfo:info]; + } +} + +@end diff --git a/platform/darwin/src/MGLGeoJSONSource.mm b/platform/darwin/src/MGLGeoJSONSource.mm index 7fd89ddc7..570b88414 100644 --- a/platform/darwin/src/MGLGeoJSONSource.mm +++ b/platform/darwin/src/MGLGeoJSONSource.mm @@ -18,6 +18,8 @@ const MGLGeoJSONSourceOption MGLGeoJSONSourceOptionSimplificationTolerance = @"M @interface MGLGeoJSONSource () +- (instancetype)initWithRawSource:(mbgl::style::GeoJSONSource *)rawSource NS_DESIGNATED_INITIALIZER; + @property (nonatomic, readwrite) NSDictionary *options; @property (nonatomic) mbgl::style::GeoJSONSource *rawSource; @@ -60,6 +62,10 @@ const MGLGeoJSONSourceOption MGLGeoJSONSourceOptionSimplificationTolerance = @"M return self; } +- (instancetype)initWithRawSource:(mbgl::style::GeoJSONSource *)rawSource { + return [super initWithRawSource:rawSource]; +} + - (void)addToMapView:(MGLMapView *)mapView { if (_pendingSource == nullptr) { diff --git a/platform/darwin/src/MGLGeoJSONSource_Private.h b/platform/darwin/src/MGLGeoJSONSource_Private.h index de5bb10fa..9d67deee3 100644 --- a/platform/darwin/src/MGLGeoJSONSource_Private.h +++ b/platform/darwin/src/MGLGeoJSONSource_Private.h @@ -1,10 +1,11 @@ #import "MGLGeoJSONSource.h" -#import "MGLGeoJSONSource_Private.h" #include @interface MGLGeoJSONSource (Private) +- (instancetype)initWithRawSource:(mbgl::style::GeoJSONSource *)rawSource; + - (mbgl::style::GeoJSONOptions)geoJSONOptions; @end diff --git a/platform/darwin/src/MGLRasterSource.mm b/platform/darwin/src/MGLRasterSource.mm index 62472050e..1671e1dec 100644 --- a/platform/darwin/src/MGLRasterSource.mm +++ b/platform/darwin/src/MGLRasterSource.mm @@ -1,4 +1,4 @@ -#import "MGLRasterSource.h" +#import "MGLRasterSource_Private.h" #import "MGLMapView_Private.h" #import "MGLSource_Private.h" @@ -9,6 +9,8 @@ @interface MGLRasterSource () +- (instancetype)initWithRawSource:(mbgl::style::RasterSource *)rawSource NS_DESIGNATED_INITIALIZER; + @property (nonatomic) mbgl::style::RasterSource *rawSource; @end @@ -39,6 +41,16 @@ return self; } +- (instancetype)initWithRawSource:(mbgl::style::RasterSource *)rawSource { + if (self = [super initWithRawSource:rawSource]) { + if (auto attribution = rawSource->getAttribution()) { + _tileSet = [[MGLTileSet alloc] initWithTileURLTemplates:@[]]; + _tileSet.attribution = @(attribution->c_str()); + } + } + return self; +} + - (void)commonInit { std::unique_ptr source; diff --git a/platform/darwin/src/MGLRasterSource_Private.h b/platform/darwin/src/MGLRasterSource_Private.h new file mode 100644 index 000000000..4a367cf8f --- /dev/null +++ b/platform/darwin/src/MGLRasterSource_Private.h @@ -0,0 +1,13 @@ +#import "MGLRasterSource.h" + +namespace mbgl { + namespace style { + class RasterSource; + } +} + +@interface MGLRasterSource (Private) + +- (instancetype)initWithRawSource:(mbgl::style::RasterSource *)rawSource; + +@end diff --git a/platform/darwin/src/MGLSource.mm b/platform/darwin/src/MGLSource.mm index 2fa580df8..c96b6c41c 100644 --- a/platform/darwin/src/MGLSource.mm +++ b/platform/darwin/src/MGLSource.mm @@ -20,6 +20,14 @@ return self; } +- (instancetype)initWithRawSource:(mbgl::style::Source *)rawSource { + NSString *identifier = @(rawSource->getID().c_str()); + if (self = [self initWithIdentifier:identifier]) { + _rawSource = rawSource; + } + return self; +} + - (void)addToMapView:(MGLMapView *)mapView { [NSException raise:NSInvalidArgumentException format: @"The source %@ cannot be added to the style. " diff --git a/platform/darwin/src/MGLSource_Private.h b/platform/darwin/src/MGLSource_Private.h index d360e71f3..3100e0ae6 100644 --- a/platform/darwin/src/MGLSource_Private.h +++ b/platform/darwin/src/MGLSource_Private.h @@ -1,12 +1,20 @@ #import "MGLSource.h" -#include -#include +namespace mbgl { + namespace style { + class Source; + } +} @class MGLMapView; @interface MGLSource (Private) +/** + Initializes and returns a source with a raw pointer to the backing store. + */ +- (instancetype)initWithRawSource:(mbgl::style::Source *)rawSource; + /** A raw pointer to the mbgl object, which is always initialized, either to the value returned by `mbgl::Map getSource`, or for independently created objects, diff --git a/platform/darwin/src/MGLStyle.mm b/platform/darwin/src/MGLStyle.mm index a6de4e798..ee0bb286b 100644 --- a/platform/darwin/src/MGLStyle.mm +++ b/platform/darwin/src/MGLStyle.mm @@ -17,10 +17,13 @@ #import "NSDate+MGLAdditions.h" #import "MGLSource.h" -#import "MGLVectorSource.h" +#import "MGLVectorSource_Private.h" #import "MGLRasterSource.h" #import "MGLGeoJSONSource.h" +#import "MGLAttributionInfo.h" +#import "MGLTileSet_Private.h" + #include #include #include @@ -158,25 +161,18 @@ static NSURL *MGLStyleURL_emerald; return rawSource ? [self sourceFromMBGLSource:rawSource] : nil; } -- (MGLSource *)sourceFromMBGLSource:(mbgl::style::Source *)mbglSource { - NSString *identifier = @(mbglSource->getID().c_str()); - +- (MGLSource *)sourceFromMBGLSource:(mbgl::style::Source *)source { // TODO: Fill in options specific to the respective source classes // https://github.com/mapbox/mapbox-gl-native/issues/6584 - MGLSource *source; - if (mbglSource->is()) { - source = [[MGLVectorSource alloc] initWithIdentifier:identifier]; - } else if (mbglSource->is()) { - source = [[MGLGeoJSONSource alloc] initWithIdentifier:identifier]; - } else if (mbglSource->is()) { - source = [[MGLRasterSource alloc] initWithIdentifier:identifier]; + if (auto vectorSource = source->as()) { + return [[MGLVectorSource alloc] initWithRawSource:vectorSource]; + } else if (auto geoJSONSource = source->as()) { + return [[MGLGeoJSONSource alloc] initWithRawSource:geoJSONSource]; + } else if (auto rasterSource = source->as()) { + return [[MGLRasterSource alloc] initWithRawSource:rasterSource]; } else { - source = [[MGLSource alloc] initWithIdentifier:identifier]; + return [[MGLSource alloc] initWithRawSource:source]; } - - source.rawSource = mbglSource; - - return source; } - (void)addSource:(MGLSource *)source @@ -206,6 +202,25 @@ static NSURL *MGLStyleURL_emerald; [source removeFromMapView:self.mapView]; } +- (nullable NS_ARRAY_OF(MGLAttributionInfo *) *)attributionInfosWithFontSize:(CGFloat)fontSize linkColor:(nullable MGLColor *)linkColor { + // It’d be incredibly convenient to use -sources here, but this operation + // depends on the sources being sorted in ascending order by creation, as + // with the std::vector used in mbgl. + auto rawSources = self.mapView.mbglMap->getSources(); + NSMutableArray *infos = [NSMutableArray arrayWithCapacity:rawSources.size()]; + for (auto rawSource = rawSources.begin(); rawSource != rawSources.end(); ++rawSource) { + MGLSource *source = [self sourceFromMBGLSource:*rawSource]; + if (![source isKindOfClass:[MGLVectorSource class]] + && ![source isKindOfClass:[MGLRasterSource class]]) { + continue; + } + + NSArray *tileSetInfos = [[(id)source tileSet] attributionInfosWithFontSize:fontSize linkColor:linkColor]; + [infos growArrayByAddingAttributionInfosFromArray:tileSetInfos]; + } + return infos; +} + #pragma mark Style layers - (NS_MUTABLE_ARRAY_OF(MGLStyleLayer *) *)layers diff --git a/platform/darwin/src/MGLStyle_Private.h b/platform/darwin/src/MGLStyle_Private.h index ee4a30c88..23ce8fbee 100644 --- a/platform/darwin/src/MGLStyle_Private.h +++ b/platform/darwin/src/MGLStyle_Private.h @@ -2,11 +2,10 @@ #import "MGLStyleLayer.h" #import "MGLFillStyleLayer.h" -#import -#include NS_ASSUME_NONNULL_BEGIN +@class MGLAttributionInfo; @class MGLMapView; @class MGLOpenGLStyleLayer; @@ -16,6 +15,8 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly, weak) MGLMapView *mapView; +- (nullable NS_ARRAY_OF(MGLAttributionInfo *) *)attributionInfosWithFontSize:(CGFloat)fontSize linkColor:(nullable MGLColor *)linkColor; + @property (nonatomic, readonly, strong) NS_MUTABLE_DICTIONARY_OF(NSString *, MGLOpenGLStyleLayer *) *openGLLayers; - (void)setStyleClasses:(NS_ARRAY_OF(NSString *) *)appliedClasses transitionDuration:(NSTimeInterval)transitionDuration; diff --git a/platform/darwin/src/MGLTileSet.h b/platform/darwin/src/MGLTileSet.h index 08a34338b..88bc7e4ae 100644 --- a/platform/darwin/src/MGLTileSet.h +++ b/platform/darwin/src/MGLTileSet.h @@ -17,6 +17,32 @@ typedef NS_ENUM(NSUInteger, MGLTileSetScheme) { */ @interface MGLTileSet : NSObject +#pragma mark Creating a Tile Set + +/** + Initializes and returns a new tile set object. + + @param tileURLTemplates An `NSArray` of `NSString` objects that represent the + tile templates. + @return The initialized tile set object. + */ +- (instancetype)initWithTileURLTemplates:(NS_ARRAY_OF(NSString *) *)tileURLTemplates; + +/** + Initializes and returns a new tile set object. + + @param tileURLTemplates An `NSArray` of `NSString` objects that represent the + tile templates. + @param minimumZoomLevel An `NSUInteger`; specifies the minimum zoom level at + which to display tiles. + @param maximumZoomLevel An `NSUInteger`; specifies the maximum zoom level at + which to display tiles. + @return The initialized tile set object. + */ +- (instancetype)initWithTileURLTemplates:(NS_ARRAY_OF(NSString *) *)tileURLTemplates minimumZoomLevel:(NSUInteger)minimumZoomLevel maximumZoomLevel:(NSUInteger)maximumZoomLevel; + +#pragma mark Accessing Tile Set Metadata + /** An `NSArray` of `NSString` objects that represent the tile templates. */ @@ -51,28 +77,6 @@ typedef NS_ENUM(NSUInteger, MGLTileSetScheme) { */ @property (nonatomic) MGLTileSetScheme scheme; -/** - Initializes and returns a new tile set object. - - @param tileURLTemplates An `NSArray` of `NSString` objects that represent the - tile templates. - @return The initialized tile set object. - */ -- (instancetype)initWithTileURLTemplates:(NS_ARRAY_OF(NSString *) *)tileURLTemplates; - -/** - Initializes and returns a new tile set object. - - @param tileURLTemplates An `NSArray` of `NSString` objects that represent the - tile templates. - @param minimumZoomLevel An `NSUInteger`; specifies the minimum zoom level at - which to display tiles. - @param maximumZoomLevel An `NSUInteger`; specifies the maximum zoom level at - which to display tiles. - @return The initialized tile set object. - */ -- (instancetype)initWithTileURLTemplates:(NS_ARRAY_OF(NSString *) *)tileURLTemplates minimumZoomLevel:(NSUInteger)minimumZoomLevel maximumZoomLevel:(NSUInteger)maximumZoomLevel; - @end NS_ASSUME_NONNULL_END diff --git a/platform/darwin/src/MGLTileSet.mm b/platform/darwin/src/MGLTileSet.mm index f795545ee..6afc6c19a 100644 --- a/platform/darwin/src/MGLTileSet.mm +++ b/platform/darwin/src/MGLTileSet.mm @@ -1,5 +1,7 @@ #import "MGLTileSet.h" +#import "MGLAttributionInfo.h" + #include @implementation MGLTileSet @@ -57,6 +59,12 @@ _maximumZoomLevel = maximumZoomLevel; } +- (nullable NS_ARRAY_OF(MGLAttributionInfo *) *)attributionInfosWithFontSize:(CGFloat)fontSize linkColor:(nullable MGLColor *)linkColor { + return [MGLAttributionInfo attributionInfosFromHTMLString:self.attribution + fontSize:fontSize + linkColor:linkColor]; +} + - (mbgl::Tileset)mbglTileset { mbgl::Tileset tileset; diff --git a/platform/darwin/src/MGLTileSet_Private.h b/platform/darwin/src/MGLTileSet_Private.h index 6a14d428d..038fe57fa 100644 --- a/platform/darwin/src/MGLTileSet_Private.h +++ b/platform/darwin/src/MGLTileSet_Private.h @@ -2,8 +2,23 @@ #include +NS_ASSUME_NONNULL_BEGIN + +@class MGLAttributionInfo; + @interface MGLTileSet (Private) +/** + A structured representation of the `attribution` property. The default value is + `nil`. + + @param fontSize The default text size in points. + @param linkColor The default link color. + */ +- (nullable NS_ARRAY_OF(MGLAttributionInfo *) *)attributionInfosWithFontSize:(CGFloat)fontSize linkColor:(nullable MGLColor *)linkColor; + - (mbgl::Tileset)mbglTileset; -@end \ No newline at end of file +@end + +NS_ASSUME_NONNULL_END diff --git a/platform/darwin/src/MGLVectorSource.mm b/platform/darwin/src/MGLVectorSource.mm index ab68d45ba..b5ec0b33b 100644 --- a/platform/darwin/src/MGLVectorSource.mm +++ b/platform/darwin/src/MGLVectorSource.mm @@ -1,4 +1,4 @@ -#import "MGLVectorSource.h" +#import "MGLVectorSource_Private.h" #import "MGLMapView_Private.h" #import "MGLSource_Private.h" @@ -9,6 +9,8 @@ @interface MGLVectorSource () +- (instancetype)initWithRawSource:(mbgl::style::VectorSource *)rawSource NS_DESIGNATED_INITIALIZER; + @property (nonatomic) mbgl::style::VectorSource *rawSource; @end @@ -38,6 +40,16 @@ return self; } +- (instancetype)initWithRawSource:(mbgl::style::VectorSource *)rawSource { + if (self = [super initWithRawSource:rawSource]) { + if (auto attribution = rawSource->getAttribution()) { + _tileSet = [[MGLTileSet alloc] initWithTileURLTemplates:@[]]; + _tileSet.attribution = @(attribution->c_str()); + } + } + return self; +} + - (void)commonInit { std::unique_ptr source; diff --git a/platform/darwin/src/MGLVectorSource_Private.h b/platform/darwin/src/MGLVectorSource_Private.h new file mode 100644 index 000000000..ce6bccdba --- /dev/null +++ b/platform/darwin/src/MGLVectorSource_Private.h @@ -0,0 +1,13 @@ +#import "MGLVectorSource.h" + +namespace mbgl { + namespace style { + class VectorSource; + } +} + +@interface MGLVectorSource (Private) + +- (instancetype)initWithRawSource:(mbgl::style::VectorSource *)rawSource; + +@end diff --git a/platform/darwin/src/NSString+MGLAdditions.h b/platform/darwin/src/NSString+MGLAdditions.h index 5b549affd..45fea2558 100644 --- a/platform/darwin/src/NSString+MGLAdditions.h +++ b/platform/darwin/src/NSString+MGLAdditions.h @@ -4,9 +4,22 @@ NS_ASSUME_NONNULL_BEGIN @interface NSString (MGLAdditions) +/** Returns the range spanning the entire receiver. */ +- (NSRange)mgl_wholeRange; + /** Returns the receiver if non-empty or nil if empty. */ - (nullable NSString *)mgl_stringOrNilIfEmpty; @end +@interface NSAttributedString (MGLAdditions) + +/** Returns the range spanning the entire receiver. */ +- (NSRange)mgl_wholeRange; + +/** Returns a copy of the receiver with leading and trailing members of the given set removed. */ +- (NSAttributedString *)mgl_attributedStringByTrimmingCharactersInSet:(NSCharacterSet *)set; + +@end + NS_ASSUME_NONNULL_END diff --git a/platform/darwin/src/NSString+MGLAdditions.m b/platform/darwin/src/NSString+MGLAdditions.m index 969886651..04a65dc5e 100644 --- a/platform/darwin/src/NSString+MGLAdditions.m +++ b/platform/darwin/src/NSString+MGLAdditions.m @@ -2,9 +2,30 @@ @implementation NSString (MGLAdditions) -- (nullable NSString *)mgl_stringOrNilIfEmpty -{ +- (NSRange)mgl_wholeRange { + return NSMakeRange(0, self.length); +} + +- (nullable NSString *)mgl_stringOrNilIfEmpty { return self.length ? self : nil; } @end + +@implementation NSAttributedString (MGLAdditions) + +- (NSRange)mgl_wholeRange { + return NSMakeRange(0, self.length); +} + +- (NSAttributedString *)mgl_attributedStringByTrimmingCharactersInSet:(NSCharacterSet *)set { + NSScanner *scanner = [NSScanner scannerWithString:self.string]; + scanner.charactersToBeSkipped = nil; + NSString *prefix; + [scanner scanCharactersFromSet:set intoString:&prefix]; + + NSString *trimmedString = [self.string stringByTrimmingCharactersInSet:set]; + return [self attributedSubstringFromRange:NSMakeRange(prefix.length, trimmedString.length)]; +} + +@end diff --git a/platform/darwin/test/MGLAttributionInfoTests.m b/platform/darwin/test/MGLAttributionInfoTests.m new file mode 100644 index 000000000..003637bf1 --- /dev/null +++ b/platform/darwin/test/MGLAttributionInfoTests.m @@ -0,0 +1,115 @@ +#import +#import + +#import "MGLAttributionInfo.h" + +@interface MGLAttributionInfoTests : XCTestCase + +@end + +@implementation MGLAttributionInfoTests + +- (void)testParsing { + static NSString * const htmlStrings[] = { + @"© Mapbox " + @"©️ OpenStreetMap " + @"CC BY-SA " + @"Improve this map", + }; + + NS_MUTABLE_ARRAY_OF(MGLAttributionInfo *) *infos = [NSMutableArray array]; + for (NSUInteger i = 0; i < sizeof(htmlStrings) / sizeof(htmlStrings[0]); i++) { + NSArray *subinfos = [MGLAttributionInfo attributionInfosFromHTMLString:htmlStrings[i] + fontSize:0 + linkColor:nil]; + [infos growArrayByAddingAttributionInfosFromArray:subinfos]; + } + + XCTAssertEqual(infos.count, 4); + + CLLocationCoordinate2D mapbox = CLLocationCoordinate2DMake(12.9810816, 77.6368034); + XCTAssertEqualObjects(infos[0].title.string, @"© Mapbox"); + XCTAssertEqualObjects(infos[0].URL, [NSURL URLWithString:@"https://www.mapbox.com/about/maps/"]); + XCTAssertFalse(infos[0].feedbackLink); + XCTAssertNil([infos[0] feedbackURLAtCenterCoordinate:mapbox zoomLevel:14]); + + XCTAssertEqualObjects(infos[1].title.string, @"©️ OpenStreetMap"); + XCTAssertEqualObjects(infos[1].URL, [NSURL URLWithString:@"http://www.openstreetmap.org/about/"]); + XCTAssertFalse(infos[1].feedbackLink); + XCTAssertNil([infos[1] feedbackURLAtCenterCoordinate:mapbox zoomLevel:14]); + + XCTAssertEqualObjects(infos[2].title.string, @"CC\u00a0BY-SA"); + XCTAssertNil(infos[2].URL); + XCTAssertFalse(infos[2].feedbackLink); + XCTAssertNil([infos[2] feedbackURLAtCenterCoordinate:mapbox zoomLevel:14]); + + XCTAssertEqualObjects(infos[3].title.string, @"Improve this map"); + XCTAssertEqualObjects(infos[3].URL, [NSURL URLWithString:@"https://www.mapbox.com/map-feedback/"]); + XCTAssertTrue(infos[3].feedbackLink); + XCTAssertEqualObjects([infos[3] feedbackURLAtCenterCoordinate:mapbox zoomLevel:14], + [NSURL URLWithString:@"https://www.mapbox.com/map-feedback/#/77.63680/12.98108/15"]); +} + +- (void)testStyle { + static NSString * const htmlStrings[] = { + @"Mapbox", + }; + + CGFloat fontSize = 72; + MGLColor *color = [MGLColor redColor]; + NS_MUTABLE_ARRAY_OF(MGLAttributionInfo *) *infos = [NSMutableArray array]; + for (NSUInteger i = 0; i < sizeof(htmlStrings) / sizeof(htmlStrings[0]); i++) { + NSArray *subinfos = [MGLAttributionInfo attributionInfosFromHTMLString:htmlStrings[i] + fontSize:72 + linkColor:color]; + [infos growArrayByAddingAttributionInfosFromArray:subinfos]; + } + + XCTAssertEqual(infos.count, 1); + + XCTAssertEqualObjects(infos[0].title.string, @"Mapbox"); + XCTAssertEqualObjects([infos[0].title attribute:NSLinkAttributeName atIndex:0 effectiveRange:nil], [NSURL URLWithString:@"https://www.mapbox.com/"]); + XCTAssertEqualObjects([infos[0].title attribute:NSUnderlineStyleAttributeName atIndex:0 effectiveRange:nil], @(NSUnderlineStyleSingle)); + +#if TARGET_OS_IPHONE + UIFont *font; +#else + NSFont *font; +#endif + font = [infos[0].title attribute:NSFontAttributeName atIndex:0 effectiveRange:nil]; + XCTAssertEqual(font.pointSize, fontSize); + + CGFloat r, g, b, a; + [color getRed:&r green:&g blue:&b alpha:&a]; + MGLColor *linkColor = [infos[0].title attribute:NSForegroundColorAttributeName atIndex:0 effectiveRange:nil]; + CGFloat linkR, linkG, linkB, linkA; + [linkColor getRed:&linkR green:&linkG blue:&linkB alpha:&linkA]; + XCTAssertEqual(r, linkR); + XCTAssertEqual(g, linkG); + XCTAssertEqual(b, linkB); + XCTAssertEqual(a, linkA); +} + +- (void)testDedupe { + static NSString * const htmlStrings[] = { + @"World", + @"Hello World", + @"Another Source", + @"Hello", + @"Hello World", + }; + + NS_MUTABLE_ARRAY_OF(MGLAttributionInfo *) *infos = [NSMutableArray array]; + for (NSUInteger i = 0; i < sizeof(htmlStrings) / sizeof(htmlStrings[0]); i++) { + NSArray *subinfos = [MGLAttributionInfo attributionInfosFromHTMLString:htmlStrings[i] + fontSize:0 + linkColor:nil]; + [infos growArrayByAddingAttributionInfosFromArray:subinfos]; + } + + XCTAssertEqual(infos.count, 2); + XCTAssertEqualObjects(infos[0].title.string, @"Hello World"); + XCTAssertEqualObjects(infos[1].title.string, @"Another Source"); +} + +@end diff --git a/platform/ios/CHANGELOG.md b/platform/ios/CHANGELOG.md index 894c33510..5c63bdd2f 100644 --- a/platform/ios/CHANGELOG.md +++ b/platform/ios/CHANGELOG.md @@ -22,6 +22,7 @@ Mapbox welcomes participation and contributions from everyone. Please read [CONT * `-[MGLMapView resetPosition]` now resets to the current style’s default center coordinates, zoom level, direction, and pitch, if specified. ([#6127](https://github.com/mapbox/mapbox-gl-native/pull/6127)) * Fixed an issue where feature querying sometimes failed to return the expected features when the map was tilted. ([#6773](https://github.com/mapbox/mapbox-gl-native/pull/6773)) * MGLFeature’s `attributes` and `identifier` properties are now writable. ([#6728](https://github.com/mapbox/mapbox-gl-native/pull/6728)) +* The action sheet that appears when tapping the information button in the bottom-right corner now lists the correct attribution for the current style. ([#5999](https://github.com/mapbox/mapbox-gl-native/pull/5999)) * The `text-pitch-alignment` property is now supported in stylesheets for improved street label legibility on a tilted map. ([#5288](https://github.com/mapbox/mapbox-gl-native/pull/5288)) * The `icon-text-fit` and `icon-text-fit-padding` properties are now supported in stylesheets, allowing the background of a shield to automatically resize to fit the shield’s text. ([#5334](https://github.com/mapbox/mapbox-gl-native/pull/5334)) * The `circle-pitch-scale` property is now supported in stylesheets, allowing circle features in a tilted base map to scale or remain the same size as the viewing distance changes. ([#5576](https://github.com/mapbox/mapbox-gl-native/pull/5576)) diff --git a/platform/ios/ios.xcodeproj/project.pbxproj b/platform/ios/ios.xcodeproj/project.pbxproj index e469c82ba..30ed5556b 100644 --- a/platform/ios/ios.xcodeproj/project.pbxproj +++ b/platform/ios/ios.xcodeproj/project.pbxproj @@ -172,6 +172,10 @@ 7E016D851D9E890300A29A21 /* MGLPolygon+MGLAdditions.h in Headers */ = {isa = PBXBuildFile; fileRef = 7E016D821D9E890300A29A21 /* MGLPolygon+MGLAdditions.h */; }; 7E016D861D9E890300A29A21 /* MGLPolygon+MGLAdditions.m in Sources */ = {isa = PBXBuildFile; fileRef = 7E016D831D9E890300A29A21 /* MGLPolygon+MGLAdditions.m */; }; 7E016D871D9E890300A29A21 /* MGLPolygon+MGLAdditions.m in Sources */ = {isa = PBXBuildFile; fileRef = 7E016D831D9E890300A29A21 /* MGLPolygon+MGLAdditions.m */; }; + DA00FC8E1D5EEB0D009AABC8 /* MGLAttributionInfo.h in Headers */ = {isa = PBXBuildFile; fileRef = DA00FC8C1D5EEB0D009AABC8 /* MGLAttributionInfo.h */; }; + DA00FC8F1D5EEB0D009AABC8 /* MGLAttributionInfo.h in Headers */ = {isa = PBXBuildFile; fileRef = DA00FC8C1D5EEB0D009AABC8 /* MGLAttributionInfo.h */; }; + DA00FC901D5EEB0D009AABC8 /* MGLAttributionInfo.mm in Sources */ = {isa = PBXBuildFile; fileRef = DA00FC8D1D5EEB0D009AABC8 /* MGLAttributionInfo.mm */; }; + DA00FC911D5EEB0D009AABC8 /* MGLAttributionInfo.mm in Sources */ = {isa = PBXBuildFile; fileRef = DA00FC8D1D5EEB0D009AABC8 /* MGLAttributionInfo.mm */; }; DA0CD5901CF56F6A00A5F5A5 /* MGLFeatureTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = DA0CD58F1CF56F6A00A5F5A5 /* MGLFeatureTests.mm */; }; DA17BE301CC4BAC300402C41 /* MGLMapView_Private.h in Headers */ = {isa = PBXBuildFile; fileRef = DA17BE2F1CC4BAC300402C41 /* MGLMapView_Private.h */; }; DA17BE311CC4BDAA00402C41 /* MGLMapView_Private.h in Headers */ = {isa = PBXBuildFile; fileRef = DA17BE2F1CC4BAC300402C41 /* MGLMapView_Private.h */; }; @@ -407,6 +411,7 @@ DAED38641D62D0FC00D7640F /* NSURL+MGLAdditions.h in Headers */ = {isa = PBXBuildFile; fileRef = DAED38611D62D0FC00D7640F /* NSURL+MGLAdditions.h */; }; DAED38651D62D0FC00D7640F /* NSURL+MGLAdditions.m in Sources */ = {isa = PBXBuildFile; fileRef = DAED38621D62D0FC00D7640F /* NSURL+MGLAdditions.m */; }; DAED38661D62D0FC00D7640F /* NSURL+MGLAdditions.m in Sources */ = {isa = PBXBuildFile; fileRef = DAED38621D62D0FC00D7640F /* NSURL+MGLAdditions.m */; }; + DAEDC4341D603417000224FF /* MGLAttributionInfoTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DAEDC4331D603417000224FF /* MGLAttributionInfoTests.m */; }; DD0902A91DB1929D00C5BDCE /* MGLNetworkConfiguration.m in Sources */ = {isa = PBXBuildFile; fileRef = DD0902A21DB18DE700C5BDCE /* MGLNetworkConfiguration.m */; }; DD0902AA1DB1929D00C5BDCE /* MGLNetworkConfiguration.m in Sources */ = {isa = PBXBuildFile; fileRef = DD0902A21DB18DE700C5BDCE /* MGLNetworkConfiguration.m */; }; DD0902AB1DB192A800C5BDCE /* MGLNetworkConfiguration.h in Headers */ = {isa = PBXBuildFile; fileRef = DD0902A41DB18F1B00C5BDCE /* MGLNetworkConfiguration.h */; }; @@ -601,6 +606,8 @@ 7E016D7D1D9E86BE00A29A21 /* MGLPolyline+MGLAdditions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "MGLPolyline+MGLAdditions.m"; sourceTree = ""; }; 7E016D821D9E890300A29A21 /* MGLPolygon+MGLAdditions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "MGLPolygon+MGLAdditions.h"; sourceTree = ""; }; 7E016D831D9E890300A29A21 /* MGLPolygon+MGLAdditions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "MGLPolygon+MGLAdditions.m"; sourceTree = ""; }; + DA00FC8C1D5EEB0D009AABC8 /* MGLAttributionInfo.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MGLAttributionInfo.h; sourceTree = ""; }; + DA00FC8D1D5EEB0D009AABC8 /* MGLAttributionInfo.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = MGLAttributionInfo.mm; sourceTree = ""; }; DA0CD58F1CF56F6A00A5F5A5 /* MGLFeatureTests.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = MGLFeatureTests.mm; path = ../../darwin/test/MGLFeatureTests.mm; sourceTree = ""; }; DA17BE2F1CC4BAC300402C41 /* MGLMapView_Private.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MGLMapView_Private.h; sourceTree = ""; }; DA1DC94A1CB6C1C2006E619F /* Mapbox GL.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Mapbox GL.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -775,6 +782,7 @@ DAD165771CF4CDFF001FF4B9 /* MGLShapeCollection.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = MGLShapeCollection.mm; sourceTree = ""; }; DAED38611D62D0FC00D7640F /* NSURL+MGLAdditions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "NSURL+MGLAdditions.h"; sourceTree = ""; }; DAED38621D62D0FC00D7640F /* NSURL+MGLAdditions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "NSURL+MGLAdditions.m"; sourceTree = ""; }; + DAEDC4331D603417000224FF /* MGLAttributionInfoTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = MGLAttributionInfoTests.m; path = ../../darwin/test/MGLAttributionInfoTests.m; sourceTree = ""; }; DD0902A21DB18DE700C5BDCE /* MGLNetworkConfiguration.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MGLNetworkConfiguration.m; sourceTree = ""; }; DD0902A41DB18F1B00C5BDCE /* MGLNetworkConfiguration.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = MGLNetworkConfiguration.h; sourceTree = ""; }; DD4823721D94AE6C00EB71B7 /* fill_filter_style.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = fill_filter_style.json; sourceTree = ""; }; @@ -1060,6 +1068,7 @@ isa = PBXGroup; children = ( 357579811D502AD4000B822E /* Styling */, + DAEDC4331D603417000224FF /* MGLAttributionInfoTests.m */, 353D23951D0B0DFE002BE09D /* MGLAnnotationViewTests.m */, DA35A2C31CCA9F8300E826B2 /* MGLClockDirectionFormatterTests.m */, DA35A2C41CCA9F8300E826B2 /* MGLCompassDirectionFormatterTests.m */, @@ -1111,6 +1120,8 @@ DA8848001CBAFA6200AB86E3 /* MGLAccountManager.m */, DD0902A41DB18F1B00C5BDCE /* MGLNetworkConfiguration.h */, DD0902A21DB18DE700C5BDCE /* MGLNetworkConfiguration.m */, + DA00FC8C1D5EEB0D009AABC8 /* MGLAttributionInfo.h */, + DA00FC8D1D5EEB0D009AABC8 /* MGLAttributionInfo.mm */, DA8847E21CBAFA5100AB86E3 /* MGLMapCamera.h */, DA8848031CBAFA6200AB86E3 /* MGLMapCamera.mm */, DA8847EC1CBAFA5100AB86E3 /* MGLStyle.h */, @@ -1422,6 +1433,7 @@ 353933FE1D3FB7DD003F57D7 /* MGLSymbolStyleLayer.h in Headers */, DA8848861CBB033F00AB86E3 /* Fabric+FABKits.h in Headers */, DA8848201CBAFA6200AB86E3 /* MGLOfflinePack_Private.h in Headers */, + DA00FC8E1D5EEB0D009AABC8 /* MGLAttributionInfo.h in Headers */, DA8847FA1CBAFA5100AB86E3 /* MGLPolyline.h in Headers */, 3566C7711D4A9198008152BC /* MGLSource_Private.h in Headers */, 4018B1C91CDC288A00F666AF /* MGLAnnotationView_Private.h in Headers */, @@ -1593,6 +1605,7 @@ DABFB8601CBE99E500D62B32 /* MGLMapCamera.h in Headers */, DA737EE21D056A4E005BDA16 /* MGLMapViewDelegate.h in Headers */, DABFB86A1CBE99E500D62B32 /* MGLStyle.h in Headers */, + DA00FC8F1D5EEB0D009AABC8 /* MGLAttributionInfo.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1911,6 +1924,7 @@ DA35A2C61CCA9F8300E826B2 /* MGLCompassDirectionFormatterTests.m in Sources */, 3575798E1D502EC7000B822E /* MGLRuntimeStylingHelper.m in Sources */, 4085AF091D933DEA00F11B22 /* MGLTileSetTests.mm in Sources */, + DAEDC4341D603417000224FF /* MGLAttributionInfoTests.m in Sources */, 357579851D502AF5000B822E /* MGLSymbolStyleLayerTests.m in Sources */, 357579871D502AFE000B822E /* MGLLineStyleLayerTests.m in Sources */, 357579891D502B06000B822E /* MGLCircleStyleLayerTests.m in Sources */, @@ -1951,6 +1965,7 @@ 400533021DB0862B0069F638 /* NSArray+MGLAdditions.mm in Sources */, 35136D421D42274500C20EFD /* MGLRasterStyleLayer.mm in Sources */, 3538AA1F1D542239008EC33D /* MGLForegroundStyleLayer.m in Sources */, + DA00FC901D5EEB0D009AABC8 /* MGLAttributionInfo.mm in Sources */, DA88482D1CBAFA6200AB86E3 /* NSBundle+MGLAdditions.m in Sources */, DA88485B1CBAFB9800AB86E3 /* MGLUserLocation.m in Sources */, 350098BD1D480108004B2AF0 /* MGLVectorSource.mm in Sources */, @@ -2025,6 +2040,7 @@ 400533031DB086490069F638 /* NSArray+MGLAdditions.mm in Sources */, 35136D431D42274500C20EFD /* MGLRasterStyleLayer.mm in Sources */, 3538AA201D542239008EC33D /* MGLForegroundStyleLayer.m in Sources */, + DA00FC911D5EEB0D009AABC8 /* MGLAttributionInfo.mm in Sources */, DAA4E4201CBB730400178DFB /* MGLOfflinePack.mm in Sources */, DAA4E4331CBB730400178DFB /* MGLUserLocation.m in Sources */, 350098BE1D480108004B2AF0 /* MGLVectorSource.mm in Sources */, diff --git a/platform/ios/resources/Base.lproj/Localizable.strings b/platform/ios/resources/Base.lproj/Localizable.strings index c4569fe23..63bed7e32 100644 --- a/platform/ios/resources/Base.lproj/Localizable.strings +++ b/platform/ios/resources/Base.lproj/Localizable.strings @@ -19,12 +19,6 @@ /* Compass abbreviation for north */ "COMPASS_NORTH" = "N"; -/* Copyright notice in attribution sheet */ -"COPY_MAPBOX" = "© Mapbox"; - -/* Copyright notice in attribution sheet */ -"COPY_OSM" = "© OpenStreetMap"; - /* Instructions in Interface Builder designable; {key}, {plist file name} */ "DESIGNABLE" = "To display a Mapbox-hosted map here, set %1$@ to your access token in %2$@\n\nFor detailed instructions, see:"; @@ -46,9 +40,6 @@ /* Map accessibility value */ "MAP_A11Y_VALUE" = "Zoom %1$dx\n%2$ld annotation(s) visible"; -/* Action in attribution sheet */ -"MAP_FEEDBACK" = "Improve This Map"; - /* Action sheet title */ "SDK_NAME" = "Mapbox iOS SDK"; diff --git a/platform/ios/src/MGLMapView.mm b/platform/ios/src/MGLMapView.mm index 3ee03182a..c67105b9e 100644 --- a/platform/ios/src/MGLMapView.mm +++ b/platform/ios/src/MGLMapView.mm @@ -55,6 +55,7 @@ #import "MGLCompactCalloutView.h" #import "MGLAnnotationContainerView.h" #import "MGLAnnotationContainerView_Private.h" +#import "MGLAttributionInfo.h" #include #include @@ -310,6 +311,8 @@ public: BOOL _delegateHasLineWidthsForShapeAnnotations; MGLCompassDirectionFormatter *_accessibilityCompassFormatter; + + NS_ARRAY_OF(MGLAttributionInfo *) *_attributionInfos; } #pragma mark - Setup & Teardown - @@ -1714,44 +1717,27 @@ public: - (void)showAttribution { - if ( ! self.attributionSheet) + self.attributionSheet = [[UIActionSheet alloc] initWithTitle:NSLocalizedStringWithDefaultValue(@"SDK_NAME", nil, nil, @"Mapbox iOS SDK", @"Action sheet title") + delegate:self + cancelButtonTitle:NSLocalizedStringWithDefaultValue(@"CANCEL", nil, nil, @"Cancel", @"") + destructiveButtonTitle:nil + otherButtonTitles:nil]; + + _attributionInfos = [self.style attributionInfosWithFontSize:[UIFont buttonFontSize] linkColor:nil]; + for (MGLAttributionInfo *info in _attributionInfos) { - self.attributionSheet = [[UIActionSheet alloc] initWithTitle:NSLocalizedStringWithDefaultValue(@"SDK_NAME", nil, nil, @"Mapbox iOS SDK", @"Action sheet title") - delegate:self - cancelButtonTitle:NSLocalizedStringWithDefaultValue(@"CANCEL", nil, nil, @"Cancel", @"") - destructiveButtonTitle:nil - otherButtonTitles: - NSLocalizedStringWithDefaultValue(@"COPY_MAPBOX", nil, nil, @"© Mapbox", @"Copyright notice in attribution sheet"), - NSLocalizedStringWithDefaultValue(@"COPY_OSM", nil, nil, @"© OpenStreetMap", @"Copyright notice in attribution sheet"), - NSLocalizedStringWithDefaultValue(@"MAP_FEEDBACK", nil, nil, @"Improve This Map", @"Action in attribution sheet"), - NSLocalizedStringWithDefaultValue(@"TELEMETRY_NAME", nil, nil, @"Mapbox Telemetry", @"Action in attribution sheet"), - nil]; - + NSString *title = [info.title.string capitalizedStringWithLocale:[NSLocale currentLocale]]; + [self.attributionSheet addButtonWithTitle:title]; } - + + [self.attributionSheet addButtonWithTitle:NSLocalizedStringWithDefaultValue(@"TELEMETRY_NAME", nil, nil, @"Mapbox Telemetry", @"Action in attribution sheet")]; + [self.attributionSheet showFromRect:self.attributionButton.frame inView:self animated:YES]; } - (void)actionSheet:(UIActionSheet *)actionSheet didDismissWithButtonIndex:(NSInteger)buttonIndex { - if (buttonIndex == actionSheet.firstOtherButtonIndex) - { - [[UIApplication sharedApplication] openURL: - [NSURL URLWithString:@"https://www.mapbox.com/about/maps/"]]; - } - else if (buttonIndex == actionSheet.firstOtherButtonIndex + 1) - { - [[UIApplication sharedApplication] openURL: - [NSURL URLWithString:@"http://www.openstreetmap.org/about/"]]; - } - else if (buttonIndex == actionSheet.firstOtherButtonIndex + 2) - { - NSString *feedbackURL = [NSString stringWithFormat:@"https://www.mapbox.com/map-feedback/#/%.5f/%.5f/%i", - self.longitude, self.latitude, (int)round(self.zoomLevel + 1)]; - [[UIApplication sharedApplication] openURL: - [NSURL URLWithString:feedbackURL]]; - } - else if (buttonIndex == actionSheet.firstOtherButtonIndex + 3) + if (buttonIndex == actionSheet.numberOfButtons - 1) { NSString *message; NSString *participate; @@ -1777,6 +1763,19 @@ public: otherButtonTitles:NSLocalizedStringWithDefaultValue(@"TELEMETRY_MORE", nil, nil, @"Tell Me More", @"Telemetry prompt button"), optOut, nil]; [alert show]; } + else if (buttonIndex > 0) + { + MGLAttributionInfo *info = _attributionInfos[buttonIndex + actionSheet.firstOtherButtonIndex]; + NSURL *url = info.URL; + if (url) + { + if (info.feedbackLink) + { + url = [info feedbackURLAtCenterCoordinate:self.centerCoordinate zoomLevel:self.zoomLevel]; + } + [[UIApplication sharedApplication] openURL:url]; + } + } } - (void)alertView:(UIAlertView *)alertView didDismissWithButtonIndex:(NSInteger)buttonIndex @@ -4694,6 +4693,10 @@ public: } break; } + case mbgl::MapChangeSourceDidChange: + { + break; + } } } diff --git a/platform/macos/CHANGELOG.md b/platform/macos/CHANGELOG.md index 1d5aa9363..ab85890ed 100644 --- a/platform/macos/CHANGELOG.md +++ b/platform/macos/CHANGELOG.md @@ -20,6 +20,7 @@ * Fixed an issue where the style zoom levels were not respected when deciding when to render a layer. ([#5811](https://github.com/mapbox/mapbox-gl-native/issues/5811)) * Fixed an issue where feature querying sometimes failed to return the expected features when the map was tilted. ([#6773](https://github.com/mapbox/mapbox-gl-native/pull/6773)) * MGLFeature’s `attributes` and `identifier` properties are now writable. ([#6728](https://github.com/mapbox/mapbox-gl-native/pull/6728)) +* Attribution views now display the correct attribution for the current style. ([#5999](https://github.com/mapbox/mapbox-gl-native/pull/5999)) * If MGLMapView is unable to obtain or parse a style, it now calls its delegate’s `-mapViewDidFailLoadingMap:withError:` method. ([#6145](https://github.com/mapbox/mapbox-gl-native/pull/6145)) * Added the `-[MGLMapViewDelegate mapView:didFinishLoadingStyle:]` delegate method, which offers the earliest opportunity to modify the layout or appearance of the current style before the map view is displayed to the user. ([#6636](https://github.com/mapbox/mapbox-gl-native/pull/6636)) * Fixed an issue causing stepwise zoom functions to be misinterpreted. ([#6328](https://github.com/mapbox/mapbox-gl-native/pull/6328)) diff --git a/platform/macos/INSTALL.md b/platform/macos/INSTALL.md index 665af128c..a4b944611 100644 --- a/platform/macos/INSTALL.md +++ b/platform/macos/INSTALL.md @@ -28,6 +28,7 @@ In a storyboard or XIB: 3. MGLMapView needs to be layer-backed: * You can make the window layer-backed by selecting the window and checking Full Size Content View in the Attributes inspector. This allows the map view to underlap the title bar and toolbar. * Alternatively, if you don’t want the entire window to be layer-backed, you can make just the map view layer-backed by selecting it and checking its entry under the View Effects inspector’s Core Animation Layer section. +4. Add a map feedback item to your Help menu. (Drag Menu Item from the Object library into Main Menu ‣ Help ‣ Menu.) Title it “Improve This Map” or similar, and connect it to the `giveFeedback:` action of First Responder. If you need to manipulate the map view programmatically: diff --git a/platform/macos/app/MapDocument.m b/platform/macos/app/MapDocument.m index 6c36d00d7..c742440e8 100644 --- a/platform/macos/app/MapDocument.m +++ b/platform/macos/app/MapDocument.m @@ -666,15 +666,6 @@ NS_ARRAY_OF(id ) *MBXFlattenedShapes(NS_ARRAY_OF(id +#import "MGLTypes.h" + +NS_ASSUME_NONNULL_BEGIN + +@class MGLAttributionInfo; + /// Button that looks like a hyperlink and opens a URL. @interface MGLAttributionButton : NSButton -/// Returns an `MGLAttributionButton` instance with the given title and URL. -- (instancetype)initWithTitle:(NSString *)title URL:(NSURL *)url; +/// Returns an `MGLAttributionButton` instance with the given info. +- (instancetype)initWithAttributionInfo:(MGLAttributionInfo *)info; /// The URL to open and display as a tooltip. -@property (nonatomic) NSURL *URL; +@property (nonatomic, readonly, nullable) NSURL *URL; /// Opens the URL. -- (IBAction)openURL:(id)sender; +- (IBAction)openURL:(nullable id)sender; @end + +NS_ASSUME_NONNULL_END diff --git a/platform/macos/src/MGLAttributionButton.m b/platform/macos/src/MGLAttributionButton.m deleted file mode 100644 index e21b86079..000000000 --- a/platform/macos/src/MGLAttributionButton.m +++ /dev/null @@ -1,50 +0,0 @@ -#import "MGLAttributionButton.h" - -#import "NSBundle+MGLAdditions.h" - -@implementation MGLAttributionButton { - NSTrackingRectTag _trackingAreaTag; -} - -- (instancetype)initWithTitle:(NSString *)title URL:(NSURL *)url { - if (self = [super initWithFrame:NSZeroRect]) { - self.bordered = NO; - self.bezelStyle = NSRegularSquareBezelStyle; - - // Start with a copyright symbol. The whole string will be mini. - NSMutableAttributedString *attributedTitle = [[NSMutableAttributedString alloc] initWithString:NSLocalizedStringWithDefaultValue(@"COPYRIGHT_PREFIX", nil, nil, @"© ", @"Copyright notice prefix") attributes:@{ - NSFontAttributeName: [NSFont systemFontOfSize:[NSFont systemFontSizeForControlSize:NSMiniControlSize]], - }]; - // Append the specified title, underlining it like a hyperlink. - [attributedTitle appendAttributedString: - [[NSAttributedString alloc] initWithString:title - attributes:@{ - NSFontAttributeName: [NSFont systemFontOfSize:[NSFont systemFontSizeForControlSize:NSMiniControlSize]], - NSUnderlineStyleAttributeName: @(NSUnderlineStyleSingle), - }]]; - self.attributedTitle = attributedTitle; - [self sizeToFit]; - - _URL = url; - self.toolTip = _URL.absoluteString; - - self.target = self; - self.action = @selector(openURL:); - } - return self; -} - -- (BOOL)wantsLayer { - return YES; -} - -- (void)resetCursorRects { - // The whole button gets a pointing hand cursor, just like a hyperlink. - [self addCursorRect:self.bounds cursor:[NSCursor pointingHandCursor]]; -} - -- (IBAction)openURL:(__unused id)sender { - [[NSWorkspace sharedWorkspace] openURL:self.URL]; -} - -@end diff --git a/platform/macos/src/MGLAttributionButton.mm b/platform/macos/src/MGLAttributionButton.mm new file mode 100644 index 000000000..ed8bb18a6 --- /dev/null +++ b/platform/macos/src/MGLAttributionButton.mm @@ -0,0 +1,55 @@ +#import "MGLAttributionButton.h" +#import "MGLAttributionInfo.h" + +#import "NSBundle+MGLAdditions.h" +#import "NSString+MGLAdditions.h" + +@implementation MGLAttributionButton + +- (instancetype)initWithAttributionInfo:(MGLAttributionInfo *)info { + if (self = [super initWithFrame:NSZeroRect]) { + self.bordered = NO; + self.bezelStyle = NSRegularSquareBezelStyle; + + // Extract any prefix consisting of intellectual property symbols. + NSScanner *scanner = [NSScanner scannerWithString:info.title.string]; + NSCharacterSet *symbolSet = [NSCharacterSet characterSetWithCharactersInString:@"©℗®℠™ &"]; + NSString *symbol; + [scanner scanCharactersFromSet:symbolSet intoString:&symbol]; + + // Remove the underline from the symbol for aesthetic reasons. + NSMutableAttributedString *title = info.title.mutableCopy; + [title removeAttribute:NSUnderlineStyleAttributeName range:NSMakeRange(0, symbol.length)]; + + self.attributedTitle = title; + [self sizeToFit]; + + _URL = info.URL; + if (_URL) { + self.toolTip = _URL.absoluteString; + } + + self.target = self; + self.action = @selector(openURL:); + } + return self; +} + +- (BOOL)wantsLayer { + return YES; +} + +- (void)resetCursorRects { + if (self.URL) { + // The whole button gets a pointing hand cursor, just like a hyperlink. + [self addCursorRect:self.bounds cursor:[NSCursor pointingHandCursor]]; + } +} + +- (IBAction)openURL:(__unused id)sender { + if (self.URL) { + [[NSWorkspace sharedWorkspace] openURL:self.URL]; + } +} + +@end diff --git a/platform/macos/src/MGLMapView.h b/platform/macos/src/MGLMapView.h index 88745db21..ea87f3b33 100644 --- a/platform/macos/src/MGLMapView.h +++ b/platform/macos/src/MGLMapView.h @@ -950,6 +950,25 @@ IB_DESIGNABLE */ - (CLLocationDistance)metersPerPointAtLatitude:(CLLocationDegrees)latitude; +#pragma mark Giving Feedback to Improve the Map + +/** + Opens one or more webpages in the default Web browser in which the user can + provide feedback about the map data. + + You should add a menu item to the Help menu of your application that invokes + this method. Title it “Improve This Map” or similar. Set its target to the + first responder and its action to `giveFeedback:`. + + This map view searches the current style’s sources for webpages to open. + Specifically, each source’s tile set has an `attribution` property containing + HTML code; if an <a> tag (link) within that code has an + class attribute set to mapbox-improve-map, its + href attribute defines the URL to open. Such links are omitted + from the attribution view. + */ +- (IBAction)giveFeedback:(id)sender; + #pragma mark Debugging the Map /** diff --git a/platform/macos/src/MGLMapView.mm b/platform/macos/src/MGLMapView.mm index a2fe7acbd..bf31daffa 100644 --- a/platform/macos/src/MGLMapView.mm +++ b/platform/macos/src/MGLMapView.mm @@ -1,6 +1,7 @@ #import "MGLMapView_Private.h" #import "MGLAnnotationImage_Private.h" #import "MGLAttributionButton.h" +#import "MGLAttributionInfo.h" #import "MGLCompassCell.h" #import "MGLOpenGLLayer.h" #import "MGLStyle.h" @@ -84,23 +85,6 @@ const CGFloat MGLAnnotationImagePaddingForHitTest = 4; /// Distance from the callout’s anchor point to the annotation it points to. const CGFloat MGLAnnotationImagePaddingForCallout = 4; -/// Copyright notices displayed in the attribution view. -struct MGLAttribution { - /// Attribution button label text. A copyright symbol is prepended to this string. - NSString *title; - /// URL to open when the attribution button is clicked. - NSString *urlString; -} MGLAttributions[] = { - { - .title = NSLocalizedStringWithDefaultValue(@"COPYRIGHT_MAPBOX", nil, nil, @"Mapbox", @"Linked part of copyright notice"), - .urlString = NSLocalizedStringWithDefaultValue(@"COPYRIGHT_MAPBOX_LINK", nil, nil, @"https://www.mapbox.com/about/maps/", @"Copyright notice link"), - }, - { - .title = NSLocalizedStringWithDefaultValue(@"COPYRIGHT_OSM", nil, nil, @"OpenStreetMap", @"Linked part of copyright notice"), - .urlString = NSLocalizedStringWithDefaultValue(@"COPYRIGHT_OSM_LINK", nil, nil, @"http://www.openstreetmap.org/about/", @"Copyright notice link"), - }, -}; - /// Unique identifier representing a single annotation in mbgl. typedef uint32_t MGLAnnotationTag; @@ -371,6 +355,7 @@ public: /// Adds legally required map attribution to the lower-left corner. - (void)installAttributionView { + [_attributionView removeFromSuperview]; _attributionView = [[NSView alloc] initWithFrame:NSZeroRect]; _attributionView.wantsLayer = YES; @@ -433,37 +418,69 @@ public: /// Updates the attribution view to reflect the sources used. For now, this is /// hard-coded to the standard Mapbox and OpenStreetMap attribution. - (void)updateAttributionView { - self.attributionView.subviews = @[]; - - for (NSUInteger i = 0; i < sizeof(MGLAttributions) / sizeof(MGLAttributions[0]); i++) { + NSView *attributionView = self.attributionView; + for (NSView *button in attributionView.subviews) { + [button removeConstraints:button.constraints]; + } + attributionView.subviews = @[]; + [attributionView removeConstraints:attributionView.constraints]; + + // Make the whole string mini by default. + // Force links to be black, because the default blue is distracting. + CGFloat miniSize = [NSFont systemFontSizeForControlSize:NSMiniControlSize]; + NSArray *attributionInfos = [self.style attributionInfosWithFontSize:miniSize linkColor:[NSColor blackColor]]; + for (MGLAttributionInfo *info in attributionInfos) { + // Feedback links are added to the Help menu. + if (info.feedbackLink) { + continue; + } + // For each attribution, add a borderless button that responds to clicks // and feels like a hyperlink. - NSURL *url = [NSURL URLWithString:MGLAttributions[i].urlString]; - NSButton *button = [[MGLAttributionButton alloc] initWithTitle:MGLAttributions[i].title URL:url]; + NSButton *button = [[MGLAttributionButton alloc] initWithAttributionInfo:info]; button.controlSize = NSMiniControlSize; button.translatesAutoresizingMaskIntoConstraints = NO; // Set the new button flush with the buttom of the container and to the // right of the previous button, with standard spacing. If there is no // previous button, align to the container instead. - NSView *previousView = self.attributionView.subviews.lastObject; - [self.attributionView addSubview:button]; - [_attributionView addConstraint: + NSView *previousView = attributionView.subviews.lastObject; + [attributionView addSubview:button]; + [attributionView addConstraint: [NSLayoutConstraint constraintWithItem:button attribute:NSLayoutAttributeBottom relatedBy:NSLayoutRelationEqual - toItem:_attributionView + toItem:attributionView attribute:NSLayoutAttributeBottom multiplier:1 constant:0]]; - [_attributionView addConstraint: + [attributionView addConstraint: [NSLayoutConstraint constraintWithItem:button attribute:NSLayoutAttributeLeading relatedBy:NSLayoutRelationEqual - toItem:previousView ? previousView : _attributionView + toItem:previousView ? previousView : attributionView attribute:previousView ? NSLayoutAttributeTrailing : NSLayoutAttributeLeading multiplier:1 constant:8]]; + [attributionView addConstraint: + [NSLayoutConstraint constraintWithItem:button + attribute:NSLayoutAttributeTop + relatedBy:NSLayoutRelationEqual + toItem:attributionView + attribute:NSLayoutAttributeTop + multiplier:1 + constant:0]]; + } + + if (attributionInfos.count) { + [attributionView addConstraint: + [NSLayoutConstraint constraintWithItem:attributionView + attribute:NSLayoutAttributeTrailing + relatedBy:NSLayoutRelationEqual + toItem:attributionView.subviews.lastObject + attribute:NSLayoutAttributeTrailing + multiplier:1 + constant:8]]; } } @@ -732,20 +749,6 @@ public: attribute:NSLayoutAttributeTrailing multiplier:1 constant:8]]; - [self addConstraint:[NSLayoutConstraint constraintWithItem:_attributionView.subviews.firstObject - attribute:NSLayoutAttributeTop - relatedBy:NSLayoutRelationEqual - toItem:_attributionView - attribute:NSLayoutAttributeTop - multiplier:1 - constant:0]]; - [self addConstraint:[NSLayoutConstraint constraintWithItem:_attributionView - attribute:NSLayoutAttributeTrailing - relatedBy:NSLayoutRelationEqual - toItem:_attributionView.subviews.lastObject - attribute:NSLayoutAttributeTrailing - multiplier:1 - constant:8]]; [super updateConstraints]; } @@ -924,6 +927,12 @@ public: } break; } + case mbgl::MapChangeSourceDidChange: + { + [self installAttributionView]; + self.needsUpdateConstraints = YES; + break; + } } } @@ -1640,6 +1649,23 @@ public: [self setDirection:-sender.doubleValue animated:YES]; } +- (IBAction)giveFeedback:(id)sender { + CLLocationCoordinate2D centerCoordinate = self.centerCoordinate; + double zoomLevel = self.zoomLevel; + NSMutableArray *urls = [NSMutableArray array]; + for (MGLAttributionInfo *info in [self.style attributionInfosWithFontSize:0 linkColor:nil]) { + NSURL *url = [info feedbackURLAtCenterCoordinate:centerCoordinate zoomLevel:zoomLevel]; + if (url) { + [urls addObject:url]; + } + } + [[NSWorkspace sharedWorkspace] openURLs:urls + withAppBundleIdentifier:nil + options:0 + additionalEventParamDescriptor:nil + launchIdentifiers:nil]; +} + #pragma mark Annotations - (nullable NS_ARRAY_OF(id ) *)annotations { @@ -2454,6 +2480,15 @@ public: return MGLFeaturesFromMBGLFeatures(features); } +#pragma mark User interface validation + +- (BOOL)validateMenuItem:(NSMenuItem *)menuItem { + if (menuItem.action == @selector(giveFeedback:)) { + return YES; + } + return [super validateMenuItem:menuItem]; +} + #pragma mark Interface Builder methods - (void)prepareForInterfaceBuilder { diff --git a/platform/macos/test/MGLAttributionButtonTests.m b/platform/macos/test/MGLAttributionButtonTests.m new file mode 100644 index 000000000..f5c0aac85 --- /dev/null +++ b/platform/macos/test/MGLAttributionButtonTests.m @@ -0,0 +1,31 @@ +#import +#import + +#import "MGLAttributionButton.h" +#import "MGLAttributionInfo.h" + +@interface MGLAttributionButtonTests : XCTestCase + +@end + +@implementation MGLAttributionButtonTests + +- (void)testPlainSymbol { + NSAttributedString *title = [[NSAttributedString alloc] initWithString:@"® & ™ Mapbox" attributes:@{ + NSUnderlineStyleAttributeName: @(NSUnderlineStyleSingle), + }]; + MGLAttributionInfo *info = [[MGLAttributionInfo alloc] initWithTitle:title URL:nil]; + MGLAttributionButton *button = [[MGLAttributionButton alloc] initWithAttributionInfo:info]; + + NSRange symbolUnderlineRange; + NSNumber *symbolUnderline = [button.attributedTitle attribute:NSUnderlineStyleAttributeName atIndex:0 effectiveRange:&symbolUnderlineRange]; + XCTAssertNil(symbolUnderline); + XCTAssertEqual(symbolUnderlineRange.length, 6); + + NSRange wordUnderlineRange; + NSNumber *wordUnderline = [button.attributedTitle attribute:NSUnderlineStyleAttributeName atIndex:6 effectiveRange:&wordUnderlineRange]; + XCTAssertEqualObjects(wordUnderline, @(NSUnderlineStyleSingle)); + XCTAssertEqual(wordUnderlineRange.length, 6); +} + +@end