|
| 1 | +import SwiftUI |
| 2 | +@_spi(Experimental) import MapboxMaps |
| 3 | + |
| 4 | +/// Example demonstrating the experimental Appearances API for dynamic icon states. |
| 5 | +/// Shows how to use appearances with feature-state to change icon images based on user interaction. |
| 6 | +/// - Default: hotel icon |
| 7 | +/// - Currently Selected: hotel-active icon |
| 8 | +/// - Previously Clicked: hotel-clicked icon |
| 9 | +struct AppearancesExample: View { |
| 10 | + private static let currentlySelectedKey = "currentlySelected" |
| 11 | + private static let hasBeenClickedKey = "hasBeenClicked" |
| 12 | + |
| 13 | + @State private var selectedFeature: FeaturesetFeature? |
| 14 | + @State private var clickedFeatureIds: Set<String> = [] |
| 15 | + |
| 16 | + var body: some View { |
| 17 | + MapReader { proxy in |
| 18 | + Map(initialViewport: .camera(center: .pyrenees, zoom: 15.5)) { |
| 19 | + // When a hotel icon is tapped, set the currentlySelected feature state to true, |
| 20 | + // unselect the previous one if any, and store this feature both as the selected |
| 21 | + // feature and in the list of features that have been clicked |
| 22 | + TapInteraction(.layer("points")) { feature, _ in |
| 23 | + guard let map = proxy.map else { return false } |
| 24 | + |
| 25 | + // Clear the currently selected feature by resetting its feature state |
| 26 | + if let previousFeature = selectedFeature { |
| 27 | + map.setFeatureState(previousFeature, state: [Self.currentlySelectedKey: false]) |
| 28 | + } |
| 29 | + |
| 30 | + // Store this feature as the currently selected feature and in the list |
| 31 | + // of features that have been clicked |
| 32 | + if let featureId = feature.id?.id { |
| 33 | + clickedFeatureIds.insert(featureId) |
| 34 | + map.setFeatureState(feature, state: [ |
| 35 | + Self.currentlySelectedKey: true, |
| 36 | + Self.hasBeenClickedKey: true |
| 37 | + ]) |
| 38 | + selectedFeature = feature |
| 39 | + print("✅ Selected feature \(featureId)") |
| 40 | + } |
| 41 | + |
| 42 | + return true |
| 43 | + } |
| 44 | + |
| 45 | + // When the map is tapped outside of any feature, unselect the currently selected |
| 46 | + // feature if there's any, or remove all features from the list of features that |
| 47 | + // have been clicked to get back to the initial state |
| 48 | + TapInteraction { _ in |
| 49 | + guard let map = proxy.map else { return false } |
| 50 | + |
| 51 | + if let previousFeature = selectedFeature { |
| 52 | + // Unselect the currently selected feature |
| 53 | + map.setFeatureState(previousFeature, state: [Self.currentlySelectedKey: false]) |
| 54 | + selectedFeature = nil |
| 55 | + print("✅ Cleared selection") |
| 56 | + } else { |
| 57 | + // Reset the state of all features to the default one |
| 58 | + clickedFeatureIds.forEach { id in |
| 59 | + map.setFeatureState( |
| 60 | + sourceId: "points", |
| 61 | + featureId: id, |
| 62 | + state: [Self.hasBeenClickedKey: false] |
| 63 | + ) { _ in } |
| 64 | + } |
| 65 | + clickedFeatureIds.removeAll() |
| 66 | + print("✅ Reset all features") |
| 67 | + } |
| 68 | + |
| 69 | + return true |
| 70 | + } |
| 71 | + } |
| 72 | + .onStyleLoaded { _ in |
| 73 | + guard let map = proxy.map else { return } |
| 74 | + setupAppearances(map) |
| 75 | + } |
| 76 | + .mapStyle(.standard) |
| 77 | + .ignoresSafeArea() |
| 78 | + } |
| 79 | + } |
| 80 | + |
| 81 | + private func setupAppearances(_ map: MapboxMap) { |
| 82 | + // Load an image for every feature state |
| 83 | + let hotelIcon = UIImage(resource: .hotel) |
| 84 | + let hotelActiveIcon = UIImage(resource: .hotelActive) |
| 85 | + let hotelClickedIcon = UIImage(resource: .hotelClicked) |
| 86 | + |
| 87 | + do { |
| 88 | + try map.addImage(hotelIcon, id: "hotel", sdf: false) |
| 89 | + try map.addImage(hotelActiveIcon, id: "hotel-active", sdf: false) |
| 90 | + try map.addImage(hotelClickedIcon, id: "hotel-clicked", sdf: false) |
| 91 | + print("✅ Added all images (hotel, hotel-active, hotel-clicked)") |
| 92 | + } catch { |
| 93 | + print("❌ Failed to add images: \(error)") |
| 94 | + return |
| 95 | + } |
| 96 | + |
| 97 | + // Add a GeoJSON source with hotel locations |
| 98 | + let sourceJSON: [String: Any] = [ |
| 99 | + "type": "geojson", |
| 100 | + "data": [ |
| 101 | + "type": "FeatureCollection", |
| 102 | + "features": [ |
| 103 | + ["type": "Feature", "id": 1, "properties": [:], "geometry": ["type": "Point", "coordinates": [1.8452993238082342, 42.100164223399275]]], |
| 104 | + ["type": "Feature", "id": 2, "properties": [:], "geometry": ["type": "Point", "coordinates": [1.8438590191857145, 42.1004178052402]]], |
| 105 | + ["type": "Feature", "id": 3, "properties": [:], "geometry": ["type": "Point", "coordinates": [1.844225198327564, 42.10130533369667]]], |
| 106 | + ["type": "Feature", "id": 4, "properties": [:], "geometry": ["type": "Point", "coordinates": [1.8443594640122, 42.0990955459275]]], |
| 107 | + ["type": "Feature", "id": 5, "properties": [:], "geometry": ["type": "Point", "coordinates": [1.8449697625811154, 42.09869705141318]]], |
| 108 | + ["type": "Feature", "id": 6, "properties": [:], "geometry": ["type": "Point", "coordinates": [1.8471058075726603, 42.09978384873651]]], |
| 109 | + ["type": "Feature", "id": 7, "properties": [:], "geometry": ["type": "Point", "coordinates": [1.8455739474818813, 42.10182152060625]]], |
| 110 | + ["type": "Feature", "id": 8, "properties": [:], "geometry": ["type": "Point", "coordinates": [1.8427787800360136, 42.10039061289771]]], |
| 111 | + ["type": "Feature", "id": 9, "properties": [:], "geometry": ["type": "Point", "coordinates": [1.8433280487479635, 42.0994396753579]]] |
| 112 | + ] |
| 113 | + ] |
| 114 | + ] |
| 115 | + |
| 116 | + do { |
| 117 | + try map.addSource(withId: "points", properties: sourceJSON) |
| 118 | + print("✅ Added GeoJSON source") |
| 119 | + } catch { |
| 120 | + print("❌ Failed to add source: \(error)") |
| 121 | + return |
| 122 | + } |
| 123 | + |
| 124 | + // Add a layer to show an icon on every point |
| 125 | + let layerJSON: [String: Any] = [ |
| 126 | + "id": "points", |
| 127 | + "type": "symbol", |
| 128 | + "source": "points", |
| 129 | + "layout": [ |
| 130 | + "icon-allow-overlap": true, |
| 131 | + "icon-image": "hotel", |
| 132 | + "icon-size": 0.75 |
| 133 | + ], |
| 134 | + // appearances are experimental and subject to change in future versions |
| 135 | + "appearances": [ |
| 136 | + [ |
| 137 | + "name": "clicked", |
| 138 | + "condition": ["boolean", ["feature-state", Self.currentlySelectedKey], false], |
| 139 | + "properties": ["icon-image": "hotel-active"] |
| 140 | + ], |
| 141 | + [ |
| 142 | + "name": "has-been-clicked", |
| 143 | + "condition": ["boolean", ["feature-state", Self.hasBeenClickedKey], false], |
| 144 | + "properties": ["icon-image": "hotel-clicked"] |
| 145 | + ] |
| 146 | + ] |
| 147 | + ] |
| 148 | + |
| 149 | + do { |
| 150 | + try map.addLayer(with: layerJSON, layerPosition: nil) |
| 151 | + print("✅ Added symbol layer with appearances") |
| 152 | + } catch { |
| 153 | + print("❌ Failed to add layer: \(error)") |
| 154 | + } |
| 155 | + } |
| 156 | + |
| 157 | +} |
| 158 | + |
| 159 | +private extension CLLocationCoordinate2D { |
| 160 | + static let pyrenees = CLLocationCoordinate2D(latitude: 42.10025506, longitude: 1.8447281852) |
| 161 | +} |
| 162 | + |
| 163 | +struct AppearancesExample_Previews: PreviewProvider { |
| 164 | + static var previews: some View { |
| 165 | + AppearancesExample() |
| 166 | + } |
| 167 | +} |
0 commit comments