@lugg/maps 1.0.0-beta.9 → 1.0.0

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 (62) hide show
  1. package/README.md +15 -100
  2. package/android/src/main/java/com/luggmaps/LuggCalloutView.kt +5 -0
  3. package/android/src/main/java/com/luggmaps/LuggMapView.kt +41 -0
  4. package/android/src/main/java/com/luggmaps/LuggMapViewManager.kt +14 -0
  5. package/android/src/main/java/com/luggmaps/LuggMapWrapperView.kt +27 -2
  6. package/android/src/main/java/com/luggmaps/LuggMarkerView.kt +7 -2
  7. package/android/src/main/java/com/luggmaps/LuggPolylineView.kt +2 -1
  8. package/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt +307 -41
  9. package/android/src/main/java/com/luggmaps/core/MapProviderDelegate.kt +1 -0
  10. package/android/src/main/java/com/luggmaps/core/PolylineAnimator.kt +61 -10
  11. package/ios/LuggMapView.mm +170 -2
  12. package/ios/LuggMapWrapperView.h +5 -0
  13. package/ios/LuggMapWrapperView.mm +18 -0
  14. package/ios/LuggMarkerView.mm +2 -0
  15. package/ios/core/AppleMapProvider.h +8 -0
  16. package/ios/core/AppleMapProvider.mm +48 -31
  17. package/ios/core/AppleStaticMapProvider.h +14 -0
  18. package/ios/core/AppleStaticMapProvider.mm +159 -0
  19. package/ios/core/GMSPolylineAnimator.m +40 -15
  20. package/ios/core/GoogleMapProvider.h +10 -0
  21. package/ios/core/GoogleMapProvider.mm +56 -44
  22. package/ios/core/GoogleStaticMapProvider.h +21 -0
  23. package/ios/core/GoogleStaticMapProvider.mm +213 -0
  24. package/ios/core/MKPolylineAnimator.m +67 -43
  25. package/ios/core/MapProviderDelegate.h +7 -0
  26. package/ios/core/StaticMapProviderBase.h +75 -0
  27. package/ios/core/StaticMapProviderBase.mm +845 -0
  28. package/ios/events/CameraIdleEvent.h +4 -2
  29. package/ios/events/CameraMoveEvent.h +4 -2
  30. package/ios/events/CirclePressEvent.h +4 -2
  31. package/ios/events/GroundOverlayPressEvent.h +4 -2
  32. package/ios/events/LongPressEvent.h +4 -2
  33. package/ios/events/MarkerDragEvent.h +12 -6
  34. package/ios/events/MarkerPressEvent.h +4 -2
  35. package/ios/events/PolygonPressEvent.h +4 -2
  36. package/ios/events/PressEvent.h +4 -2
  37. package/ios/events/ReadyEvent.h +4 -2
  38. package/ios/events/TileOverlayPressEvent.h +4 -2
  39. package/ios/extensions/MKMapView+Zoom.h +20 -0
  40. package/ios/extensions/MKMapView+Zoom.m +28 -18
  41. package/ios/extensions/UIView+Snapshot.h +16 -0
  42. package/ios/extensions/UIView+Snapshot.m +27 -0
  43. package/lib/module/MapView.js +7 -0
  44. package/lib/module/MapView.js.map +1 -1
  45. package/lib/module/MapView.web.js +9 -4
  46. package/lib/module/MapView.web.js.map +1 -1
  47. package/lib/module/fabric/LuggMapViewNativeComponent.ts +3 -0
  48. package/lib/typescript/src/MapView.d.ts.map +1 -1
  49. package/lib/typescript/src/MapView.types.d.ts +24 -0
  50. package/lib/typescript/src/MapView.types.d.ts.map +1 -1
  51. package/lib/typescript/src/MapView.web.d.ts.map +1 -1
  52. package/lib/typescript/src/fabric/LuggMapViewNativeComponent.d.ts +3 -0
  53. package/lib/typescript/src/fabric/LuggMapViewNativeComponent.d.ts.map +1 -1
  54. package/package.json +19 -12
  55. package/src/MapView.tsx +7 -0
  56. package/src/MapView.types.ts +24 -0
  57. package/src/MapView.web.tsx +15 -9
  58. package/src/fabric/LuggMapViewNativeComponent.ts +3 -0
  59. package/plugin/build/withMapsAndroid.d.ts +0 -5
  60. package/plugin/build/withMapsAndroid.js +0 -15
  61. package/plugin/build/withMapsIOS.d.ts +0 -5
  62. package/plugin/build/withMapsIOS.js +0 -27
@@ -5,6 +5,7 @@ import android.content.ComponentCallbacks2
5
5
  import android.content.Context
6
6
  import android.content.res.Configuration
7
7
  import android.view.View
8
+ import android.view.ViewTreeObserver
8
9
  import android.widget.ImageView
9
10
  import androidx.core.graphics.createBitmap
10
11
  import androidx.core.view.ViewCompat
@@ -15,9 +16,8 @@ import com.google.android.gms.maps.GoogleMap
15
16
  import com.google.android.gms.maps.GoogleMapOptions
16
17
  import com.google.android.gms.maps.MapView
17
18
  import com.google.android.gms.maps.OnMapReadyCallback
18
- import com.google.android.gms.maps.model.AdvancedMarker
19
- import com.google.android.gms.maps.model.AdvancedMarkerOptions
20
19
  import com.google.android.gms.maps.model.BitmapDescriptorFactory
20
+ import com.google.android.gms.maps.model.CameraPosition
21
21
  import com.google.android.gms.maps.model.Circle
22
22
  import com.google.android.gms.maps.model.CircleOptions
23
23
  import com.google.android.gms.maps.model.GroundOverlay
@@ -26,6 +26,7 @@ import com.google.android.gms.maps.model.LatLng
26
26
  import com.google.android.gms.maps.model.LatLngBounds
27
27
  import com.google.android.gms.maps.model.MapColorScheme
28
28
  import com.google.android.gms.maps.model.Marker
29
+ import com.google.android.gms.maps.model.MarkerOptions
29
30
  import com.google.android.gms.maps.model.Polygon
30
31
  import com.google.android.gms.maps.model.PolygonOptions
31
32
  import com.google.android.gms.maps.model.PolylineOptions
@@ -48,7 +49,10 @@ import com.luggmaps.LuggTileOverlayViewDelegate
48
49
  import com.luggmaps.extensions.findViewByTag
49
50
  import java.net.URL
50
51
  import kotlin.math.atan
52
+ import kotlin.math.ln
53
+ import kotlin.math.log2
51
54
  import kotlin.math.pow
55
+ import kotlin.math.sin
52
56
  import kotlin.math.sinh
53
57
 
54
58
  class GoogleMapProvider(private val context: Context) :
@@ -78,6 +82,10 @@ class GoogleMapProvider(private val context: Context) :
78
82
 
79
83
  var mapId: String = DEMO_MAP_ID
80
84
 
85
+ // Renders a non-interactive static map — lite mode when the view fits
86
+ // the lite bitmap cap, a gesture-less full map otherwise. Creation-time only.
87
+ var staticMode: Boolean = false
88
+
81
89
  private var wrapperView: LuggMapWrapperView? = null
82
90
  private var currentNightMode: Int = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
83
91
  private var mapView: MapView? = null
@@ -96,6 +104,7 @@ class GoogleMapProvider(private val context: Context) :
96
104
  private val groundOverlayToViewMap = mutableMapOf<GroundOverlay, LuggGroundOverlayView>()
97
105
  private val markerToViewMap = mutableMapOf<Marker, LuggMarkerView>()
98
106
  private val liveMarkerViews = mutableSetOf<LuggMarkerView>()
107
+ private var staticProjectionReady = false
99
108
  private var activeNonBubbledMarker: Marker? = null
100
109
  private var tapLocation: LatLng? = null
101
110
 
@@ -109,6 +118,7 @@ class GoogleMapProvider(private val context: Context) :
109
118
  private var scrollEnabled: Boolean = true
110
119
  private var rotateEnabled: Boolean = true
111
120
  private var pitchEnabled: Boolean = true
121
+ private var compassEnabled: Boolean = true
112
122
  private var userLocationEnabled: Boolean = false
113
123
  private var userLocationButtonEnabled: Boolean = false
114
124
 
@@ -126,6 +136,13 @@ class GoogleMapProvider(private val context: Context) :
126
136
  private var insetAdjustment: String = "never"
127
137
  private var systemInsets: EdgeInsets = EdgeInsets()
128
138
 
139
+ // The maps renderer adds and positions the watermark asynchronously; the
140
+ // layout listener re-evaluates the translation on every layout pass
141
+ private var watermarkLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
142
+ private var watermarkView: View? = null
143
+ private var watermarkTargetX = 0f
144
+ private var watermarkTargetY = 0f
145
+
129
146
  // region MapProvider
130
147
 
131
148
  override fun initializeMap(wrapperView: View, latitude: Double, longitude: Double, zoom: Float) {
@@ -136,21 +153,45 @@ class GoogleMapProvider(private val context: Context) :
136
153
  initialZoom = zoom
137
154
 
138
155
  val wrapper = wrapperView as LuggMapWrapperView
156
+ wrapper.relayoutChildOnRequest = staticMode
139
157
  this.wrapperView = wrapper
140
158
 
141
159
  context.applicationContext.registerComponentCallbacks(this)
142
160
 
143
- val options = GoogleMapOptions().mapId(mapId)
161
+ // Static mode needs the view size to pick lite vs full map, so wait
162
+ // for the first layout if it hasn't happened yet
163
+ if (staticMode && (wrapper.width == 0 || wrapper.height == 0)) {
164
+ wrapper.onLayoutReady = { createMapView() }
165
+ } else {
166
+ createMapView()
167
+ }
168
+ }
169
+
170
+ private fun createMapView() {
171
+ if (mapView != null) return
172
+ val wrapper = wrapperView ?: return
173
+
174
+ // Lite mode renders the map as a single bitmap capped at ~2048px per
175
+ // dimension, centered in the view — bigger views get letterboxed. Fall
176
+ // back to a full map (gestures disabled) when the view exceeds the cap
177
+ val useLite = staticMode && maxOf(wrapper.width, wrapper.height) <= LITE_MODE_MAX_SIZE
178
+ val options = GoogleMapOptions().liteMode(useLite)
179
+ // Lite mode doesn't support map IDs (cloud-based styling); setting one
180
+ // makes the static map render blank
181
+ if (!useLite) options.mapId(mapId)
144
182
  mapView = MapView(context, options).also { view ->
145
183
  view.onCreate(null)
146
184
  view.onResume()
147
185
  view.getMapAsync(this)
148
186
  wrapper.addView(view)
149
187
  }
188
+ wrapper.onLayoutReady = null
150
189
  }
151
190
 
152
191
  override fun destroy() {
192
+ wrapperView?.onLayoutReady = null
153
193
  detachWindowInsetsListener()
194
+ detachWatermarkLayoutListener()
154
195
  context.applicationContext.unregisterComponentCallbacks(this)
155
196
  dismissNonBubbledCallout()
156
197
  for (markerView in liveMarkerViews) {
@@ -194,7 +235,23 @@ class GoogleMapProvider(private val context: Context) :
194
235
  googleMap = map
195
236
  _isMapReady = true
196
237
 
197
- val position = LatLng(initialLatitude, initialLongitude)
238
+ if (staticMode) {
239
+ // Tapping the map otherwise shows an "open in Google Maps" toolbar
240
+ map.uiSettings.isMapToolbarEnabled = false
241
+ // The projection is only valid once the map has rendered, and no
242
+ // camera events fire afterwards to correct live marker positions
243
+ map.setOnMapLoadedCallback {
244
+ staticProjectionReady = true
245
+ positionLiveMarkers()
246
+ }
247
+ }
248
+
249
+ // Padding must be in place before the initial camera move: setPadding
250
+ // never repositions the current camera, but camera updates after it are
251
+ // relative to the padded center
252
+ applyEdgeInsets()
253
+
254
+ val position = if (staticMode) staticCameraTarget() else LatLng(initialLatitude, initialLongitude)
198
255
  map.moveCamera(CameraUpdateFactory.newLatLngZoom(position, initialZoom))
199
256
 
200
257
  map.setOnCameraMoveStartedListener(this)
@@ -217,7 +274,6 @@ class GoogleMapProvider(private val context: Context) :
217
274
 
218
275
  applyUiSettings()
219
276
  applyZoomLimits()
220
- applyEdgeInsets()
221
277
  applyInsetAdjustment()
222
278
  applyTheme()
223
279
  applyUserLocation()
@@ -441,24 +497,33 @@ class GoogleMapProvider(private val context: Context) :
441
497
 
442
498
  override fun setZoomEnabled(enabled: Boolean) {
443
499
  zoomEnabled = enabled
500
+ if (staticMode) return
444
501
  googleMap?.uiSettings?.isZoomGesturesEnabled = enabled
445
502
  }
446
503
 
447
504
  override fun setScrollEnabled(enabled: Boolean) {
448
505
  scrollEnabled = enabled
506
+ if (staticMode) return
449
507
  googleMap?.uiSettings?.isScrollGesturesEnabled = enabled
450
508
  }
451
509
 
452
510
  override fun setRotateEnabled(enabled: Boolean) {
453
511
  rotateEnabled = enabled
512
+ if (staticMode) return
454
513
  googleMap?.uiSettings?.isRotateGesturesEnabled = enabled
455
514
  }
456
515
 
457
516
  override fun setPitchEnabled(enabled: Boolean) {
458
517
  pitchEnabled = enabled
518
+ if (staticMode) return
459
519
  googleMap?.uiSettings?.isTiltGesturesEnabled = enabled
460
520
  }
461
521
 
522
+ override fun setCompassEnabled(enabled: Boolean) {
523
+ compassEnabled = enabled
524
+ googleMap?.uiSettings?.isCompassEnabled = enabled
525
+ }
526
+
462
527
  @SuppressLint("MissingPermission")
463
528
  override fun setUserLocationEnabled(enabled: Boolean) {
464
529
  userLocationEnabled = enabled
@@ -505,6 +570,11 @@ class GoogleMapProvider(private val context: Context) :
505
570
  }
506
571
 
507
572
  override fun setEdgeInsets(edgeInsets: EdgeInsets) {
573
+ if (staticMode) {
574
+ setStaticEdgeInsets(edgeInsets)
575
+ return
576
+ }
577
+
508
578
  val oldInsets = this.edgeInsets
509
579
  this.edgeInsets = edgeInsets
510
580
  applyEdgeInsets()
@@ -518,6 +588,11 @@ class GoogleMapProvider(private val context: Context) :
518
588
  }
519
589
 
520
590
  override fun setEdgeInsets(edgeInsets: EdgeInsets, duration: Int) {
591
+ if (staticMode) {
592
+ setStaticEdgeInsets(edgeInsets)
593
+ return
594
+ }
595
+
521
596
  val map = googleMap
522
597
  val oldInsets = this.edgeInsets
523
598
  this.edgeInsets = edgeInsets
@@ -599,6 +674,10 @@ class GoogleMapProvider(private val context: Context) :
599
674
  syncPolylineView(polylineView)
600
675
  }
601
676
 
677
+ override fun polylineViewDidDrop(polylineView: LuggPolylineView) {
678
+ teardownPolyline(polylineView)
679
+ }
680
+
602
681
  // endregion
603
682
 
604
683
  // region PolygonViewDelegate
@@ -692,12 +771,12 @@ class GoogleMapProvider(private val context: Context) :
692
771
  val map = googleMap ?: return
693
772
 
694
773
  val position = LatLng(markerView.latitude, markerView.longitude)
695
- val options = AdvancedMarkerOptions()
774
+ val options = MarkerOptions()
696
775
  .position(position)
697
776
  .title(markerView.title)
698
777
  .snippet(markerView.description)
699
778
 
700
- val marker = map.addMarker(options) as AdvancedMarker
779
+ val marker = map.addMarker(options) ?: return
701
780
  marker.setAnchor(markerView.anchorX, markerView.anchorY)
702
781
  marker.zIndex = markerView.zIndex
703
782
  marker.rotation = markerView.rotate
@@ -715,9 +794,8 @@ class GoogleMapProvider(private val context: Context) :
715
794
  }
716
795
  }
717
796
 
718
- // Workaround: AdvancedMarker.iconView is buggy on Android, so we manually add the custom
719
- // content view to the wrapper and position it via screen projection instead. The underlying
720
- // marker uses a transparent bitmap matching the content size so taps still trigger onMarkerClick.
797
+ // Live marker: content view is added to the wrapper and positioned via screen projection.
798
+ // The underlying marker uses a transparent bitmap matching the content size so taps still trigger onMarkerClick.
721
799
  private fun showLiveMarker(markerView: LuggMarkerView) {
722
800
  val wrapper = wrapperView ?: return
723
801
 
@@ -728,6 +806,10 @@ class GoogleMapProvider(private val context: Context) :
728
806
 
729
807
  val contentView = markerView.contentView
730
808
  contentView.pointerEvents = com.facebook.react.uimanager.PointerEvents.NONE
809
+ // Until the lite map's projection is valid, hide the marker instead of
810
+ // flashing it at a wrong position; onMapLoaded positions and shows it
811
+ contentView.visibility =
812
+ if (staticMode && !staticProjectionReady) View.INVISIBLE else View.VISIBLE
731
813
  (contentView.parent as? android.view.ViewGroup)?.removeView(contentView)
732
814
  wrapper.addView(contentView)
733
815
  liveMarkerViews.add(markerView)
@@ -759,10 +841,12 @@ class GoogleMapProvider(private val context: Context) :
759
841
 
760
842
  private fun positionLiveMarker(markerView: LuggMarkerView) {
761
843
  val map = googleMap ?: return
844
+ if (staticMode && !staticProjectionReady) return
762
845
  val contentView = markerView.contentView
763
846
  val point = map.projection.toScreenLocation(LatLng(markerView.latitude, markerView.longitude))
764
847
  contentView.translationX = point.x - contentView.width * markerView.anchorX
765
848
  contentView.translationY = point.y - contentView.height * markerView.anchorY
849
+ contentView.visibility = View.VISIBLE
766
850
  }
767
851
 
768
852
  // endregion
@@ -775,8 +859,12 @@ class GoogleMapProvider(private val context: Context) :
775
859
  }
776
860
 
777
861
  override fun removePolylineView(polylineView: LuggPolylineView) {
778
- polylineAnimators[polylineView]?.destroy()
779
- polylineAnimators.remove(polylineView)
862
+ teardownPolyline(polylineView)
863
+ }
864
+
865
+ private fun teardownPolyline(polylineView: LuggPolylineView) {
866
+ pendingPolylineViews.remove(polylineView)
867
+ polylineAnimators.remove(polylineView)?.destroy()
780
868
  polylineView.polyline?.remove()
781
869
  polylineView.polyline = null
782
870
  }
@@ -792,15 +880,13 @@ class GoogleMapProvider(private val context: Context) :
792
880
  return
793
881
  }
794
882
 
795
- polylineView.polyline?.width = polylineView.strokeWidth.dpToPx()
796
- polylineView.polyline?.zIndex = polylineView.zIndex
797
-
798
883
  polylineAnimators[polylineView]?.apply {
799
884
  coordinates = polylineView.coordinates
800
885
  strokeColors = polylineView.strokeColors
801
886
  strokeWidth = polylineView.strokeWidth.dpToPx()
887
+ zIndex = polylineView.zIndex
802
888
  animatedOptions = polylineView.animatedOptions
803
- animated = polylineView.animated
889
+ animated = polylineView.animated && !staticMode
804
890
  update()
805
891
  }
806
892
  }
@@ -814,6 +900,8 @@ class GoogleMapProvider(private val context: Context) :
814
900
  private fun addPolylineViewToMap(polylineView: LuggPolylineView) {
815
901
  val map = googleMap ?: return
816
902
 
903
+ polylineAnimators.remove(polylineView)?.destroy()
904
+
817
905
  val options = PolylineOptions()
818
906
  .width(polylineView.strokeWidth.dpToPx())
819
907
  .zIndex(polylineView.zIndex)
@@ -826,8 +914,10 @@ class GoogleMapProvider(private val context: Context) :
826
914
  coordinates = polylineView.coordinates
827
915
  strokeColors = polylineView.strokeColors
828
916
  strokeWidth = polylineView.strokeWidth.dpToPx()
917
+ zIndex = polylineView.zIndex
829
918
  animatedOptions = polylineView.animatedOptions
830
- animated = polylineView.animated
919
+ // Static maps render once; show the full polyline instead of animating
920
+ animated = polylineView.animated && !staticMode
831
921
  update()
832
922
  }
833
923
 
@@ -1126,10 +1216,25 @@ class GoogleMapProvider(private val context: Context) :
1126
1216
  // region Commands
1127
1217
 
1128
1218
  override fun moveCamera(latitude: Double, longitude: Double, zoom: Double, duration: Int) {
1219
+ if (staticMode) {
1220
+ // Static maps don't animate; re-center at the final camera
1221
+ initialLatitude = latitude
1222
+ initialLongitude = longitude
1223
+ if (zoom > 0) initialZoom = zoom.toFloat()
1224
+ val map = googleMap ?: return
1225
+ map.moveCamera(CameraUpdateFactory.newLatLngZoom(staticCameraTarget(), initialZoom))
1226
+ positionLiveMarkers()
1227
+ return
1228
+ }
1229
+
1129
1230
  val map = googleMap ?: return
1130
1231
  val position = LatLng(latitude, longitude)
1131
1232
  val targetZoom = if (zoom > 0) zoom.toFloat() else map.cameraPosition.zoom
1132
- val cameraUpdate = CameraUpdateFactory.newLatLngZoom(position, targetZoom)
1233
+ val cameraPosition = CameraPosition.Builder(map.cameraPosition)
1234
+ .target(position)
1235
+ .zoom(targetZoom)
1236
+ .build()
1237
+ val cameraUpdate = CameraUpdateFactory.newCameraPosition(cameraPosition)
1133
1238
  when {
1134
1239
  duration < 0 -> map.animateCamera(cameraUpdate)
1135
1240
  duration > 0 -> map.animateCamera(cameraUpdate, duration, null)
@@ -1151,15 +1256,20 @@ class GoogleMapProvider(private val context: Context) :
1151
1256
  val latLongs = coordinates.filterIsInstance<LatLng>()
1152
1257
  if (latLongs.isEmpty()) return
1153
1258
 
1154
- val boundsBuilder = LatLngBounds.Builder()
1155
- latLongs.forEach { boundsBuilder.include(it) }
1156
- val bounds = boundsBuilder.build()
1157
-
1158
1259
  val top = edgeInsetsTop.toFloat().dpToPx().toInt()
1159
1260
  val left = edgeInsetsLeft.toFloat().dpToPx().toInt()
1160
1261
  val bottom = edgeInsetsBottom.toFloat().dpToPx().toInt()
1161
1262
  val right = edgeInsetsRight.toFloat().dpToPx().toInt()
1162
1263
 
1264
+ if (staticMode) {
1265
+ fitStaticCoordinates(latLongs, top, left, bottom, right)
1266
+ return
1267
+ }
1268
+
1269
+ val boundsBuilder = LatLngBounds.Builder()
1270
+ latLongs.forEach { boundsBuilder.include(it) }
1271
+ val bounds = boundsBuilder.build()
1272
+
1163
1273
  val combined = combinedEdgeInsets()
1164
1274
  map.setPadding(
1165
1275
  combined.left + left,
@@ -1185,10 +1295,17 @@ class GoogleMapProvider(private val context: Context) :
1185
1295
 
1186
1296
  private fun applyUiSettings() {
1187
1297
  googleMap?.uiSettings?.apply {
1298
+ if (staticMode) {
1299
+ // The non-lite fallback is a full map; keep it behaving like a
1300
+ // static image (lite mode ignores gestures anyway)
1301
+ setAllGesturesEnabled(false)
1302
+ return
1303
+ }
1188
1304
  isZoomGesturesEnabled = zoomEnabled
1189
1305
  isScrollGesturesEnabled = scrollEnabled
1190
1306
  isRotateGesturesEnabled = rotateEnabled
1191
1307
  isTiltGesturesEnabled = pitchEnabled
1308
+ isCompassEnabled = compassEnabled
1192
1309
  isMyLocationButtonEnabled = userLocationButtonEnabled
1193
1310
  }
1194
1311
  }
@@ -1224,33 +1341,178 @@ class GoogleMapProvider(private val context: Context) :
1224
1341
 
1225
1342
  private fun applyEdgeInsets(duration: Int = 0) {
1226
1343
  val combined = combinedEdgeInsets()
1227
- googleMap?.setPadding(combined.left, combined.top, combined.right, combined.bottom)
1344
+ // Lite mode misrenders padding: the static image is drawn only within
1345
+ // the padded viewport instead of the full view. The camera target shift
1346
+ // (staticCameraTarget) compensates for insets instead.
1347
+ if (!staticMode) {
1348
+ googleMap?.setPadding(combined.left, combined.top, combined.right, combined.bottom)
1349
+ }
1228
1350
  applyWatermarkTranslation(combined, duration)
1229
1351
  }
1230
1352
 
1353
+ // Insets are applied by shifting the camera target so the coordinate
1354
+ // centers in the inset viewport like a live map would (see
1355
+ // applyEdgeInsets for why padding can't be used)
1356
+ private fun setStaticEdgeInsets(edgeInsets: EdgeInsets) {
1357
+ val oldInsets = this.edgeInsets
1358
+ this.edgeInsets = edgeInsets
1359
+ applyEdgeInsets()
1360
+
1361
+ val map = googleMap
1362
+ if (map != null && oldInsets != edgeInsets) {
1363
+ map.moveCamera(CameraUpdateFactory.newLatLngZoom(staticCameraTarget(), initialZoom))
1364
+ positionLiveMarkers()
1365
+ }
1366
+ }
1367
+
1368
+ private fun staticCameraTarget(): LatLng {
1369
+ val offsetX = (edgeInsets.left - edgeInsets.right) / 2.0
1370
+ val offsetY = (edgeInsets.top - edgeInsets.bottom) / 2.0
1371
+ if (offsetX == 0.0 && offsetY == 0.0) return LatLng(initialLatitude, initialLongitude)
1372
+
1373
+ val worldSize = mercatorWorldSize(initialZoom)
1374
+ return latLngFromMercator(
1375
+ mercatorX(initialLongitude) - offsetX / worldSize,
1376
+ mercatorY(initialLatitude) - offsetY / worldSize
1377
+ )
1378
+ }
1379
+
1380
+ // A static camera can't fit with map padding (lite mode misrenders it);
1381
+ // compute the fitted camera in mercator space instead, shifting the
1382
+ // center for asymmetric padding like edge insets
1383
+ private fun fitStaticCoordinates(
1384
+ coordinates: List<LatLng>,
1385
+ top: Int,
1386
+ left: Int,
1387
+ bottom: Int,
1388
+ right: Int
1389
+ ) {
1390
+ val map = googleMap ?: return
1391
+ val wrapper = wrapperView ?: return
1392
+ if (wrapper.width == 0 || wrapper.height == 0) return
1393
+
1394
+ var minX = Double.MAX_VALUE
1395
+ var minY = Double.MAX_VALUE
1396
+ var maxX = -Double.MAX_VALUE
1397
+ var maxY = -Double.MAX_VALUE
1398
+ for (coordinate in coordinates) {
1399
+ val x = mercatorX(coordinate.longitude)
1400
+ val y = mercatorY(coordinate.latitude)
1401
+ minX = minOf(minX, x)
1402
+ maxX = maxOf(maxX, x)
1403
+ minY = minOf(minY, y)
1404
+ maxY = maxOf(maxY, y)
1405
+ }
1406
+
1407
+ val insets = combinedEdgeInsets()
1408
+ val availWidth = (wrapper.width - insets.left - insets.right - left - right).coerceAtLeast(1)
1409
+ val availHeight = (wrapper.height - insets.top - insets.bottom - top - bottom).coerceAtLeast(1)
1410
+
1411
+ // World size in px at which the bounds fit the padded viewport;
1412
+ // infinite for coincident coordinates - keep the current zoom then
1413
+ val fitWorldSize = minOf(availWidth / (maxX - minX), availHeight / (maxY - minY))
1414
+ if (fitWorldSize.isFinite()) {
1415
+ initialZoom = log2(fitWorldSize / 256f.dpToPx())
1416
+ .toFloat()
1417
+ .coerceIn(map.minZoomLevel, map.maxZoomLevel)
1418
+ }
1419
+
1420
+ val worldSize = mercatorWorldSize(initialZoom)
1421
+ val center = latLngFromMercator(
1422
+ (minX + maxX) / 2.0 - (left - right) / 2.0 / worldSize,
1423
+ (minY + maxY) / 2.0 - (top - bottom) / 2.0 / worldSize
1424
+ )
1425
+ initialLatitude = center.latitude
1426
+ initialLongitude = center.longitude
1427
+
1428
+ map.moveCamera(CameraUpdateFactory.newLatLngZoom(staticCameraTarget(), initialZoom))
1429
+ positionLiveMarkers()
1430
+ }
1431
+
1432
+ // Web mercator world space in [0, 1]
1433
+ private fun mercatorX(longitude: Double): Double = (longitude + 180.0) / 360.0
1434
+
1435
+ private fun mercatorY(latitude: Double): Double {
1436
+ val sinLat = sin(Math.toRadians(latitude))
1437
+ return 0.5 - ln((1.0 + sinLat) / (1.0 - sinLat)) / (4.0 * Math.PI)
1438
+ }
1439
+
1440
+ private fun latLngFromMercator(x: Double, y: Double): LatLng =
1441
+ LatLng(Math.toDegrees(atan(sinh(Math.PI * (1.0 - 2.0 * y)))), x * 360.0 - 180.0)
1442
+
1443
+ // World size in px at the given zoom
1444
+ private fun mercatorWorldSize(zoom: Float): Double = 256f.dpToPx().toDouble() * 2.0.pow(zoom.toDouble())
1445
+
1446
+ // Whether the renderer repositions the watermark for setPadding varies by
1447
+ // renderer version and by whether the watermark existed when padding was
1448
+ // set (and lite mode never applies padding). Measure where layout actually
1449
+ // placed it and translate only for the remainder.
1231
1450
  private fun applyWatermarkTranslation(insets: EdgeInsets, duration: Int = 0) {
1232
1451
  val view = mapView ?: return
1233
- view.findViewByTag("GoogleWatermark")?.let { watermark ->
1234
- val targetY = -insets.bottom.toFloat()
1235
- val targetX = insets.left.toFloat()
1236
- if (duration > 0) {
1237
- watermark.animate()
1238
- .translationY(targetY)
1239
- .translationX(targetX)
1240
- .setDuration(duration.toLong())
1241
- .start()
1242
- } else if (duration < 0) {
1243
- watermark.animate()
1244
- .translationY(targetY)
1245
- .translationX(targetX)
1246
- .start()
1247
- } else {
1248
- watermark.translationY = targetY
1249
- watermark.translationX = targetX
1250
- }
1452
+ attachWatermarkLayoutListener()
1453
+
1454
+ if (view.height == 0) return
1455
+ val watermark = view.findViewByTag("GoogleWatermark") ?: return
1456
+
1457
+ // Layout position relative to the map view, excluding translation
1458
+ var left = 0
1459
+ var top = 0
1460
+ var current: View? = watermark
1461
+ while (current != null && current !== view) {
1462
+ left += current.left
1463
+ top += current.top
1464
+ current = current.parent as? View
1465
+ }
1466
+ if (current == null) return
1467
+
1468
+ val bottomGap = view.height - (top + watermark.height)
1469
+ val targetY = if (bottomGap >= insets.bottom) 0f else -insets.bottom.toFloat()
1470
+ val targetX = if (left >= insets.left) 0f else insets.left.toFloat()
1471
+
1472
+ // Skip when already applied (or animating) toward this target so layout
1473
+ // passes don't snap an in-flight animation
1474
+ if (watermark === watermarkView && targetX == watermarkTargetX && targetY == watermarkTargetY) {
1475
+ return
1476
+ }
1477
+ watermarkView = watermark
1478
+ watermarkTargetX = targetX
1479
+ watermarkTargetY = targetY
1480
+
1481
+ if (duration > 0) {
1482
+ watermark.animate()
1483
+ .translationY(targetY)
1484
+ .translationX(targetX)
1485
+ .setDuration(duration.toLong())
1486
+ .start()
1487
+ } else if (duration < 0) {
1488
+ watermark.animate()
1489
+ .translationY(targetY)
1490
+ .translationX(targetX)
1491
+ .start()
1492
+ } else {
1493
+ watermark.translationY = targetY
1494
+ watermark.translationX = targetX
1251
1495
  }
1252
1496
  }
1253
1497
 
1498
+ private fun attachWatermarkLayoutListener() {
1499
+ if (watermarkLayoutListener != null) return
1500
+ val view = mapView ?: return
1501
+
1502
+ val listener = ViewTreeObserver.OnGlobalLayoutListener {
1503
+ applyWatermarkTranslation(combinedEdgeInsets())
1504
+ }
1505
+ watermarkLayoutListener = listener
1506
+ view.viewTreeObserver.addOnGlobalLayoutListener(listener)
1507
+ }
1508
+
1509
+ private fun detachWatermarkLayoutListener() {
1510
+ watermarkView = null
1511
+ val listener = watermarkLayoutListener ?: return
1512
+ watermarkLayoutListener = null
1513
+ mapView?.viewTreeObserver?.takeIf { it.isAlive }?.removeOnGlobalLayoutListener(listener)
1514
+ }
1515
+
1254
1516
  private fun attachWindowInsetsListener() {
1255
1517
  val view = mapView ?: return
1256
1518
  ViewCompat.setOnApplyWindowInsetsListener(view) { _, windowInsets ->
@@ -1304,5 +1566,9 @@ class GoogleMapProvider(private val context: Context) :
1304
1566
 
1305
1567
  companion object {
1306
1568
  const val DEMO_MAP_ID = "DEMO_MAP_ID"
1569
+
1570
+ // Undocumented cap on the lite mode bitmap (2048 minus a 2px border,
1571
+ // measured empirically)
1572
+ private const val LITE_MODE_MAX_SIZE = 2046
1307
1573
  }
1308
1574
  }
@@ -30,6 +30,7 @@ interface MapProvider {
30
30
  fun setScrollEnabled(enabled: Boolean)
31
31
  fun setRotateEnabled(enabled: Boolean)
32
32
  fun setPitchEnabled(enabled: Boolean)
33
+ fun setCompassEnabled(enabled: Boolean)
33
34
  fun setUserLocationEnabled(enabled: Boolean)
34
35
  fun setUserLocationButtonEnabled(enabled: Boolean)
35
36
  fun setMapType(value: String)