Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<MarkerDescriptor>? = null
private var pendingPolylines: Array<PolylineDescriptor>? = null
Expand All @@ -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
Expand Down Expand Up @@ -298,23 +313,26 @@ class GoogleMapProviderAdapter(
syncMarkerPressHandlers()
}

override fun fetchCamera(): Promise<Camera> {
val map = googleMap
if (map != null) {
return promiseOnMain { map.cameraPosition.toCamera() }
override fun fetchCamera(): Promise<Camera> = 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,
)
}

Expand All @@ -327,21 +345,8 @@ class GoogleMapProviderAdapter(
updateMapCamera(camera, animated = true, durationMs = (animationDuration * 1000).toInt())
}

override fun getVisibleRegion(): Promise<VisibleRegion> {
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<VisibleRegion> = promiseOnMain {
googleMap?.projection?.toNitroVisibleRegion() ?: emptyVisibleRegion()
}

override fun fitToCoordinates(
Expand Down Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private fun configureMap(map: GoogleMap) {
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -269,33 +273,44 @@ class HybridMapView(private val context: ThemedReactContext) :
adapter?.onClusterPress = value
}

override fun fetchCamera(): Promise<Camera> = currentAdapter().fetchCamera()
override fun fetchCamera(): Promise<Camera> {
val mounted = adapter ?: return notMountedRejection()
return mounted.fetchCamera()
}

override fun applyCamera(camera: Camera): Promise<Unit> {
currentAdapter().applyCamera(camera)
val mounted = adapter ?: return notMountedRejection()
mounted.applyCamera(camera)
return Promise.resolved(Unit)
}

override fun animateCamera(camera: Camera, duration: Double?): Promise<Unit> {
currentAdapter().animateCamera(camera, duration)
val mounted = adapter ?: return notMountedRejection()
mounted.animateCamera(camera, duration)
return Promise.resolved(Unit)
}

override fun getVisibleRegion(): Promise<VisibleRegion> = currentAdapter().getVisibleRegion()
override fun getVisibleRegion(): Promise<VisibleRegion> {
val mounted = adapter ?: return notMountedRejection()
return mounted.getVisibleRegion()
}

override fun fitToCoordinates(
coordinates: Array<Coordinate>,
padding: EdgePadding?,
animated: Boolean?,
): Promise<Unit> {
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
Expand Down Expand Up @@ -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 <T> notMountedRejection(): Promise<T> =
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,12 @@ interface MapProviderAdapter {
fun animateCamera(camera: Camera, duration: Double?)
fun getVisibleRegion(): Promise<VisibleRegion>
fun fitToCoordinates(coordinates: Array<Coordinate>, 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()
}
Loading
Loading