also looking at this
feat(maps): add key-free native Android maps and shared map controls
#363
28 files
+1857
-481
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -23,6 +23,8 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap | ||
| 23 | 23 | var onRegionChangeComplete: ((JSONObject) -> Unit)? = null |
| 24 | 24 | private var userChangingCamera = false |
| 25 | 25 | private var markerBearings = mutableMapOf<String, Float>() |
| 26 | private var markerPinColors = mutableMapOf<String, Int>() | |
| 27 | private val pinIcons = mutableMapOf<Int, com.google.android.gms.maps.model.BitmapDescriptor>() | |
| 26 | 28 | private var directionIcon: com.google.android.gms.maps.model.BitmapDescriptor? = null |
| 27 | 29 | private var lifecycleStarted = false |
| 28 | 30 | private var destroyed = false |
| @@ -104,6 +106,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap | ||
| 104 | 106 | runCatching { map.isMyLocationEnabled = showUserLocation } |
| 105 | 107 | val nextMarkers = mutableMapOf<String, Pair<LatLng, String?>>() |
| 106 | 108 | val nextBearings = mutableMapOf<String, Float>() |
| 109 | val nextPinColors = mutableMapOf<String, Int>() | |
| 107 | 110 | val nextHandlers = mutableMapOf<String, String>() |
| 108 | 111 | val nextPolylines = mutableMapOf<String, Triple<List<LatLng>, Int, Float>>() |
| 109 | 112 | for (index in 0 until pendingChildren.length()) { |
| @@ -118,6 +121,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap | ||
| 118 | 121 | if (type == "mapmarker" || type == "marker") { |
| 119 | 122 | points.firstOrNull()?.let { point -> |
| 120 | 123 | nextMarkers[id] = point to (props.opt("title") as? String) |
| 124 | (props.opt("pinColor") as? String)?.let { value -> runCatching { craftNativeColor(value) }.getOrNull()?.let { nextPinColors[id] = it } } | |
| 121 | 125 | (props.opt("bearing") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.let { nextBearings[id] = (it % 360).toFloat() } |
| 122 | 126 | listOf(events.opt("onPress"), events.opt("onMarkerPress")) |
| 123 | 127 | .firstNotNullOfOrNull(::craftNativeOptionalString) |
| @@ -146,18 +150,20 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap | ||
| 146 | 150 | } |
| 147 | 151 | } |
| 148 | 152 | markerBearings = nextBearings |
| 153 | markerPinColors = nextPinColors | |
| 154 | pinIcons.keys.filter { it !in markerPinColors.values }.forEach { pinIcons.remove(it) } | |
| 149 | 155 | markers.keys.filter { nextMarkers[it] == null }.forEach { markers.remove(it)?.remove() } |
| 150 | 156 | for ((id, value) in nextMarkers) { |
| 151 | 157 | val marker = markers[id] |
| 152 | 158 | if (marker != null) { |
| 153 | 159 | marker.position = value.first |
| 154 | 160 | marker.title = value.second |
| 155 | marker.setIcon(if (markerBearings.containsKey(id)) directionMarkerIcon() else BitmapDescriptorFactory.defaultMarker()) | |
| 161 | marker.setIcon(if (markerBearings.containsKey(id)) directionMarkerIcon() else pinMarkerIcon(id)) | |
| 156 | 162 | marker.isFlat = markerBearings.containsKey(id) |
| 157 | 163 | marker.rotation = markerBearings[id] ?: 0f |
| 158 | marker.setAnchor(0.5f, if (markerBearings.containsKey(id)) 0.5f else 1f) | |
| 164 | marker.setAnchor(0.5f, if (markerBearings.containsKey(id) || markerPinColors.containsKey(id)) 0.5f else 1f) | |
| 159 | 165 | } else { |
| 160 | map.addMarker(MarkerOptions().position(value.first).title(value.second).icon(if (markerBearings.containsKey(id)) directionMarkerIcon() else BitmapDescriptorFactory.defaultMarker()).flat(markerBearings.containsKey(id)).rotation(markerBearings[id] ?: 0f).anchor(0.5f, if (markerBearings.containsKey(id)) 0.5f else 1f))?.let { markers[id] = it } | |
| 166 | map.addMarker(MarkerOptions().position(value.first).title(value.second).icon(if (markerBearings.containsKey(id)) directionMarkerIcon() else pinMarkerIcon(id)).flat(markerBearings.containsKey(id)).rotation(markerBearings[id] ?: 0f).anchor(0.5f, if (markerBearings.containsKey(id) || markerPinColors.containsKey(id)) 0.5f else 1f))?.let { markers[id] = it } | |
| 161 | 167 | } |
| 162 | 168 | } |
| 163 | 169 | markerHandlers.clear() |
| @@ -261,6 +267,21 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap | ||
| 261 | 267 | } |
| 262 | 268 | } |
| 263 | 269 | |
| 270 | private fun pinMarkerIcon(id: String): com.google.android.gms.maps.model.BitmapDescriptor { | |
| 271 | val color = markerPinColors[id] ?: return BitmapDescriptorFactory.defaultMarker() | |
| 272 | return pinIcons.getOrPut(color) { | |
| 273 | val size = (18 * resources.displayMetrics.density).roundToInt().coerceAtLeast(18) | |
| 274 | val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) | |
| 275 | val canvas = Canvas(bitmap) | |
| 276 | val paint = Paint(Paint.ANTI_ALIAS_FLAG) | |
| 277 | paint.color = Color.WHITE | |
| 278 | canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint) | |
| 279 | paint.color = color | |
| 280 | canvas.drawCircle(size / 2f, size / 2f, size * 0.38f, paint) | |
| 281 | BitmapDescriptorFactory.fromBitmap(bitmap) | |
| 282 | } | |
| 283 | } | |
| 284 | ||
| 264 | 285 | private fun directionMarkerIcon(): com.google.android.gms.maps.model.BitmapDescriptor { |
| 265 | 286 | directionIcon?.let { return it } |
| 266 | 287 | val size = (26 * resources.displayMetrics.density).roundToInt().coerceAtLeast(24) |
| @@ -360,6 +381,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap | ||
| 360 | 381 | onMapPress = null |
| 361 | 382 | onRegionChangeComplete = null |
| 362 | 383 | directionIcon = null |
| 384 | pinIcons.clear(); markerPinColors.clear() | |
| 363 | 385 | tileOverlay?.remove(); tileOverlay = null |
| 364 | 386 | markerHandlers.clear() |
| 365 | 387 | markers.values.forEach { it.remove() } |
| @@ -811,6 +811,34 @@ final class NativeRenderUnitTests: XCTestCase { | ||
| 811 | 811 | XCTAssertEqual(marker.transform.b, 1, accuracy: 0.02) |
| 812 | 812 | } |
| 813 | 813 | |
| 814 | func testMapUpdatesNativePinColorAndTiltGestureWithoutReplacingTheMarker() throws { | |
| 815 | let controller = CraftNativeScreenController(config: CraftConfig()) | |
| 816 | let window = try hostWindow() | |
| 817 | window.rootViewController = controller | |
| 818 | window.makeKeyAndVisible() | |
| 819 | defer { window.isHidden = true } | |
| 820 | controller.loadViewIfNeeded() | |
| 821 | func document(_ color: String, _ pitch: Bool) -> [String: Any] { | |
| 822 | ["type": "Map", "props": ["key": "colored-map", "pitchEnabled": pitch], | |
| 823 | "style": ["width": 320, "height": 320], | |
| 824 | "children": [["type": "MapMarker", "props": ["id": "start", "geometry": [37.7, -119.5], "pinColor": color]]]] | |
| 825 | } | |
| 826 | controller.render(document("#24724b", false)) | |
| 827 | controller.view.layoutIfNeeded() | |
| 828 | let map = try XCTUnwrap(find(MKMapView.self, key: "colored-map", below: controller.view)) | |
| 829 | let annotation = try XCTUnwrap(map.annotations.first) | |
| 830 | let marker = try XCTUnwrap(map.delegate?.mapView?(map, viewFor: annotation)) | |
| 831 | XCTAssertFalse(map.isPitchEnabled) | |
| 832 | XCTAssertEqual(marker.tintColor, UIColor(red: 36 / 255, green: 114 / 255, blue: 75 / 255, alpha: 1)) | |
| 833 | XCTAssertEqual(marker.image?.renderingMode, .alwaysOriginal) | |
| 834 | controller.render(document("#2878c8", true)) | |
| 835 | controller.view.layoutIfNeeded() | |
| 836 | XCTAssertTrue(map.isPitchEnabled) | |
| 837 | XCTAssertTrue(map.annotations.first === annotation) | |
| 838 | let updated = try XCTUnwrap(map.delegate?.mapView?(map, viewFor: annotation)) | |
| 839 | XCTAssertEqual(updated.tintColor, UIColor(red: 40 / 255, green: 120 / 255, blue: 200 / 255, alpha: 1)) | |
| 840 | } | |
| 841 | ||
| 814 | 842 | func testMapUsesNativeTopographicTilesWithAttribution() throws { |
| 815 | 843 | let controller = CraftNativeScreenController(config: CraftConfig()) |
| 816 | 844 | controller.loadViewIfNeeded() |
| @@ -1171,6 +1171,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate { | ||
| 1171 | 1171 | private var authoredHeading: Double? |
| 1172 | 1172 | private var edgePadding = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24) |
| 1173 | 1173 | private var markerBearings: [String: Double] = [:] |
| 1174 | private var markerPinColors: [String: UIColor] = [:] | |
| 1174 | 1175 | private var tileOverlay: MKTileOverlay? |
| 1175 | 1176 | private var tileTemplate: String? |
| 1176 | 1177 | private lazy var tileAttribution: UILabel = { |
| @@ -1220,6 +1221,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate { | ||
| 1220 | 1221 | } |
| 1221 | 1222 | edgePadding = UIEdgeInsets(top: inset("top"), left: inset("left"), bottom: inset("bottom"), right: inset("right")) |
| 1222 | 1223 | var nextBearings: [String: Double] = [:] |
| 1224 | var nextPinColors: [String: UIColor] = [:] | |
| 1223 | 1225 | // Showing the user dot must not trigger a permission prompt from a |
| 1224 | 1226 | // renderer pass. The app's existing location capability owns that |
| 1225 | 1227 | // prompt; a map only opts in after authorization is already granted. |
| @@ -1240,6 +1242,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate { | ||
| 1240 | 1242 | if type == "mapmarker" || type == "marker" { |
| 1241 | 1243 | guard let point = points.first else { continue } |
| 1242 | 1244 | nextMarkers[id] = (point, childProps["title"] as? String) |
| 1245 | if let color = childProps["pinColor"] as? String { nextPinColors[id] = Self.color(color) } | |
| 1243 | 1246 | if let bearing = Self.numeric(childProps["bearing"]), bearing.isFinite { |
| 1244 | 1247 | nextBearings[id] = bearing.truncatingRemainder(dividingBy: 360) |
| 1245 | 1248 | } |
| @@ -1257,6 +1260,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate { | ||
| 1257 | 1260 | } |
| 1258 | 1261 | |
| 1259 | 1262 | markerBearings = nextBearings |
| 1263 | markerPinColors = nextPinColors | |
| 1260 | 1264 | let staleMarkers = annotationsByID.keys.filter { nextMarkers[$0] == nil } |
| 1261 | 1265 | removeAnnotations(staleMarkers.compactMap { annotationsByID.removeValue(forKey: $0) }) |
| 1262 | 1266 | for (id, value) in nextMarkers { |
| @@ -1420,8 +1424,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate { | ||
| 1420 | 1424 | .withRenderingMode(.alwaysOriginal) |
| 1421 | 1425 | view.transform = CGAffineTransform(rotationAngle: CGFloat((bearing - camera.heading) * .pi / 180)) |
| 1422 | 1426 | } else { |
| 1427 | view.tintColor = markerPinColors[id] ?? .systemRed | |
| 1423 | 1428 | view.image = UIImage(systemName: "mappin.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28))? |
| 1424 | .withTintColor(.systemRed, renderingMode: .alwaysOriginal) | |
| 1429 | .withTintColor(view.tintColor, renderingMode: .alwaysOriginal) | |
| 1425 | 1430 | view.transform = .identity |
| 1426 | 1431 | } |
| 1427 | 1432 | } |