@lugg/maps 1.0.0-beta.17 → 1.0.0-beta.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/android/src/main/java/com/luggmaps/LuggMapView.kt +33 -0
  2. package/android/src/main/java/com/luggmaps/LuggMapViewManager.kt +9 -0
  3. package/android/src/main/java/com/luggmaps/LuggMapWrapperView.kt +27 -2
  4. package/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt +110 -5
  5. package/ios/LuggMapView.mm +153 -2
  6. package/ios/LuggMapWrapperView.h +5 -0
  7. package/ios/LuggMapWrapperView.mm +18 -0
  8. package/ios/LuggMarkerView.mm +2 -0
  9. package/ios/core/AppleMapProvider.h +8 -0
  10. package/ios/core/AppleMapProvider.mm +41 -28
  11. package/ios/core/AppleStaticMapProvider.h +14 -0
  12. package/ios/core/AppleStaticMapProvider.mm +146 -0
  13. package/ios/core/GoogleMapProvider.h +10 -0
  14. package/ios/core/GoogleMapProvider.mm +47 -41
  15. package/ios/core/GoogleStaticMapProvider.h +21 -0
  16. package/ios/core/GoogleStaticMapProvider.mm +207 -0
  17. package/ios/core/MKPolylineAnimator.m +67 -43
  18. package/ios/core/MapProviderDelegate.h +6 -0
  19. package/ios/core/StaticMapProviderBase.h +70 -0
  20. package/ios/core/StaticMapProviderBase.mm +777 -0
  21. package/ios/extensions/MKMapView+Zoom.h +14 -0
  22. package/ios/extensions/MKMapView+Zoom.m +17 -15
  23. package/ios/extensions/UIView+Snapshot.h +16 -0
  24. package/ios/extensions/UIView+Snapshot.m +27 -0
  25. package/lib/module/MapView.js +4 -0
  26. package/lib/module/MapView.js.map +1 -1
  27. package/lib/module/MapView.web.js +8 -5
  28. package/lib/module/MapView.web.js.map +1 -1
  29. package/lib/module/fabric/LuggMapViewNativeComponent.ts +2 -0
  30. package/lib/typescript/src/MapView.d.ts.map +1 -1
  31. package/lib/typescript/src/MapView.types.d.ts +18 -0
  32. package/lib/typescript/src/MapView.types.d.ts.map +1 -1
  33. package/lib/typescript/src/MapView.web.d.ts.map +1 -1
  34. package/lib/typescript/src/fabric/LuggMapViewNativeComponent.d.ts +2 -0
  35. package/lib/typescript/src/fabric/LuggMapViewNativeComponent.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/MapView.tsx +4 -0
  38. package/src/MapView.types.ts +18 -0
  39. package/src/MapView.web.tsx +14 -10
  40. package/src/fabric/LuggMapViewNativeComponent.ts +2 -0
@@ -1,6 +1,7 @@
1
1
  package com.luggmaps
2
2
 
3
3
  import android.annotation.SuppressLint
4
+ import android.content.res.Configuration
4
5
  import android.view.View
5
6
  import android.view.View.VISIBLE
6
7
  import com.facebook.react.uimanager.ThemedReactContext
@@ -65,6 +66,7 @@ class LuggMapView(private val reactContext: ThemedReactContext) :
65
66
  private var rotateEnabled: Boolean = true
66
67
  private var pitchEnabled: Boolean = true
67
68
  private var compassEnabled: Boolean = true
69
+ private var staticMode: Boolean = false
68
70
  private var userLocationEnabled: Boolean = false
69
71
  private var userLocationButtonEnabled: Boolean = false
70
72
  private var poiEnabled: Boolean = true
@@ -132,8 +134,13 @@ class LuggMapView(private val reactContext: ThemedReactContext) :
132
134
  private fun initializeProvider() {
133
135
  if (provider != null || mapWrapperView == null) return
134
136
 
137
+ if (staticMode) {
138
+ applyStaticPlaceholder()
139
+ }
140
+
135
141
  val google = GoogleMapProvider(context)
136
142
  google.mapId = mapId
143
+ google.staticMode = staticMode
137
144
  google.delegate = this
138
145
  provider = google
139
146
 
@@ -154,6 +161,23 @@ class LuggMapView(private val reactContext: ThemedReactContext) :
154
161
  }
155
162
  }
156
163
 
164
+ // Shown while the map loads, until the map covers it
165
+ private fun applyStaticPlaceholder() {
166
+ // A user-provided background acts as the placeholder
167
+ if (background != null) return
168
+
169
+ val isDark = when (theme) {
170
+ "dark" -> true
171
+
172
+ "light" -> false
173
+
174
+ else ->
175
+ (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
176
+ Configuration.UI_MODE_NIGHT_YES
177
+ }
178
+ mapWrapperView?.setBackgroundColor(if (isDark) COLOR_PLACEHOLDER_DARK else COLOR_PLACEHOLDER_LIGHT)
179
+ }
180
+
157
181
  // endregion
158
182
 
159
183
  // region MapProviderDelegate
@@ -251,6 +275,10 @@ class LuggMapView(private val reactContext: ThemedReactContext) :
251
275
  provider?.setCompassEnabled(enabled)
252
276
  }
253
277
 
278
+ fun setStaticMode(enabled: Boolean) {
279
+ staticMode = enabled
280
+ }
281
+
254
282
  fun setUserLocationEnabled(enabled: Boolean) {
255
283
  if (userLocationEnabled == enabled) return
256
284
  userLocationEnabled = enabled
@@ -344,4 +372,9 @@ class LuggMapView(private val reactContext: ThemedReactContext) :
344
372
  }
345
373
 
346
374
  // endregion
375
+
376
+ companion object {
377
+ private const val COLOR_PLACEHOLDER_LIGHT = 0xFFF2F2F7.toInt()
378
+ private const val COLOR_PLACEHOLDER_DARK = 0xFF2C2C2E.toInt()
379
+ }
347
380
  }
@@ -137,6 +137,15 @@ class LuggMapViewManager :
137
137
  view.setCompassEnabled(value)
138
138
  }
139
139
 
140
+ @ReactProp(name = "staticMode", defaultBoolean = false)
141
+ override fun setStaticMode(view: LuggMapView, value: Boolean) {
142
+ view.setStaticMode(value)
143
+ }
144
+
145
+ // iOS only - lite mode doesn't need snapshot caching
146
+ @ReactProp(name = "staticKey")
147
+ override fun setStaticKey(view: LuggMapView, value: String?) = Unit
148
+
140
149
  @ReactProp(name = "userLocationEnabled", defaultBoolean = false)
141
150
  override fun setUserLocationEnabled(view: LuggMapView, value: Boolean) {
142
151
  view.setUserLocationEnabled(value)
@@ -10,11 +10,30 @@ class LuggMapWrapperView(context: ThemedReactContext) : ReactViewGroup(context)
10
10
 
11
11
  var touchEventHandler: ((MotionEvent) -> Unit)? = null
12
12
 
13
+ // React Native never runs an Android layout pass on Yoga-managed views, so
14
+ // a child's requestLayout (e.g. a lite mode MapView displaying its bitmap
15
+ // once loaded) would otherwise never result in a measure/layout. Live maps
16
+ // don't need this - their GL surface renders continuously once sized.
17
+ var relayoutChildOnRequest: Boolean = false
18
+
19
+ // Invoked on layout once the view has a non-zero size, so the map
20
+ // provider can create a map sized to the view
21
+ var onLayoutReady: (() -> Unit)? = null
22
+
23
+ private val measureAndLayoutChild = Runnable { layoutChild(width, height) }
24
+
13
25
  override fun dispatchTouchEvent(event: MotionEvent): Boolean {
14
26
  touchEventHandler?.invoke(event)
15
27
  return super.dispatchTouchEvent(event)
16
28
  }
17
29
 
30
+ override fun requestLayout() {
31
+ super.requestLayout()
32
+ if (relayoutChildOnRequest) {
33
+ post(measureAndLayoutChild)
34
+ }
35
+ }
36
+
18
37
  override fun onLayout(
19
38
  changed: Boolean,
20
39
  left: Int,
@@ -23,8 +42,14 @@ class LuggMapWrapperView(context: ThemedReactContext) : ReactViewGroup(context)
23
42
  bottom: Int
24
43
  ) {
25
44
  super.onLayout(changed, left, top, right, bottom)
26
- val w = right - left
27
- val h = bottom - top
45
+ layoutChild(right - left, bottom - top)
46
+ if (right - left > 0 && bottom - top > 0) {
47
+ onLayoutReady?.invoke()
48
+ }
49
+ }
50
+
51
+ private fun layoutChild(w: Int, h: Int) {
52
+ if (w <= 0 || h <= 0) return
28
53
  getChildAt(0)?.let {
29
54
  it.measure(
30
55
  MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY),
@@ -48,7 +48,9 @@ import com.luggmaps.LuggTileOverlayViewDelegate
48
48
  import com.luggmaps.extensions.findViewByTag
49
49
  import java.net.URL
50
50
  import kotlin.math.atan
51
+ import kotlin.math.ln
51
52
  import kotlin.math.pow
53
+ import kotlin.math.sin
52
54
  import kotlin.math.sinh
53
55
 
54
56
  class GoogleMapProvider(private val context: Context) :
@@ -78,6 +80,10 @@ class GoogleMapProvider(private val context: Context) :
78
80
 
79
81
  var mapId: String = DEMO_MAP_ID
80
82
 
83
+ // Renders a non-interactive static map — lite mode when the view fits
84
+ // the lite bitmap cap, a gesture-less full map otherwise. Creation-time only.
85
+ var staticMode: Boolean = false
86
+
81
87
  private var wrapperView: LuggMapWrapperView? = null
82
88
  private var currentNightMode: Int = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
83
89
  private var mapView: MapView? = null
@@ -96,6 +102,7 @@ class GoogleMapProvider(private val context: Context) :
96
102
  private val groundOverlayToViewMap = mutableMapOf<GroundOverlay, LuggGroundOverlayView>()
97
103
  private val markerToViewMap = mutableMapOf<Marker, LuggMarkerView>()
98
104
  private val liveMarkerViews = mutableSetOf<LuggMarkerView>()
105
+ private var staticProjectionReady = false
99
106
  private var activeNonBubbledMarker: Marker? = null
100
107
  private var tapLocation: LatLng? = null
101
108
 
@@ -137,20 +144,43 @@ class GoogleMapProvider(private val context: Context) :
137
144
  initialZoom = zoom
138
145
 
139
146
  val wrapper = wrapperView as LuggMapWrapperView
147
+ wrapper.relayoutChildOnRequest = staticMode
140
148
  this.wrapperView = wrapper
141
149
 
142
150
  context.applicationContext.registerComponentCallbacks(this)
143
151
 
144
- val options = GoogleMapOptions().mapId(mapId)
152
+ // Static mode needs the view size to pick lite vs full map, so wait
153
+ // for the first layout if it hasn't happened yet
154
+ if (staticMode && (wrapper.width == 0 || wrapper.height == 0)) {
155
+ wrapper.onLayoutReady = { createMapView() }
156
+ } else {
157
+ createMapView()
158
+ }
159
+ }
160
+
161
+ private fun createMapView() {
162
+ if (mapView != null) return
163
+ val wrapper = wrapperView ?: return
164
+
165
+ // Lite mode renders the map as a single bitmap capped at ~2048px per
166
+ // dimension, centered in the view — bigger views get letterboxed. Fall
167
+ // back to a full map (gestures disabled) when the view exceeds the cap
168
+ val useLite = staticMode && maxOf(wrapper.width, wrapper.height) <= LITE_MODE_MAX_SIZE
169
+ val options = GoogleMapOptions().liteMode(useLite)
170
+ // Lite mode doesn't support map IDs (cloud-based styling); setting one
171
+ // makes the static map render blank
172
+ if (!useLite) options.mapId(mapId)
145
173
  mapView = MapView(context, options).also { view ->
146
174
  view.onCreate(null)
147
175
  view.onResume()
148
176
  view.getMapAsync(this)
149
177
  wrapper.addView(view)
150
178
  }
179
+ wrapper.onLayoutReady = null
151
180
  }
152
181
 
153
182
  override fun destroy() {
183
+ wrapperView?.onLayoutReady = null
154
184
  detachWindowInsetsListener()
155
185
  context.applicationContext.unregisterComponentCallbacks(this)
156
186
  dismissNonBubbledCallout()
@@ -195,7 +225,18 @@ class GoogleMapProvider(private val context: Context) :
195
225
  googleMap = map
196
226
  _isMapReady = true
197
227
 
198
- val position = LatLng(initialLatitude, initialLongitude)
228
+ if (staticMode) {
229
+ // Tapping the map otherwise shows an "open in Google Maps" toolbar
230
+ map.uiSettings.isMapToolbarEnabled = false
231
+ // The projection is only valid once the map has rendered, and no
232
+ // camera events fire afterwards to correct live marker positions
233
+ map.setOnMapLoadedCallback {
234
+ staticProjectionReady = true
235
+ positionLiveMarkers()
236
+ }
237
+ }
238
+
239
+ val position = if (staticMode) staticCameraTarget() else LatLng(initialLatitude, initialLongitude)
199
240
  map.moveCamera(CameraUpdateFactory.newLatLngZoom(position, initialZoom))
200
241
 
201
242
  map.setOnCameraMoveStartedListener(this)
@@ -442,21 +483,25 @@ class GoogleMapProvider(private val context: Context) :
442
483
 
443
484
  override fun setZoomEnabled(enabled: Boolean) {
444
485
  zoomEnabled = enabled
486
+ if (staticMode) return
445
487
  googleMap?.uiSettings?.isZoomGesturesEnabled = enabled
446
488
  }
447
489
 
448
490
  override fun setScrollEnabled(enabled: Boolean) {
449
491
  scrollEnabled = enabled
492
+ if (staticMode) return
450
493
  googleMap?.uiSettings?.isScrollGesturesEnabled = enabled
451
494
  }
452
495
 
453
496
  override fun setRotateEnabled(enabled: Boolean) {
454
497
  rotateEnabled = enabled
498
+ if (staticMode) return
455
499
  googleMap?.uiSettings?.isRotateGesturesEnabled = enabled
456
500
  }
457
501
 
458
502
  override fun setPitchEnabled(enabled: Boolean) {
459
503
  pitchEnabled = enabled
504
+ if (staticMode) return
460
505
  googleMap?.uiSettings?.isTiltGesturesEnabled = enabled
461
506
  }
462
507
 
@@ -511,6 +556,11 @@ class GoogleMapProvider(private val context: Context) :
511
556
  }
512
557
 
513
558
  override fun setEdgeInsets(edgeInsets: EdgeInsets) {
559
+ if (staticMode) {
560
+ setStaticEdgeInsets(edgeInsets)
561
+ return
562
+ }
563
+
514
564
  val oldInsets = this.edgeInsets
515
565
  this.edgeInsets = edgeInsets
516
566
  applyEdgeInsets()
@@ -524,6 +574,11 @@ class GoogleMapProvider(private val context: Context) :
524
574
  }
525
575
 
526
576
  override fun setEdgeInsets(edgeInsets: EdgeInsets, duration: Int) {
577
+ if (staticMode) {
578
+ setStaticEdgeInsets(edgeInsets)
579
+ return
580
+ }
581
+
527
582
  val map = googleMap
528
583
  val oldInsets = this.edgeInsets
529
584
  this.edgeInsets = edgeInsets
@@ -737,6 +792,10 @@ class GoogleMapProvider(private val context: Context) :
737
792
 
738
793
  val contentView = markerView.contentView
739
794
  contentView.pointerEvents = com.facebook.react.uimanager.PointerEvents.NONE
795
+ // Until the lite map's projection is valid, hide the marker instead of
796
+ // flashing it at a wrong position; onMapLoaded positions and shows it
797
+ contentView.visibility =
798
+ if (staticMode && !staticProjectionReady) View.INVISIBLE else View.VISIBLE
740
799
  (contentView.parent as? android.view.ViewGroup)?.removeView(contentView)
741
800
  wrapper.addView(contentView)
742
801
  liveMarkerViews.add(markerView)
@@ -768,10 +827,12 @@ class GoogleMapProvider(private val context: Context) :
768
827
 
769
828
  private fun positionLiveMarker(markerView: LuggMarkerView) {
770
829
  val map = googleMap ?: return
830
+ if (staticMode && !staticProjectionReady) return
771
831
  val contentView = markerView.contentView
772
832
  val point = map.projection.toScreenLocation(LatLng(markerView.latitude, markerView.longitude))
773
833
  contentView.translationX = point.x - contentView.width * markerView.anchorX
774
834
  contentView.translationY = point.y - contentView.height * markerView.anchorY
835
+ contentView.visibility = View.VISIBLE
775
836
  }
776
837
 
777
838
  // endregion
@@ -813,7 +874,7 @@ class GoogleMapProvider(private val context: Context) :
813
874
  strokeColors = polylineView.strokeColors
814
875
  strokeWidth = polylineView.strokeWidth.dpToPx()
815
876
  animatedOptions = polylineView.animatedOptions
816
- animated = polylineView.animated
877
+ animated = polylineView.animated && !staticMode
817
878
  update()
818
879
  }
819
880
  }
@@ -842,7 +903,8 @@ class GoogleMapProvider(private val context: Context) :
842
903
  strokeColors = polylineView.strokeColors
843
904
  strokeWidth = polylineView.strokeWidth.dpToPx()
844
905
  animatedOptions = polylineView.animatedOptions
845
- animated = polylineView.animated
906
+ // Static maps render once; show the full polyline instead of animating
907
+ animated = polylineView.animated && !staticMode
846
908
  update()
847
909
  }
848
910
 
@@ -1204,6 +1266,12 @@ class GoogleMapProvider(private val context: Context) :
1204
1266
 
1205
1267
  private fun applyUiSettings() {
1206
1268
  googleMap?.uiSettings?.apply {
1269
+ if (staticMode) {
1270
+ // The non-lite fallback is a full map; keep it behaving like a
1271
+ // static image (lite mode ignores gestures anyway)
1272
+ setAllGesturesEnabled(false)
1273
+ return
1274
+ }
1207
1275
  isZoomGesturesEnabled = zoomEnabled
1208
1276
  isScrollGesturesEnabled = scrollEnabled
1209
1277
  isRotateGesturesEnabled = rotateEnabled
@@ -1244,10 +1312,43 @@ class GoogleMapProvider(private val context: Context) :
1244
1312
 
1245
1313
  private fun applyEdgeInsets(duration: Int = 0) {
1246
1314
  val combined = combinedEdgeInsets()
1247
- googleMap?.setPadding(combined.left, combined.top, combined.right, combined.bottom)
1315
+ // Lite mode misrenders padding: the static image is drawn only within
1316
+ // the padded viewport instead of the full view. The camera target shift
1317
+ // (staticCameraTarget) compensates for insets instead.
1318
+ if (!staticMode) {
1319
+ googleMap?.setPadding(combined.left, combined.top, combined.right, combined.bottom)
1320
+ }
1248
1321
  applyWatermarkTranslation(combined, duration)
1249
1322
  }
1250
1323
 
1324
+ // Insets are applied by shifting the camera target so the coordinate
1325
+ // centers in the inset viewport like a live map would (see
1326
+ // applyEdgeInsets for why padding can't be used)
1327
+ private fun setStaticEdgeInsets(edgeInsets: EdgeInsets) {
1328
+ val oldInsets = this.edgeInsets
1329
+ this.edgeInsets = edgeInsets
1330
+ applyEdgeInsets()
1331
+
1332
+ val map = googleMap
1333
+ if (map != null && oldInsets != edgeInsets) {
1334
+ map.moveCamera(CameraUpdateFactory.newLatLngZoom(staticCameraTarget(), initialZoom))
1335
+ positionLiveMarkers()
1336
+ }
1337
+ }
1338
+
1339
+ private fun staticCameraTarget(): LatLng {
1340
+ val offsetX = (edgeInsets.left - edgeInsets.right) / 2.0
1341
+ val offsetY = (edgeInsets.top - edgeInsets.bottom) / 2.0
1342
+ if (offsetX == 0.0 && offsetY == 0.0) return LatLng(initialLatitude, initialLongitude)
1343
+
1344
+ // Web mercator world size in px at the initial zoom
1345
+ val worldSize = 256f.dpToPx().toDouble() * 2.0.pow(initialZoom.toDouble())
1346
+ val sinLat = sin(Math.toRadians(initialLatitude))
1347
+ val x = (initialLongitude + 180.0) / 360.0 - offsetX / worldSize
1348
+ val y = 0.5 - ln((1.0 + sinLat) / (1.0 - sinLat)) / (4.0 * Math.PI) - offsetY / worldSize
1349
+ return LatLng(Math.toDegrees(atan(sinh(Math.PI * (1.0 - 2.0 * y)))), x * 360.0 - 180.0)
1350
+ }
1351
+
1251
1352
  private fun applyWatermarkTranslation(insets: EdgeInsets, duration: Int = 0) {
1252
1353
  val view = mapView ?: return
1253
1354
  view.findViewByTag("GoogleWatermark")?.let { watermark ->
@@ -1324,5 +1425,9 @@ class GoogleMapProvider(private val context: Context) :
1324
1425
 
1325
1426
  companion object {
1326
1427
  const val DEMO_MAP_ID = "DEMO_MAP_ID"
1428
+
1429
+ // Undocumented cap on the lite mode bitmap (2048 minus a 2px border,
1430
+ // measured empirically)
1431
+ private const val LITE_MODE_MAX_SIZE = 2046
1327
1432
  }
1328
1433
  }
@@ -7,7 +7,9 @@
7
7
  #import "LuggPolylineView.h"
8
8
  #import "LuggTileOverlayView.h"
9
9
  #import "core/AppleMapProvider.h"
10
+ #import "core/AppleStaticMapProvider.h"
10
11
  #import "core/GoogleMapProvider.h"
12
+ #import "core/GoogleStaticMapProvider.h"
11
13
  #import "core/MapProviderDelegate.h"
12
14
  #import "events/CameraIdleEvent.h"
13
15
  #import "events/CameraMoveEvent.h"
@@ -25,6 +27,19 @@
25
27
  using namespace facebook::react;
26
28
  using namespace luggmaps::events;
27
29
 
30
+ // Static snapshots cached across view recycling, keyed by the user-provided
31
+ // staticKey plus the map settings that affect appearance.
32
+ static NSCache<NSString *, UIImage *> *StaticSnapshotCache(void) {
33
+ static NSCache<NSString *, UIImage *> *cache;
34
+ static dispatch_once_t onceToken;
35
+ dispatch_once(&onceToken, ^{
36
+ cache = [[NSCache alloc] init];
37
+ cache.countLimit = 100;
38
+ cache.totalCostLimit = 64 * 1024 * 1024; // bytes
39
+ });
40
+ return cache;
41
+ }
42
+
28
43
  @interface LuggMapView () <RCTLuggMapViewViewProtocol, MapProviderDelegate>
29
44
  @end
30
45
 
@@ -40,6 +55,9 @@ using namespace luggmaps::events;
40
55
  BOOL _rotateEnabled;
41
56
  BOOL _pitchEnabled;
42
57
  BOOL _compassEnabled;
58
+ BOOL _staticMode;
59
+ NSString *_staticKey;
60
+ BOOL _staticSnapshotDone;
43
61
  BOOL _userLocationEnabled;
44
62
  LuggMapViewMapType _mapType;
45
63
  LuggMapViewTheme _theme;
@@ -70,6 +88,8 @@ using namespace luggmaps::events;
70
88
  _rotateEnabled = YES;
71
89
  _pitchEnabled = YES;
72
90
  _compassEnabled = YES;
91
+ _staticMode = NO;
92
+ _staticKey = @"";
73
93
  _userLocationEnabled = NO;
74
94
  _poiEnabled = NO;
75
95
  _poiFilterMode = LuggMapViewPoiFilterMode::Including;
@@ -170,17 +190,110 @@ using namespace luggmaps::events;
170
190
  [super didMoveToWindow];
171
191
  if (self.window) {
172
192
  if (!_provider && _mapWrapperView) {
173
- [self initializeProvider];
193
+ if (_staticMode) {
194
+ [self startStaticContent];
195
+ } else {
196
+ [self initializeProvider];
197
+ }
174
198
  }
175
199
  [_provider resumeAnimations];
176
200
  } else {
201
+ // A static map mid-warmup may hold an expensive live map view. Destroy
202
+ // it off-window and re-render on return instead of letting paused maps
203
+ // pile up across screen switches and all resume at once
204
+ if (_staticMode && _provider && !_staticSnapshotDone) {
205
+ [_provider destroy];
206
+ _provider = nil;
207
+ _initialized = NO;
208
+ }
177
209
  [_provider pauseAnimations];
178
210
  }
179
211
  }
180
212
 
213
+ // Shown while the map warms up, until the live map or snapshot covers it
214
+ - (void)applyStaticPlaceholderBackground {
215
+ const auto &viewProps =
216
+ *std::static_pointer_cast<LuggMapViewProps const>(_props);
217
+ if (viewProps.backgroundColor) {
218
+ // A user-provided background acts as the placeholder
219
+ return;
220
+ }
221
+
222
+ switch (_theme) {
223
+ case LuggMapViewTheme::Dark:
224
+ _mapWrapperView.overrideUserInterfaceStyle = UIUserInterfaceStyleDark;
225
+ break;
226
+ case LuggMapViewTheme::Light:
227
+ _mapWrapperView.overrideUserInterfaceStyle = UIUserInterfaceStyleLight;
228
+ break;
229
+ default:
230
+ _mapWrapperView.overrideUserInterfaceStyle =
231
+ UIUserInterfaceStyleUnspecified;
232
+ break;
233
+ }
234
+ _mapWrapperView.backgroundColor = UIColor.secondarySystemBackgroundColor;
235
+ }
236
+
237
+ - (nullable NSString *)staticSnapshotCacheKey {
238
+ if (_staticKey.length == 0 || !_mapWrapperView)
239
+ return nil;
240
+
241
+ CGSize size = _mapWrapperView.bounds.size;
242
+ if (size.width <= 0 || size.height <= 0)
243
+ return nil;
244
+
245
+ // 'system' theme snapshots depend on the current appearance
246
+ NSInteger style = _theme == LuggMapViewTheme::System
247
+ ? (NSInteger)self.traitCollection.userInterfaceStyle
248
+ : -(NSInteger)_theme;
249
+
250
+ // Camera is part of the key so a reused staticKey with a different
251
+ // location/zoom can't serve a stale snapshot
252
+ const auto &viewProps =
253
+ *std::static_pointer_cast<LuggMapViewProps const>(_props);
254
+
255
+ return [NSString
256
+ stringWithFormat:@"%@|%d|%@|%d|%ld|%.0fx%.0f|%.6f,%.6f,%.2f|%@",
257
+ _staticKey, (int)_providerType, _mapId, (int)_mapType,
258
+ (long)style, size.width, size.height,
259
+ viewProps.initialCoordinate.latitude,
260
+ viewProps.initialCoordinate.longitude,
261
+ viewProps.initialZoom,
262
+ NSStringFromUIEdgeInsets(_edgeInsets)];
263
+ }
264
+
265
+ - (void)startStaticContent {
266
+ [self applyStaticPlaceholderBackground];
267
+
268
+ // Both static providers render fully async: the placeholder shows until
269
+ // the snapshotter (Apple) or a brief live-map warmup (Google) delivers
270
+ // the base map, which reveals together with the marker/shape overlays
271
+ [self initializeProvider];
272
+ }
273
+
274
+ // staticKey identifies the map's content; when it changes (e.g. a recycled
275
+ // list row bound to a new item), any live warmup or displayed snapshot is
276
+ // stale and the static lifecycle restarts. Without this, an in-flight
277
+ // warmup caches the old item's map under the new key.
278
+ - (void)resetStaticContent {
279
+ _staticSnapshotDone = NO;
280
+
281
+ if (_provider) {
282
+ [_provider destroy];
283
+ _provider = nil;
284
+ _initialized = NO;
285
+ }
286
+
287
+ if (self.window) {
288
+ [self startStaticContent];
289
+ }
290
+ }
291
+
181
292
  - (void)prepareForRecycle {
182
293
  [super prepareForRecycle];
183
294
 
295
+ _staticSnapshotDone = NO;
296
+
184
297
  [_provider destroy];
185
298
  _provider = nil;
186
299
  _mapWrapperView = nil;
@@ -197,14 +310,28 @@ using namespace luggmaps::events;
197
310
  *std::static_pointer_cast<LuggMapViewProps const>(_props);
198
311
 
199
312
  if (_providerType == LuggMapViewProvider::Apple) {
200
- _provider = [[AppleMapProvider alloc] init];
313
+ _provider = _staticMode ? [[AppleStaticMapProvider alloc] init]
314
+ : [[AppleMapProvider alloc] init];
315
+ } else if (_staticMode) {
316
+ GoogleStaticMapProvider *google = [[GoogleStaticMapProvider alloc] init];
317
+ google.mapId = _mapId;
318
+ _provider = google;
201
319
  } else {
202
320
  GoogleMapProvider *google = [[GoogleMapProvider alloc] init];
203
321
  google.mapId = _mapId;
204
322
  _provider = google;
205
323
  }
206
324
 
325
+ if (_staticMode) {
326
+ NSString *cacheKey = [self staticSnapshotCacheKey];
327
+ if (cacheKey) {
328
+ ((StaticMapProviderBase *)_provider).cachedBaseImage =
329
+ [StaticSnapshotCache() objectForKey:cacheKey];
330
+ }
331
+ }
332
+
207
333
  _provider.delegate = self;
334
+ _provider.staticMode = _staticMode;
208
335
 
209
336
  CLLocationCoordinate2D coordinate =
210
337
  CLLocationCoordinate2DMake(viewProps.initialCoordinate.latitude,
@@ -244,6 +371,19 @@ using namespace luggmaps::events;
244
371
  ReadyEvent::emit<LuggMapViewEventEmitter>(_eventEmitter);
245
372
  }
246
373
 
374
+ - (void)mapProviderDidFinishStaticSnapshot {
375
+ _staticSnapshotDone = YES;
376
+ }
377
+
378
+ - (void)mapProviderDidCaptureStaticImage:(UIImage *)image {
379
+ NSString *key = [self staticSnapshotCacheKey];
380
+ if (key) {
381
+ NSUInteger cost = (NSUInteger)(image.size.width * image.scale *
382
+ image.size.height * image.scale * 4);
383
+ [StaticSnapshotCache() setObject:image forKey:key cost:cost];
384
+ }
385
+ }
386
+
247
387
  - (void)mapProviderDidMoveCamera:(double)latitude
248
388
  longitude:(double)longitude
249
389
  zoom:(double)zoom
@@ -303,6 +443,12 @@ using namespace luggmaps::events;
303
443
  *std::static_pointer_cast<LuggMapViewProps const>(_props);
304
444
 
305
445
  _providerType = newViewProps.provider;
446
+ _staticMode = newViewProps.staticMode;
447
+
448
+ NSString *newStaticKey =
449
+ [NSString stringWithUTF8String:newViewProps.staticKey.c_str()];
450
+ BOOL staticKeyChanged = ![newStaticKey isEqualToString:_staticKey];
451
+ _staticKey = newStaticKey;
306
452
 
307
453
  NSString *newMapId =
308
454
  [NSString stringWithUTF8String:newViewProps.mapId.c_str()];
@@ -381,6 +527,11 @@ using namespace luggmaps::events;
381
527
  }
382
528
 
383
529
  [super updateProps:props oldProps:oldProps];
530
+
531
+ // After super so the snapshot cache key sees the new camera props
532
+ if (staticKeyChanged && _staticMode && _mapWrapperView) {
533
+ [self resetStaticContent];
534
+ }
384
535
  }
385
536
 
386
537
  #pragma mark - Commands
@@ -4,6 +4,11 @@
4
4
  NS_ASSUME_NONNULL_BEGIN
5
5
 
6
6
  @interface LuggMapWrapperView : RCTViewComponentView
7
+
8
+ /// Called after layout; lets the static snapshotter start (or restart)
9
+ /// once the wrapper has a real size, since a snapshot can't autoresize
10
+ @property(nonatomic, copy, nullable) void (^layoutHandler)(void);
11
+
7
12
  @end
8
13
 
9
14
  NS_ASSUME_NONNULL_END
@@ -29,6 +29,24 @@ using namespace facebook::react;
29
29
  return self;
30
30
  }
31
31
 
32
+ - (void)layoutSubviews {
33
+ [super layoutSubviews];
34
+ if (self.layoutHandler) {
35
+ self.layoutHandler();
36
+ }
37
+ }
38
+
39
+ - (void)prepareForRecycle {
40
+ [super prepareForRecycle];
41
+
42
+ // Static placeholder styling is applied outside of props
43
+ // (LuggMapView applyStaticPlaceholderBackground) and would otherwise
44
+ // leak into the next map that reuses this recycled view
45
+ self.backgroundColor = nil;
46
+ self.overrideUserInterfaceStyle = UIUserInterfaceStyleUnspecified;
47
+ self.layoutHandler = nil;
48
+ }
49
+
32
50
  Class<RCTComponentViewProtocol> LuggMapWrapperViewCls(void) {
33
51
  return LuggMapWrapperView.class;
34
52
  }
@@ -287,6 +287,8 @@ using namespace luggmaps::events;
287
287
  _iconView.layer.anchorPoint = CGPointMake(0.5, 0.5);
288
288
  _iconView.frame = CGRectMake(0, 0, _iconView.bounds.size.width,
289
289
  _iconView.bounds.size.height);
290
+ // Static providers hide the icon view until positioned
291
+ _iconView.hidden = NO;
290
292
  }
291
293
 
292
294
  - (void)showCallout {
@@ -3,6 +3,14 @@
3
3
 
4
4
  NS_ASSUME_NONNULL_BEGIN
5
5
 
6
+ /// POI filter for the given props, shared by the live map view and the
7
+ /// static snapshotter. Returns nil for "show everything".
8
+ MKPointOfInterestFilter *_Nullable LuggPointOfInterestFilter(
9
+ BOOL poiEnabled, facebook::react::LuggMapViewPoiFilterMode filterMode,
10
+ NSArray<NSString *> *filterCategories);
11
+
12
+ MKMapType LuggMKMapTypeFromMapType(facebook::react::LuggMapViewMapType mapType);
13
+
6
14
  @interface LuggAppleMapViewContent : MKMapView
7
15
  @end
8
16