diff --git a/platform/darwin/src/MLNStyle.h b/platform/darwin/src/MLNStyle.h index ced327ca3..54757147a 100644 --- a/platform/darwin/src/MLNStyle.h +++ b/platform/darwin/src/MLNStyle.h @@ -70,6 +70,20 @@ MLN_EXPORT */ @property (readonly, copy, nullable) NSString *name; +/** + * The style JSON representation of the map. + * + * Setting this property results in an asynchronous style change. If you wish to know when the style + * change is complete, observe the ``MLNMapViewDelegate/mapView:didFinishLoadingStyle:`` method + * on ``MLNMapViewDelegate``. + * + * The JSON must conform to the + * MapLibre Style Specification. + * + * @throws NSInvalidArgumentException if styleJSON is nil or invalid JSON + */ +@property (nonatomic, copy) NSString *styleJSON; + // MARK: Managing Sources /** diff --git a/platform/darwin/src/MLNStyle.mm b/platform/darwin/src/MLNStyle.mm index 48cbe1727..3f4d211ff 100644 --- a/platform/darwin/src/MLNStyle.mm +++ b/platform/darwin/src/MLNStyle.mm @@ -139,6 +139,16 @@ const MLNExceptionName MLNRedundantSourceIdentifierException = @"MLNRedundantSou return name.empty() ? nil : @(name.c_str()); } +- (NSString *)styleJSON { + std::string styleJSON = self.rawStyle->getJSON(); + // Return empty string if no style is set + return styleJSON.empty() ? @"" : @(styleJSON.c_str()); +} + +- (void)setStyleJSON:(NSString *)styleJSON { + self.rawStyle->loadJSON([styleJSON UTF8String]); +} + // MARK: Sources - (NSSet<__kindof MLNSource *> *)sources { diff --git a/platform/darwin/test/MLNMapViewTests.m b/platform/darwin/test/MLNMapViewTests.m index 06fa816fc..55d9f037d 100644 --- a/platform/darwin/test/MLNMapViewTests.m +++ b/platform/darwin/test/MLNMapViewTests.m @@ -9,11 +9,13 @@ #endif static MLNMapView *mapView; - -@interface MLNMapViewTests : XCTestCase +@interface MLNMapViewTests : XCTestCase @end -@implementation MLNMapViewTests +@implementation MLNMapViewTests { + XCTestExpectation *_styleLoadingExpectation; + XCTestExpectation *_styleLoadErrorExpectation; +} - (void)setUp { [super setUp]; @@ -21,14 +23,35 @@ static MLNMapView *mapView; [MLNSettings setApiKey:@"pk.feedcafedeadbeefbadebede"]; NSURL *styleURL = [[NSBundle bundleForClass:[self class]] URLForResource:@"one-liner" withExtension:@"json"]; mapView = [[MLNMapView alloc] initWithFrame:CGRectMake(0, 0, 64, 64) styleURL:styleURL]; + mapView.delegate = self; + if (!mapView.style) { + _styleLoadingExpectation = [self expectationWithDescription:@"Map view should finish loading style."]; + [self waitForExpectationsWithTimeout:10 handler:nil]; + } } - (void)tearDown { + _styleLoadingExpectation = nil; mapView = nil; [MLNSettings setApiKey:nil]; [super tearDown]; } +- (void)mapView:(MLNMapView *)mapView didFinishLoadingStyle:(MLNStyle *)style { + XCTAssertNotNil(mapView.style); + XCTAssertEqual(mapView.style, style); + + [_styleLoadingExpectation fulfill]; +} + +- (void)mapViewDidFailLoadingMap:(MLNMapView *)mapView withError:(NSError *)error { + XCTAssertNotNil(error); + XCTAssertEqual(error.domain, MLNErrorDomain); + if (error.code == MLNErrorCodeLoadStyleFailed || error.code == MLNErrorCodeParseStyleFailed) { + [_styleLoadErrorExpectation fulfill]; + } +} + - (void)testCoordinateBoundsConversion { [mapView setCenterCoordinate:CLLocationCoordinate2DMake(33, 179)]; @@ -160,5 +183,105 @@ static MLNMapView *mapView; XCTAssertEqual(mapView.tileCacheEnabled, YES); } +- (void)testStyleJSONWhenMapViewInitWithStyleURL { + // Test getting style JSON + NSString *styleJSON = mapView.styleJSON; + XCTAssertNotNil(styleJSON, @"Style JSON should not be nil"); + NSString * expectedJSON = @"{\"version\":8,\"sources\":{},\"layers\":[]}"; + XCTAssertEqualObjects([self normalizeJSON:expectedJSON], + [self normalizeJSON:styleJSON], + @"Style JSON should be expected"); + + // Verify the JSON is valid + NSError *error = nil; + id jsonObject = [NSJSONSerialization JSONObjectWithData:[styleJSON dataUsingEncoding:NSUTF8StringEncoding] + options:0 + error:&error]; + XCTAssertNil(error, @"Style JSON should be valid JSON"); + XCTAssertNotNil(jsonObject, @"Style JSON should parse to a valid object"); + XCTAssertTrue([jsonObject isKindOfClass:[NSDictionary class]], @"Style JSON should represent a dictionary"); +} + +- (void)testMapViewInitWithStyleJSON { + // Test setting style JSON + NSString *styleJSON = @"{\"version\": 8, \"sources\": { \"mapbox\": {\"type\": \"vector\", \"tiles\": [ \"local://tiles/{z}-{x}-{y}.mvt\" ] }}, \"layers\": [], \"metadata\": { \"test\": 1, \"type\": \"template\"}}"; + MLNMapView* mapViewWithJSON = [[MLNMapView alloc] initWithFrame:CGRectMake(0, 0, 64, 64) styleJSON:styleJSON]; + + // Verify the style was updated + NSString *loadedStyleJSON = mapViewWithJSON.styleJSON; + XCTAssertEqualObjects([self normalizeJSON:loadedStyleJSON], + [self normalizeJSON:styleJSON], + @"Style JSON should match what was set"); + + XCTAssertNotNil(mapViewWithJSON.style); + XCTAssertNotNil(mapViewWithJSON.style.sources); +} + +- (void)testUpdateStyleJSON { + _styleLoadingExpectation = nil; + _styleLoadErrorExpectation = [self expectationWithDescription:@"Style should load error"]; + + // Test setting style JSON + NSString *newStyleJSON = @"{\"version\": 8, \"sources\": { \"mapbox\": {\"type\": \"vector\", \"tiles\": [ \"local://tiles/{z}-{x}-{y}.mvt\" ] }}, \"layers\": [], \"metadata\": { \"test\": 1, \"type\": \"template\"}}"; + mapView.styleJSON = newStyleJSON; + + // Verify the style was updated + NSString *updatedStyleJSON = mapView.styleJSON; + XCTAssertEqualObjects([self normalizeJSON:updatedStyleJSON], + [self normalizeJSON:newStyleJSON], + @"Style JSON should match what was set"); + + XCTAssertNotNil(mapView.style); + XCTAssertNotNil(mapView.style.sources); + // source "org.maplibre.annotations" is added by default + XCTAssertEqual(mapView.style.sources.count, 2UL); + + // Test invalid JSON syntax + NSString *invalidJSON = @"{invalid json"; + mapView.styleJSON = invalidJSON; + [self waitForExpectations:@[_styleLoadErrorExpectation] timeout:10]; +} + +- (void)testStyleJSONAfterAddLayer { + // Test getting style JSON + NSString *styleJSON = mapView.styleJSON; + XCTAssertNotNil(styleJSON, @"Style JSON should not be nil"); + + // Add a raster tile source + MLNVectorTileSource *vectorTileSource = [[MLNVectorTileSource alloc] initWithIdentifier:@"some-identifier" tileURLTemplates:@[] options:nil]; + [mapView.style addSource:vectorTileSource]; + + // Add a layer using it + MLNFillStyleLayer *fillLayer = [[MLNFillStyleLayer alloc] initWithIdentifier:@"fillLayer" source:vectorTileSource]; + [mapView.style addLayer:fillLayer]; + + // Style JSON should not be updated + NSString *updatedStyleJSON = mapView.styleJSON; + + XCTAssertEqualObjects([self normalizeJSON:updatedStyleJSON], + [self normalizeJSON:styleJSON], + @"Style JSON should be updated"); +} + +// Helper method to normalize JSON strings for comparison +- (NSString *)normalizeJSON:(NSString *)jsonString { + NSError *error = nil; + id jsonObject = [NSJSONSerialization JSONObjectWithData:[jsonString dataUsingEncoding:NSUTF8StringEncoding] + options:0 + error:&error]; + if (error) { + return jsonString; + } + + NSData *normalizedData = [NSJSONSerialization dataWithJSONObject:jsonObject + options:0 + error:&error]; + if (error) { + return jsonString; + } + + NSString *normalizedString = [[NSString alloc] initWithData:normalizedData encoding:NSUTF8StringEncoding]; + return normalizedString ?: jsonString; +} @end diff --git a/platform/darwin/test/MLNStyleTests.mm b/platform/darwin/test/MLNStyleTests.mm index 5c4d69fbb..9f36afeef 100644 --- a/platform/darwin/test/MLNStyleTests.mm +++ b/platform/darwin/test/MLNStyleTests.mm @@ -422,4 +422,19 @@ XCTAssertFalse(self.style.performsPlacementTransitions, @"Enabling placement transitions should be NO."); } +- (void)testStyleJSON { + // Test getting style JSON + NSString *styleJSON = self.style.styleJSON; + XCTAssertNotNil(styleJSON, @"Style JSON should not be nil"); + + // Verify the JSON is valid + NSError *error = nil; + id jsonObject = [NSJSONSerialization JSONObjectWithData:[styleJSON dataUsingEncoding:NSUTF8StringEncoding] + options:0 + error:&error]; + XCTAssertNil(error, @"Style JSON should be valid JSON"); + XCTAssertNotNil(jsonObject, @"Style JSON should parse to a valid object"); + XCTAssertTrue([jsonObject isKindOfClass:[NSDictionary class]], @"Style JSON should represent a dictionary"); +} + @end diff --git a/platform/ios/src/MLNMapView.h b/platform/ios/src/MLNMapView.h index beafb9fb0..89f787841 100644 --- a/platform/ios/src/MLNMapView.h +++ b/platform/ios/src/MLNMapView.h @@ -207,6 +207,18 @@ MLN_EXPORT */ - (instancetype)initWithFrame:(CGRect)frame styleURL:(nullable NSURL *)styleURL; +/** + * Initializes and returns a newly allocated map view with the specified frame + * and style JSON. + * + * @param frame The frame for the view, measured in points. + * @param styleJSON JSON string of the map style to display. The JSON must conform to the + * MapLibre Style Specification. + * Specify `nil` for the default style. + * @return An initialized map view. + */ +- (instancetype)initWithFrame:(CGRect)frame styleJSON:(NSString *)styleJSON; + // MARK: Accessing the Delegate /** @@ -253,6 +265,20 @@ MLN_EXPORT */ @property (nonatomic, null_resettable) NSURL *styleURL; +/** + * The style JSON representation of the map. + * + * Setting this property results in an asynchronous style change. If you wish to know when the style + * change is complete, observe the ``MLNMapViewDelegate/mapView:didFinishLoadingStyle:`` method + * on ``MLNMapViewDelegate``. + * + * The JSON must conform to the + * MapLibre Style Specification. + * + * @throws NSInvalidArgumentException if styleJSON is nil or invalid JSON + */ +@property (nonatomic, copy) NSString *styleJSON; + /** Reloads the style. diff --git a/platform/ios/src/MLNMapView.mm b/platform/ios/src/MLNMapView.mm index e5329c809..da42ab8b8 100644 --- a/platform/ios/src/MLNMapView.mm +++ b/platform/ios/src/MLNMapView.mm @@ -424,6 +424,7 @@ public: @property (nonatomic, copy) MLNMapCamera *residualCamera; @property (nonatomic) MLNMapDebugMaskOptions residualDebugMask; @property (nonatomic, copy) NSURL *residualStyleURL; +@property (nonatomic, copy, nullable) NSString *initialStyleJSON; /// Tilt gesture recognizer helper @property (nonatomic, assign) CGPoint dragGestureMiddlePoint; @@ -522,6 +523,20 @@ public: return self; } +- (instancetype)initWithFrame:(CGRect)frame styleJSON:(NSString *)styleJSON +{ + if (self = [super initWithFrame:frame]) + { + MLNLogInfo(@"Starting %@ initialization.", NSStringFromClass([self class])); + MLNLogDebug(@"Initializing frame: %@ styleJSON: %@", NSStringFromCGRect(frame), styleJSON); + [self commonInit]; + self.styleJSON = styleJSON; + _initialStyleJSON = [styleJSON copy]; + MLNLogInfo(@"Finalizing %@ initialization.", NSStringFromClass([self class])); + } + return self; +} + - (instancetype)initWithCoder:(nonnull NSCoder *)decoder { if (self = [super initWithCoder:decoder]) @@ -552,6 +567,11 @@ public: return self.residualStyleURL; } + if (self.mbglMap.getStyle().getJSON().length() > 0 && + self.mbglMap.getStyle().getURL().empty()) { + return [NSURL URLWithString:@"local://style.json"]; + } + NSString *styleURLString = @(self.mbglMap.getStyle().getURL().c_str()).mgl_stringOrNilIfEmpty; MLNAssert(styleURLString, @"Invalid style URL string %@", styleURLString); return styleURLString ? [NSURL URLWithString:styleURLString] : nil; @@ -569,6 +589,16 @@ public: self.mbglMap.getStyle().loadURL([[styleURL absoluteString] UTF8String]); } +- (NSString *)styleJSON { + return self.style.styleJSON; +} + +- (void)setStyleJSON:(NSString *)styleJSON { + // Reset style and load new JSON + self.style = nil; + self.mbglMap.getStyle().loadJSON([styleJSON UTF8String]); +} + - (IBAction)reloadStyle:(__unused id)sender { MLNLogInfo(@"Reloading style."); NSURL *styleURL = self.styleURL; @@ -890,7 +920,9 @@ public: self.terminated = YES; self.residualCamera = self.camera; self.residualDebugMask = self.debugMask; - self.residualStyleURL = self.styleURL; + if (!_initialStyleJSON) { + self.residualStyleURL = self.styleURL; + } // Tear down C++ objects, insuring worker threads correctly terminate. // Because of how _mbglMap is constructed, we need to destroy it first.