diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt index 15067f2..06f555f 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt @@ -1,7 +1,9 @@ package com.margelo.nitro.nitromaps import android.Manifest +import android.content.ComponentCallbacks import android.content.pm.PackageManager +import android.content.res.Configuration import android.os.Handler import android.os.Looper import android.view.View @@ -32,7 +34,6 @@ class GoogleMapProviderAdapter( private var googleMap: GoogleMap? = null private var isUserGesture = false private var hasFiredMapReady = false - private var isDestroyed = false private val overlayController = MapOverlayController(null, context) private var pendingMarkers: Array? = null private var pendingPolylines: Array? = null @@ -49,32 +50,46 @@ class GoogleMapProviderAdapter( mapId(mapId) } }, - ).also { mapView -> - mapView.onCreate(null) - context.addLifecycleEventListener(this@GoogleMapProviderAdapter) - - mapView.addOnAttachStateChangeListener( - object : View.OnAttachStateChangeListener { - override fun onViewAttachedToWindow(v: View) { - if (!isDestroyed) { - mapView.onResume() - } - } + ) - override fun onViewDetachedFromWindow(v: View) { - context.removeLifecycleEventListener(this@GoogleMapProviderAdapter) - mapView.onPause() - destroyMapViewIfNeeded(mapView) - } - }, - ) + private val lifecycle = MapViewLifecycleOwner(view) + + private var isAttachedToWindow = false - mapView.getMapAsync { map -> + /** React only mounts views while the host runs; [onHostPause] corrects this. */ + private var isHostResumed = true + + private val attachStateListener = object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + isAttachedToWindow = true + syncLifecycleState() + } + + override fun onViewDetachedFromWindow(v: View) { + isAttachedToWindow = false + syncLifecycleState() + } + } + + private val memoryCallbacks = object : ComponentCallbacks { + override fun onConfigurationChanged(newConfig: Configuration) = Unit + + override fun onLowMemory() { + lifecycle.onLowMemory() + } + } + + init { + context.addLifecycleEventListener(this) + context.registerComponentCallbacks(memoryCallbacks) + view.addOnAttachStateChangeListener(attachStateListener) + + view.getMapAsync { map -> googleMap = map configureMap(map) } - installViewportSizeListener(mapView) + installViewportSizeListener(view) } private var _mapType = MapType.STANDARD @@ -298,23 +313,26 @@ class GoogleMapProviderAdapter( syncMarkerPressHandlers() } - override fun fetchCamera(): Promise { - val map = googleMap - if (map != null) { - return promiseOnMain { map.cameraPosition.toCamera() } + override fun fetchCamera(): Promise = promiseOnMain { + googleMap?.cameraPosition?.toCamera() ?: fallbackCamera() + } + + /** The camera the caller last asked for, used until the map itself can answer. */ + private fun fallbackCamera(): Camera { + val camera = _camera + if (camera != null) { + return camera } - return Promise.resolved( - _camera ?: Camera( - center = Coordinate( - latitude = _region?.latitude ?: 0.0, - longitude = _region?.longitude ?: 0.0, - ), - zoom = 10.0, - heading = null, - pitch = null, - altitude = null, + return Camera( + center = Coordinate( + latitude = _region?.latitude ?: 0.0, + longitude = _region?.longitude ?: 0.0, ), + zoom = 10.0, + heading = null, + pitch = null, + altitude = null, ) } @@ -327,21 +345,8 @@ class GoogleMapProviderAdapter( updateMapCamera(camera, animated = true, durationMs = (animationDuration * 1000).toInt()) } - override fun getVisibleRegion(): Promise { - val map = googleMap - if (map != null) { - return promiseOnMain { map.projection.toNitroVisibleRegion() } - } - - val zero = Coordinate(latitude = 0.0, longitude = 0.0) - return Promise.resolved( - VisibleRegion( - nearLeft = zero, - nearRight = zero, - farLeft = zero, - farRight = zero, - ), - ) + override fun getVisibleRegion(): Promise = promiseOnMain { + googleMap?.projection?.toNitroVisibleRegion() ?: emptyVisibleRegion() } override fun fitToCoordinates( @@ -376,20 +381,31 @@ class GoogleMapProviderAdapter( } override fun onHostResume() { - if (!isDestroyed) { - view.onResume() - } + isHostResumed = true + syncLifecycleState() } override fun onHostPause() { - if (!isDestroyed) { - view.onPause() - } + isHostResumed = false + syncLifecycleState() } override fun onHostDestroy() { - context.removeLifecycleEventListener(this) - destroyMapViewIfNeeded(view) + destroyMapView() + } + + /** + * Brings the map to the state implied by whether it is on screen and whether the + * host is in the foreground. Leaving the window stops the map, never destroys it. + */ + private fun syncLifecycleState() { + val target = when { + !isAttachedToWindow -> MapViewLifecycleState.CREATED + isHostResumed -> MapViewLifecycleState.RESUMED + else -> MapViewLifecycleState.STARTED + } + + lifecycle.moveTo(target) } private fun configureMap(map: GoogleMap) { @@ -722,9 +738,9 @@ class GoogleMapProviderAdapter( onMapReady?.invoke() } - override fun prepareForRecycle() { - isUserGesture = false - hasFiredMapReady = false + override fun release() { + // Drop the JS callbacks first: a map event still in flight must not reach a + // view that is already gone. onRegionChange = null onRegionChangeComplete = null onMapReady = null @@ -737,48 +753,31 @@ class GoogleMapProviderAdapter( onPolygonPress = null onCirclePress = null onClusterPress = null - _markers = null - _polylines = null - _polygons = null - _circles = null - pendingMarkers = null - pendingPolylines = null - pendingPolygons = null - pendingCircles = null + overlayController.clear() - _mapType = MapType.STANDARD - _region = null - _camera = null - scrollEnabled = true - zoomEnabled = true - rotateEnabled = true - pitchEnabled = true - _showsUserLocation = null - _followsUserLocation = null - _showsCompass = null - _showsScale = null - _customMapStyle = null - _clusteringEnabled = null - _mapPadding = null - _markerEnteringAnimation = null - _clusterEnteringAnimation = null - overlayController.markerEnteringAnimation = null - overlayController.clusterEnteringAnimation = null - googleMap?.mapType = MapType.STANDARD.toGoogleMapType() - googleMap?.isMyLocationEnabled = false - googleMap?.setMapStyle(null) - googleMap?.setPadding(0, 0, 0, 0) - applyUiSettings() - } - - private fun destroyMapViewIfNeeded(mapView: MapView) { - if (isDestroyed) { + destroyMapView() + } + + /** + * Tears the map down for good. Both call sites discard the adapter afterwards; + * detaching from the window deliberately does not come here. + */ + private fun destroyMapView() { + if (lifecycle.isDestroyed) { return } - mapView.onDestroy() - isDestroyed = true + context.removeLifecycleEventListener(this) + context.unregisterComponentCallbacks(memoryCallbacks) + view.removeOnAttachStateChangeListener(attachStateListener) + lifecycle.moveTo(MapViewLifecycleState.DESTROYED) + googleMap = null } } private fun normalizeGoogleMapId(value: String?): String? = value?.trim()?.takeIf { it.isNotEmpty() } + +private fun emptyVisibleRegion(): VisibleRegion { + val zero = Coordinate(latitude = 0.0, longitude = 0.0) + return VisibleRegion(nearLeft = zero, nearRight = zero, farLeft = zero, farRight = zero) +} diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt index debd57e..8eab2bf 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt @@ -8,12 +8,16 @@ import com.facebook.react.uimanager.ThemedReactContext import com.margelo.nitro.core.Promise import com.margelo.nitro.views.RecyclableView +private const val MAP_VIEW_NOT_MOUNTED_MESSAGE = "MapView is not mounted" + @Keep @DoNotStrip class HybridMapView(private val context: ThemedReactContext) : HybridMapViewSpec(), RecyclableView { + /** Written on the UI thread, read from the JS thread by the imperative methods. */ + @Volatile private var adapter: MapProviderAdapter? = null private var _provider = MapProvider.GOOGLE @@ -269,33 +273,44 @@ class HybridMapView(private val context: ThemedReactContext) : adapter?.onClusterPress = value } - override fun fetchCamera(): Promise = currentAdapter().fetchCamera() + override fun fetchCamera(): Promise { + val mounted = adapter ?: return notMountedRejection() + return mounted.fetchCamera() + } override fun applyCamera(camera: Camera): Promise { - currentAdapter().applyCamera(camera) + val mounted = adapter ?: return notMountedRejection() + mounted.applyCamera(camera) return Promise.resolved(Unit) } override fun animateCamera(camera: Camera, duration: Double?): Promise { - currentAdapter().animateCamera(camera, duration) + val mounted = adapter ?: return notMountedRejection() + mounted.animateCamera(camera, duration) return Promise.resolved(Unit) } - override fun getVisibleRegion(): Promise = currentAdapter().getVisibleRegion() + override fun getVisibleRegion(): Promise { + val mounted = adapter ?: return notMountedRejection() + return mounted.getVisibleRegion() + } override fun fitToCoordinates( coordinates: Array, padding: EdgePadding?, animated: Boolean?, ): Promise { - currentAdapter().fitToCoordinates(coordinates, padding, animated) + val mounted = adapter ?: return notMountedRejection() + mounted.fitToCoordinates(coordinates, padding, animated) return Promise.resolved(Unit) } + override fun onDropView() { + releaseAdapter() + } + override fun prepareForRecycle() { - adapter?.prepareForRecycle() - adapter?.view?.let(view::removeView) - adapter = null + releaseAdapter() _provider = MapProvider.GOOGLE _mapType = MapType.STANDARD _region = null @@ -332,18 +347,25 @@ class HybridMapView(private val context: ThemedReactContext) : onClusterPress = null } - private fun currentAdapter(): MapProviderAdapter { - adapter?.let { return it } - installAdapter(_provider) - return requireNotNull(adapter) + private fun notMountedRejection(): Promise = + Promise.rejected(IllegalStateException(MAP_VIEW_NOT_MOUNTED_MESSAGE)) + + /** + * Detaches and destroys the installed adapter. Both teardown paths land here: + * [onDropView] fires on every unmount, while [prepareForRecycle] only fires when + * React Native has view recycling enabled. + */ + private fun releaseAdapter() { + adapter?.release() + adapter?.view?.let(view::removeView) + adapter = null } private fun installAdapter(provider: MapProvider) { + // Built before the teardown so an unsupported provider leaves the current map intact. val nextAdapter = makeAdapter(provider) - val previousAdapter = adapter - previousAdapter?.prepareForRecycle() - previousAdapter?.view?.let(view::removeView) + releaseAdapter() adapter = nextAdapter attach(nextAdapter.view) syncState(nextAdapter) diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt index 162f420..06f7e14 100644 --- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt @@ -48,5 +48,12 @@ interface MapProviderAdapter { fun animateCamera(camera: Camera, duration: Double?) fun getVisibleRegion(): Promise fun fitToCoordinates(coordinates: Array, padding: EdgePadding?, animated: Boolean?) - fun prepareForRecycle() + + /** + * Destroys the underlying native map and unregisters everything the adapter owns. + * Every caller discards the adapter afterwards, so this is a one-way transition -- + * it is not the Nitro `RecyclableView.prepareForRecycle` reset. Leaving the window + * does not trigger it; a detached map is only stopped. + */ + fun release() } diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapViewLifecycleOwner.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapViewLifecycleOwner.kt new file mode 100644 index 0000000..a7503a9 --- /dev/null +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapViewLifecycleOwner.kt @@ -0,0 +1,94 @@ +package com.margelo.nitro.nitromaps + +import com.google.android.gms.maps.MapView + +/** + * Drives a [MapView] through the lifecycle callbacks the Maps SDK requires. + * + * The SDK only tolerates ordered transitions, so [moveTo] walks one state at a time. + * Detaching never destroys the map — only an explicit move to + * [MapViewLifecycleState.DESTROYED] does — because a destroyed map cannot be resumed. + */ +internal class MapViewLifecycleOwner(private val mapView: MapView) { + private var state = MapViewLifecycleState.CREATED + + val isDestroyed: Boolean + get() = state == MapViewLifecycleState.DESTROYED + + init { + mapView.onCreate(null) + } + + /** Moves the map to [target], emitting every intermediate callback along the way. */ + fun moveTo(target: MapViewLifecycleState) { + if (isDestroyed) { + return + } + + if (target == MapViewLifecycleState.DESTROYED) { + destroy() + return + } + + while (state.ordinal < target.ordinal) { + stepUp() + } + + while (state.ordinal > target.ordinal) { + stepDown() + } + } + + fun onLowMemory() { + if (isDestroyed) { + return + } + + mapView.onLowMemory() + } + + private fun destroy() { + while (state.ordinal > MapViewLifecycleState.CREATED.ordinal) { + stepDown() + } + + mapView.onDestroy() + state = MapViewLifecycleState.DESTROYED + } + + private fun stepUp() { + when (state) { + MapViewLifecycleState.CREATED -> { + mapView.onStart() + state = MapViewLifecycleState.STARTED + } + + MapViewLifecycleState.STARTED -> { + mapView.onResume() + state = MapViewLifecycleState.RESUMED + } + + MapViewLifecycleState.RESUMED, + MapViewLifecycleState.DESTROYED, + -> Unit + } + } + + private fun stepDown() { + when (state) { + MapViewLifecycleState.RESUMED -> { + mapView.onPause() + state = MapViewLifecycleState.STARTED + } + + MapViewLifecycleState.STARTED -> { + mapView.onStop() + state = MapViewLifecycleState.CREATED + } + + MapViewLifecycleState.CREATED, + MapViewLifecycleState.DESTROYED, + -> Unit + } + } +} diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapViewLifecycleState.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapViewLifecycleState.kt new file mode 100644 index 0000000..93f46b0 --- /dev/null +++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapViewLifecycleState.kt @@ -0,0 +1,12 @@ +package com.margelo.nitro.nitromaps + +/** + * Ordered lifecycle states a Google `MapView` can be driven through. [CREATED], + * [STARTED] and [RESUMED] are reversible; [DESTROYED] is terminal. + */ +internal enum class MapViewLifecycleState { + CREATED, + STARTED, + RESUMED, + DESTROYED, +}