diff --git a/platform/ios/MapLibre.docc/ActionJournalExample.md b/platform/ios/MapLibre.docc/ActionJournalExample.md index 8d33785b32cb..07f677b743f2 100644 --- a/platform/ios/MapLibre.docc/ActionJournalExample.md +++ b/platform/ios/MapLibre.docc/ActionJournalExample.md @@ -65,6 +65,7 @@ Enabling the action journal. ```swift let options = MLNMapOptions() options.actionJournalOptions.enabled = true + options.actionJournalOptions.renderingStatsReportInterval = 10 options.styleURL = AMERICANA_STYLE mapView = MLNMapView(frame: view.bounds, options: options) ``` diff --git a/platform/ios/MapLibre.docc/FeatureStateExample.md b/platform/ios/MapLibre.docc/FeatureStateExample.md new file mode 100644 index 000000000000..628ff58df732 --- /dev/null +++ b/platform/ios/MapLibre.docc/FeatureStateExample.md @@ -0,0 +1,234 @@ +# Set Feature State + +This example demonstrates how to use feature state to create interactive maps with dynamic styling based on user interactions. + +## Overview + +[Feature state](https://maplibre.org/maplibre-style-spec/expressions/#feature-state) allows you to assign user-defined key-value pairs to features at runtime for styling purposes. This is useful for creating interactive maps where features change appearance based on user interactions like selection, highlighting, or other states. + +@Video( + source: "FeatureState.mp4", + poster: "FeatureState.png", + alt: "A video showing how tapping on US states toggles their feature-state values." +) + +## Key Concepts + +- **Feature State**: User-defined key-value pairs assigned to features at runtime +- **Feature-State Expressions**: Style expressions that access feature state values +- **Interactive Styling**: Dynamic visual changes based on user interactions + +## Example Implementation + +### Setting Up the Map View + + + +```swift +import MapLibre +import SwiftUI +import UIKit + +class FeatureStateExampleUIKit: UIViewController, MLNMapViewDelegate { + private var mapView: MLNMapView! + + override func viewDidLoad() { + super.viewDidLoad() + + let mapView = MLNMapView(frame: view.bounds) + mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + mapView.delegate = self + + // Set initial camera position to show US states + mapView.setCenter(CLLocationCoordinate2D(latitude: 42.619626, longitude: -103.523181), zoomLevel: 3, animated: false) + view.addSubview(mapView) + + self.mapView = mapView + } +``` + +### Adding Data Source and Style Layers + + + +```swift +func mapView(_ mapView: MLNMapView, didFinishLoading style: MLNStyle) { + // Add US states GeoJSON source + let statesURL = URL(string: "https://maplibre.org/maplibre-gl-js/docs/assets/us_states.geojson")! + let statesSource = MLNShapeSource(identifier: "states", url: statesURL) + style.addSource(statesSource) + + // Add state fills layer with feature-state expressions for highlighting and selection effects + let stateFillsLayer = MLNFillStyleLayer(identifier: "state-fills", source: statesSource) + + // Use feature-state expression to change color based on selection + let fillColorExpression = NSExpression(mglJSONObject: [ + "case", + ["boolean", ["feature-state", "selected"], false], + UIColor.red.withAlphaComponent(0.7), // Selected color + UIColor.blue.withAlphaComponent(0.5), // Default color + ]) + stateFillsLayer.fillColor = fillColorExpression + + // Use feature-state expression to change opacity when highlighted + // This expression checks if the feature has a "highlighted" state set to true + let highlightedExpression = NSExpression(mglJSONObject: [ + "case", + ["boolean", ["feature-state", "highlighted"], false], + 1.0, + 0.5, + ]) + stateFillsLayer.fillOpacity = highlightedExpression + + style.addLayer(stateFillsLayer) + + // Add state borders layer with feature-state expressions for highlighting and selection effects + let stateBordersLayer = MLNLineStyleLayer(identifier: "state-borders", source: statesSource) + + // Use feature-state expression to change border color based on selection + let borderColorExpression = NSExpression(mglJSONObject: [ + "case", + ["boolean", ["feature-state", "selected"], false], + UIColor.red, // Selected border color + UIColor.blue, // Default border color + ]) + stateBordersLayer.lineColor = borderColorExpression + + // Use feature-state expression to change line width when highlighted + let borderWidthExpression = NSExpression(mglJSONObject: [ + "case", + ["boolean", ["feature-state", "highlighted"], false], + 2.0, + 1.0, + ]) + stateBordersLayer.lineWidth = borderWidthExpression + + style.addLayer(stateBordersLayer) + + // Add tap gesture recognizer to handle feature selection + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleMapTap(_:))) + mapView.addGestureRecognizer(tapGesture) + } +``` + +### Handling User Interactions + + + +```swift +@objc private func handleMapTap(_ gesture: UITapGestureRecognizer) { + let location = gesture.location(in: mapView) + let features = mapView.visibleFeatures(at: location, styleLayerIdentifiers: ["state-fills"]) + + if let feature = features.first { + // Toggle selection state + let currentState = mapView.getFeatureState(feature, sourceID: "states") + let isSelected = currentState?["selected"] as? Bool ?? false + + mapView.setFeatureState(feature, sourceID: "states", state: ["selected": !isSelected]) + + // Show alert with feature information + let stateName = feature.attributes["STATE_NAME"] as? String ?? "Unknown State" + let alert = UIAlertController( + title: "State Selected", + message: "\(stateName) is now \(!isSelected ? "selected" : "deselected")", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } + } +``` + +### SwiftUI Integration + + + +```swift +} + +struct FeatureStateExampleUIViewControllerRepresentable: UIViewControllerRepresentable { + typealias UIViewControllerType = FeatureStateExampleUIKit + + func makeUIViewController(context _: Context) -> FeatureStateExampleUIKit { + FeatureStateExampleUIKit() + } + + func updateUIViewController(_: FeatureStateExampleUIKit, context _: Context) {} +} + +// SwiftUI wrapper +struct FeatureStateExample: View { + var body: some View { + FeatureStateExampleUIViewControllerRepresentable() + .navigationTitle("Feature State") + .navigationBarTitleDisplayMode(.inline) + } +} +``` + +## Key Features Demonstrated + +### 1. Setting Feature State +```swift +// Set feature state for a specific feature +mapView.setFeatureState(feature, sourceID: "states", state: ["selected": true]) + +// Set feature state using explicit identifiers +mapView.setFeatureState("states", sourceLayer: nil, featureID: "54", state: ["selected": true]) +``` + +### 2. Getting Feature State +```swift +// Get current state of a feature +let currentState = mapView.getFeatureState(feature, sourceID: "states") +let isSelected = currentState?["selected"] as? Bool ?? false + +// Get state using explicit identifiers +let state = mapView.getFeatureState("states", sourceLayer: nil, featureID: "54") +``` + +### 3. Removing Feature State +```swift +// Remove specific state key +mapView.removeFeatureState(feature, sourceID: "states", stateKey: "selected") + +// Remove all state for a feature +mapView.removeFeatureState(feature, sourceID: "states", stateKey: nil) +``` + +### 4. Feature-State Expressions +```swift +// Create expressions that respond to feature state +let colorExpression = NSExpression(mglJSONObject: [ + "case", + ["boolean", ["feature-state", "selected"], false], + UIColor.red, // Selected color + UIColor.blue // Default color +]) +``` + +## API Reference + +### MLNMapView Feature State Methods + +- ``MLNMapView/setFeatureState:sourceID:state:`` - Set state for a feature +- ``MLNMapView/setFeatureState:sourceLayer:featureID:state:`` - Set state with explicit identifiers +- ``MLNMapView/getFeatureState:sourceID:`` - Get current state of a feature +- ``MLNMapView/getFeatureState:sourceLayer:featureID:`` - Get state with explicit identifiers +- ``MLNMapView/removeFeatureState:sourceID:stateKey:`` - Remove state from a feature +- ``MLNMapView/removeFeatureState:sourceLayer:featureID:stateKey:`` - Remove state with explicit identifiers + +## Best Practices + +1. **Use meaningful state keys**: Choose descriptive names like "selected", "highlighted", "touched" +2. **Keep state values simple**: Use basic JSON types (strings, numbers, booleans) +3. **Clear state when appropriate**: Remove state when features are no longer relevant +4. **Use feature-state expressions**: Create dynamic styling that responds to state changes +5. **Handle edge cases**: Always provide fallback values in expressions + +## Related Examples + +- [Predicates and Expressions](Predicates_and_Expressions.md) - Learn about style expressions +- [GeoJSON](GeoJSON.md) - Working with GeoJSON data sources +- [Gesture Recognizers](GestureRecognizers.md) - Handling user interactions diff --git a/platform/ios/MapLibre.docc/MapLibre.md b/platform/ios/MapLibre.docc/MapLibre.md index 32966f7627e0..3b99ca95f3c8 100644 --- a/platform/ios/MapLibre.docc/MapLibre.md +++ b/platform/ios/MapLibre.docc/MapLibre.md @@ -31,6 +31,7 @@ Powerful, free and open-source mapping toolkit with full control over data sourc - - - +- ### Map Interaction diff --git a/platform/ios/MapLibre.docc/ObserverExample.md b/platform/ios/MapLibre.docc/ObserverExample.md index f5fdc148cbf9..b9782b2e39e8 100644 --- a/platform/ios/MapLibre.docc/ObserverExample.md +++ b/platform/ios/MapLibre.docc/ObserverExample.md @@ -13,9 +13,7 @@ Observe frame rendering statistics with ``MLNMapViewDelegate/mapViewDidFinishRen ```swift -func mapViewDidFinishRenderingFrame(_: MLNMapView, fullyRendered: Bool, renderingStats: MLNRenderingStats) { - - } +func mapViewDidFinishRenderingFrame(_: MLNMapView, fullyRendered _: Bool, renderingStats _: MLNRenderingStats) {} ``` See also: ``MLNMapViewDelegate/mapViewDidFinishRenderingFrame:fullyRendered:`` and ``MLNMapViewDelegate/mapViewDidFinishRenderingFrame:fullyRendered:frameEncodingTime:frameRenderingTime:`` diff --git a/platform/ios/app-swift/Sources/FeatureStateExample.swift b/platform/ios/app-swift/Sources/FeatureStateExample.swift new file mode 100644 index 000000000000..3bddae5f39cd --- /dev/null +++ b/platform/ios/app-swift/Sources/FeatureStateExample.swift @@ -0,0 +1,135 @@ +// #-example-code(FeatureStateExampleSetup) +import MapLibre +import SwiftUI +import UIKit + +class FeatureStateExampleUIKit: UIViewController, MLNMapViewDelegate { + private var mapView: MLNMapView! + + override func viewDidLoad() { + super.viewDidLoad() + + let mapView = MLNMapView(frame: view.bounds) + mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + mapView.delegate = self + + // Set initial camera position to show US states + mapView.setCenter(CLLocationCoordinate2D(latitude: 42.619626, longitude: -103.523181), zoomLevel: 3, animated: false) + view.addSubview(mapView) + + self.mapView = mapView + } + + // #-end-example-code + + // #-example-code(FeatureStateExampleLayers) + + func mapView(_ mapView: MLNMapView, didFinishLoading style: MLNStyle) { + // Add US states GeoJSON source + let statesURL = URL(string: "https://maplibre.org/maplibre-gl-js/docs/assets/us_states.geojson")! + let statesSource = MLNShapeSource(identifier: "states", url: statesURL) + style.addSource(statesSource) + + // Add state fills layer with feature-state expressions for highlighting and selection effects + let stateFillsLayer = MLNFillStyleLayer(identifier: "state-fills", source: statesSource) + + // Use feature-state expression to change color based on selection + let fillColorExpression = NSExpression(mglJSONObject: [ + "case", + ["boolean", ["feature-state", "selected"], false], + UIColor.red.withAlphaComponent(0.7), // Selected color + UIColor.blue.withAlphaComponent(0.5), // Default color + ]) + stateFillsLayer.fillColor = fillColorExpression + + // Use feature-state expression to change opacity when highlighted + // This expression checks if the feature has a "highlighted" state set to true + let highlightedExpression = NSExpression(mglJSONObject: [ + "case", + ["boolean", ["feature-state", "highlighted"], false], + 1.0, + 0.5, + ]) + stateFillsLayer.fillOpacity = highlightedExpression + + style.addLayer(stateFillsLayer) + + // Add state borders layer with feature-state expressions for highlighting and selection effects + let stateBordersLayer = MLNLineStyleLayer(identifier: "state-borders", source: statesSource) + + // Use feature-state expression to change border color based on selection + let borderColorExpression = NSExpression(mglJSONObject: [ + "case", + ["boolean", ["feature-state", "selected"], false], + UIColor.red, // Selected border color + UIColor.blue, // Default border color + ]) + stateBordersLayer.lineColor = borderColorExpression + + // Use feature-state expression to change line width when highlighted + let borderWidthExpression = NSExpression(mglJSONObject: [ + "case", + ["boolean", ["feature-state", "highlighted"], false], + 2.0, + 1.0, + ]) + stateBordersLayer.lineWidth = borderWidthExpression + + style.addLayer(stateBordersLayer) + + // Add tap gesture recognizer to handle feature selection + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleMapTap(_:))) + mapView.addGestureRecognizer(tapGesture) + } + + // #-end-example-code + + // #-example-code(FeatureStateExampleInteraction) + + @objc private func handleMapTap(_ gesture: UITapGestureRecognizer) { + let location = gesture.location(in: mapView) + let features = mapView.visibleFeatures(at: location, styleLayerIdentifiers: ["state-fills"]) + + if let feature = features.first { + // Toggle selection state + let currentState = mapView.getFeatureState(feature, sourceID: "states") + let isSelected = currentState?["selected"] as? Bool ?? false + + mapView.setFeatureState(feature, sourceID: "states", state: ["selected": !isSelected]) + + // Show alert with feature information + let stateName = feature.attributes["STATE_NAME"] as? String ?? "Unknown State" + let alert = UIAlertController( + title: "State Selected", + message: "\(stateName) is now \(!isSelected ? "selected" : "deselected")", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } + } + // #-end-example-code + + // #-example-code(FeatureStateExampleSwiftUI) +} + +struct FeatureStateExampleUIViewControllerRepresentable: UIViewControllerRepresentable { + typealias UIViewControllerType = FeatureStateExampleUIKit + + func makeUIViewController(context _: Context) -> FeatureStateExampleUIKit { + FeatureStateExampleUIKit() + } + + func updateUIViewController(_: FeatureStateExampleUIKit, context _: Context) {} +} + +// SwiftUI wrapper +struct FeatureStateExample: View { + var body: some View { + FeatureStateExampleUIViewControllerRepresentable() + .navigationTitle("Feature State") + .navigationBarTitleDisplayMode(.inline) + } +} + +// #-end-example-code diff --git a/platform/ios/app-swift/Sources/MapLibreNavigationView.swift b/platform/ios/app-swift/Sources/MapLibreNavigationView.swift index ed9b0cac3c35..2626cf5faa4d 100644 --- a/platform/ios/app-swift/Sources/MapLibreNavigationView.swift +++ b/platform/ios/app-swift/Sources/MapLibreNavigationView.swift @@ -37,6 +37,9 @@ struct MapLibreNavigationView: View { NavigationLink("ClusteringExample") { ClusteringExampleUIViewControllerRepresentable() } + NavigationLink("FeatureStateExample") { + FeatureStateExample() + } NavigationLink("ObserverExample") { ObserverExampleViewUIViewControllerRepresentable() } diff --git a/platform/ios/src/MLNMapView.h b/platform/ios/src/MLNMapView.h index 727ea0610d5f..2d239c907600 100644 --- a/platform/ios/src/MLNMapView.h +++ b/platform/ios/src/MLNMapView.h @@ -2237,6 +2237,106 @@ vertically on the map. predicate:(nullable NSPredicate *)predicate NS_SWIFT_NAME(visibleFeatures(in:styleLayerIdentifiers:predicate:)); +// MARK: Managing Feature State + +/** + Sets the state of a feature. A feature's state is a set of user-defined key-value pairs that are + assigned to a feature at runtime. When using this method, the state object is merged with any + existing key-value pairs in the feature's state. Features are identified by their feature.id + attribute, which can be any number or string. + + This method can only be used with sources that have a feature.id attribute. The feature.id + attribute can be defined in three ways: + + For vector or GeoJSON sources, including an id attribute in the original data file. + For vector or GeoJSON sources, using the promoteId option at the time the source is defined. + For GeoJSON sources, using the generateId option to auto-assign an id based on the feature's index + in the source data. If you change feature data using map.getSource('some id').setData(..), you may + need to re-apply state taking into account updated id values. + + You can use the feature-state expression to access the values in a feature's state object for the + purposes of styling. + + @param feature The feature identifier. Feature objects returned from `visibleFeaturesAtPoint:`, + `visibleFeaturesInRect:`, or event handlers can be used as feature identifiers. + @param sourceID The identifier of the source containing the feature. + @param state A set of key-value pairs. The values should be valid JSON types. + + #### Related examples + - to learn how to use feature state for interactive styling. + */ +- (void)setFeatureState:(id)feature + sourceID:(NSString *)sourceID + state:(NSDictionary *)state; + +/** + Sets the state of a feature using explicit source and feature identifiers. + + @param sourceID The identifier of the source containing the feature. + @param sourceLayerID The identifier of the source layer containing the feature. Pass `nil` if the + source does not support source layers. + @param featureID The identifier of the feature. + @param state A set of key-value pairs. The values should be valid JSON types. + */ +- (void)setFeatureStateForSource:(NSString *)sourceID + sourceLayer:(nullable NSString *)sourceLayerID + featureID:(NSString *)featureID + state:(NSDictionary *)state; + +/** + Gets the current state of a feature. + + @param feature The feature identifier. Feature objects returned from `visibleFeaturesAtPoint:`, + `visibleFeaturesInRect:`, or event handlers can be used as feature identifiers. + @param sourceID The identifier of the source containing the feature. + @return A dictionary containing the current state of the feature, or `nil` if the feature has no + state. + */ +- (nullable NSDictionary *)getFeatureState:(id)feature + sourceID:(NSString *)sourceID; + +/** + Gets the current state of a feature using explicit source and feature identifiers. + + @param sourceID The identifier of the source containing the feature. + @param sourceLayerID The identifier of the source layer containing the feature. Pass `nil` if the + source does not support source layers. + @param featureID The identifier of the feature. + @return A dictionary containing the current state of the feature, or `nil` if the feature has no + state. + */ +- (nullable NSDictionary *)getFeatureStateForSource:(NSString *)sourceID + sourceLayer: + (nullable NSString *)sourceLayerID + featureID:(NSString *)featureID; + +/** + Removes the state of a feature. + + @param feature The feature identifier. Feature objects returned from `visibleFeaturesAtPoint:`, + `visibleFeaturesInRect:`, or event handlers can be used as feature identifiers. + @param sourceID The identifier of the source containing the feature. + @param stateKey The key of the state to remove. Pass `nil` to remove all state for the feature. + */ +- (void)removeFeatureState:(id)feature + sourceID:(NSString *)sourceID + stateKey:(nullable NSString *)stateKey; + +/** + Removes the state of a feature using explicit source and feature identifiers. + + @param sourceID The identifier of the source containing the feature. + @param sourceLayerID The identifier of the source layer containing the feature. Pass `nil` if the + source does not support source layers. + @param featureID The identifier of the feature. Pass `nil` to remove state for all features in the + source layer. + @param stateKey The key of the state to remove. Pass `nil` to remove all state for the feature. + */ +- (void)removeFeatureStateForSource:(NSString *)sourceID + sourceLayer:(nullable NSString *)sourceLayerID + featureID:(nullable NSString *)featureID + stateKey:(nullable NSString *)stateKey; + // MARK: Debugging the Map /** diff --git a/platform/ios/src/MLNMapView.mm b/platform/ios/src/MLNMapView.mm index 8a6b9542e300..0404903eacb8 100644 --- a/platform/ios/src/MLNMapView.mm +++ b/platform/ios/src/MLNMapView.mm @@ -1,5 +1,6 @@ #import "MLNMapView_Private.h" #import "MLNMapView+Impl.h" +#import "MLNStyleValue_Private.h" #include #include @@ -6811,6 +6812,119 @@ - (void)updateHeadingForDeviceOrientation return MLNFeaturesFromMBGLFeatures(features); } +// MARK: Managing Feature State + +- (void)setFeatureState:(id)feature sourceID:(NSString *)sourceID state:(NSDictionary *)state { + MLNAssertIsMainThread(); + + if (!feature || !sourceID || !state) { + return; + } + + NSString *featureID = [NSString stringWithFormat:@"%@", feature.identifier]; + [self setFeatureStateForSource:sourceID sourceLayer:nil featureID:featureID state:state]; +} + +- (void)setFeatureStateForSource:(NSString *)sourceID + sourceLayer:(nullable NSString *)sourceLayerID + featureID:(NSString *)featureID + state:(NSDictionary *)state { + MLNAssertIsMainThread(); + + if (!sourceID || !featureID || !state) { + return; + } + + mbgl::FeatureState mbglState; + for (NSString *key in state) { + id value = state[key]; + NSExpression *expression = [NSExpression expressionForConstantValue:value]; + mbglState[key.UTF8String] = expression.mgl_constantMBGLValue; + } + + _rendererFrontend->getRenderer()->setFeatureState( + sourceID.UTF8String, + sourceLayerID ? std::optional(sourceLayerID.UTF8String) : std::nullopt, + featureID.UTF8String, + mbglState + ); + + [self setNeedsRerender]; +} + +- (nullable NSDictionary *)getFeatureState:(id)feature sourceID:(NSString *)sourceID { + MLNAssertIsMainThread(); + + if (!feature || !sourceID) { + return nil; + } + + NSString *featureID = [NSString stringWithFormat:@"%@", feature.identifier]; + return [self getFeatureStateForSource:sourceID sourceLayer:nil featureID:featureID]; +} + +- (nullable NSDictionary *)getFeatureStateForSource:(NSString *)sourceID + sourceLayer:(nullable NSString *)sourceLayerID + featureID:(NSString *)featureID { + MLNAssertIsMainThread(); + + if (!sourceID || !featureID) { + return nil; + } + + mbgl::FeatureState mbglState; + _rendererFrontend->getRenderer()->getFeatureState( + mbglState, + sourceID.UTF8String, + sourceLayerID ? std::optional(sourceLayerID.UTF8String) : std::nullopt, + featureID.UTF8String + ); + + if (mbglState.empty()) { + return nil; + } + + NSMutableDictionary *state = [NSMutableDictionary dictionaryWithCapacity:mbglState.size()]; + for (const auto &pair : mbglState) { + NSString *key = [NSString stringWithUTF8String:pair.first.c_str()]; + id value = MLNJSONObjectFromMBGLValue(pair.second); + state[key] = value; + } + + return [state copy]; +} + +- (void)removeFeatureState:(id)feature sourceID:(NSString *)sourceID stateKey:(nullable NSString *)stateKey { + MLNAssertIsMainThread(); + + if (!feature || !sourceID) { + return; + } + + NSString *featureID = [NSString stringWithFormat:@"%@", feature.identifier]; + [self removeFeatureStateForSource:sourceID sourceLayer:nil featureID:featureID stateKey:stateKey]; +} + +- (void)removeFeatureStateForSource:(NSString *)sourceID + sourceLayer:(nullable NSString *)sourceLayerID + featureID:(nullable NSString *)featureID + stateKey:(nullable NSString *)stateKey { + MLNAssertIsMainThread(); + + if (!sourceID) { + return; + } + + _rendererFrontend->getRenderer()->removeFeatureState( + sourceID.UTF8String, + sourceLayerID ? std::optional(sourceLayerID.UTF8String) : std::nullopt, + featureID ? std::optional(featureID.UTF8String) : std::nullopt, + stateKey ? std::optional(stateKey.UTF8String) : std::nullopt + ); + + [self setNeedsRerender]; +} + // MARK: - Utility - - (void)animateWithDelay:(NSTimeInterval)delay animations:(void (^)(void))animations