Skip to content

Commit d14daba

Browse files
authored
fix: Use recomposed map listener callbacks rather than only the initially … (#478)
* fix: Use recomposed map listener callbacks rather than only the initially composed version Fixes #466 * Minor formatting fix * Label leaf composable as @GoogleMapComposable for proper compile-time diagnostics * Clarify documentation * Address spurious subcomposition recompositions by delaying state updates to after parent composition, not during parent composition * Delay GoogleMap object access until composition apply phase (see #501) * Revert "Address spurious subcomposition recompositions by delaying state updates to after parent composition, not during parent composition" This reverts commit dff2b0a.
1 parent 26ec87e commit d14daba

4 files changed

Lines changed: 150 additions & 26 deletions

File tree

maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ import androidx.compose.ui.Modifier
3636
import androidx.compose.ui.platform.LocalContext
3737
import androidx.compose.ui.platform.LocalInspectionMode
3838
import androidx.compose.ui.platform.LocalLifecycleOwner
39-
import androidx.compose.ui.semantics.semantics
4039
import androidx.compose.ui.viewinterop.AndroidView
4140
import androidx.lifecycle.Lifecycle
4241
import androidx.lifecycle.LifecycleEventObserver
@@ -126,17 +125,19 @@ public fun GoogleMap(
126125
val currentContent by rememberUpdatedState(content)
127126
LaunchedEffect(Unit) {
128127
disposingComposition {
129-
mapView.newComposition(parentComposition) {
128+
mapView.newComposition(parentComposition, mapClickListeners) {
130129
MapUpdater(
131130
mergeDescendants = mergeDescendants,
132131
contentDescription = currentContentDescription,
133132
cameraPositionState = currentCameraPositionState,
134-
clickListeners = mapClickListeners,
135133
contentPadding = currentContentPadding,
136134
locationSource = currentLocationSource,
137135
mapProperties = currentMapProperties,
138136
mapUiSettings = currentUiSettings,
139137
)
138+
139+
MapClickListenerUpdater()
140+
140141
CompositionLocalProvider(
141142
LocalCameraPositionState provides currentCameraPositionState,
142143
) {
@@ -158,11 +159,12 @@ internal suspend inline fun disposingComposition(factory: () -> Composition) {
158159

159160
private suspend inline fun MapView.newComposition(
160161
parent: CompositionContext,
162+
mapClickListeners: MapClickListeners,
161163
noinline content: @Composable () -> Unit
162164
): Composition {
163165
val map = awaitMap()
164166
return Composition(
165-
MapApplier(map, this), parent
167+
MapApplier(map, this, mapClickListeners), parent
166168
).apply {
167169
setContent(content)
168170
}

maps-compose/src/main/java/com/google/maps/android/compose/MapApplier.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,15 @@ internal interface MapNode {
3131

3232
private object MapNodeRoot : MapNode
3333

34+
// [mapClickListeners] must be a singleton for the [map] and is therefore stored here:
35+
// [GoogleMap.setOnIndoorStateChangeListener()] will not actually set a new non-null listener if
36+
// called more than once; if [mapClickListeners] were passed through the Compose function hierarchy
37+
// we would need to consider the case of it changing, which would require special treatment
38+
// for that particular listener; yet MapClickListeners never actually changes.
3439
internal class MapApplier(
3540
val map: GoogleMap,
3641
internal val mapView: MapView,
42+
val mapClickListeners: MapClickListeners,
3743
) : AbstractApplier<MapNode>(MapNodeRoot) {
3844

3945
private val decorations = mutableListOf<MapNode>()

maps-compose/src/main/java/com/google/maps/android/compose/MapClickListeners.kt

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,21 @@
1515
package com.google.maps.android.compose
1616

1717
import android.location.Location
18+
import androidx.compose.runtime.Composable
19+
import androidx.compose.runtime.ComposeNode
20+
import androidx.compose.runtime.NonRestartableComposable
21+
import androidx.compose.runtime.currentComposer
1822
import androidx.compose.runtime.getValue
1923
import androidx.compose.runtime.mutableStateOf
2024
import androidx.compose.runtime.setValue
25+
import com.google.android.gms.maps.GoogleMap
26+
import com.google.android.gms.maps.GoogleMap.OnIndoorStateChangeListener
27+
import com.google.android.gms.maps.GoogleMap.OnMapClickListener
28+
import com.google.android.gms.maps.GoogleMap.OnMapLoadedCallback
29+
import com.google.android.gms.maps.GoogleMap.OnMapLongClickListener
30+
import com.google.android.gms.maps.GoogleMap.OnMyLocationButtonClickListener
31+
import com.google.android.gms.maps.GoogleMap.OnMyLocationClickListener
32+
import com.google.android.gms.maps.GoogleMap.OnPoiClickListener
2133
import com.google.android.gms.maps.model.IndoorBuilding
2234
import com.google.android.gms.maps.model.LatLng
2335
import com.google.android.gms.maps.model.PointOfInterest
@@ -56,3 +68,129 @@ internal class MapClickListeners {
5668
var onMyLocationClick: ((Location) -> Unit)? by mutableStateOf(null)
5769
var onPOIClick: ((PointOfInterest) -> Unit)? by mutableStateOf(null)
5870
}
71+
72+
/**
73+
* @param L GoogleMap click listener type, e.g. [OnMapClickListener]
74+
*/
75+
internal class MapClickListenerNode<L : Any>(
76+
private val map: GoogleMap,
77+
private val setter: GoogleMap.(L?) -> Unit,
78+
private val listener: L
79+
) : MapNode {
80+
override fun onAttached() = setListener(listener)
81+
override fun onRemoved() = setListener(null)
82+
override fun onCleared() = setListener(null)
83+
84+
private fun setListener(listenerOrNull: L?) = map.setter(listenerOrNull)
85+
}
86+
87+
@Suppress("ComplexRedundantLet")
88+
@Composable
89+
internal fun MapClickListenerUpdater() {
90+
// The mapClickListeners container object is not allowed to ever change
91+
val mapClickListeners = (currentComposer.applier as MapApplier).mapClickListeners
92+
93+
with(mapClickListeners) {
94+
::indoorStateChangeListener.let { callback ->
95+
MapClickListenerComposeNode(
96+
callback,
97+
GoogleMap::setOnIndoorStateChangeListener,
98+
object : OnIndoorStateChangeListener {
99+
override fun onIndoorBuildingFocused() =
100+
callback().onIndoorBuildingFocused()
101+
102+
override fun onIndoorLevelActivated(building: IndoorBuilding) =
103+
callback().onIndoorLevelActivated(building)
104+
}
105+
)
106+
}
107+
108+
::onMapClick.let { callback ->
109+
MapClickListenerComposeNode(
110+
callback,
111+
GoogleMap::setOnMapClickListener,
112+
OnMapClickListener { callback()?.invoke(it) }
113+
)
114+
}
115+
116+
::onMapLongClick.let { callback ->
117+
MapClickListenerComposeNode(
118+
callback,
119+
GoogleMap::setOnMapLongClickListener,
120+
OnMapLongClickListener { callback()?.invoke(it) }
121+
)
122+
}
123+
124+
::onMapLoaded.let { callback ->
125+
MapClickListenerComposeNode(
126+
callback,
127+
GoogleMap::setOnMapLoadedCallback,
128+
OnMapLoadedCallback { callback()?.invoke() }
129+
)
130+
}
131+
132+
::onMyLocationButtonClick.let { callback ->
133+
MapClickListenerComposeNode(
134+
callback,
135+
GoogleMap::setOnMyLocationButtonClickListener,
136+
OnMyLocationButtonClickListener { callback()?.invoke() ?: false }
137+
)
138+
}
139+
140+
::onMyLocationClick.let { callback ->
141+
MapClickListenerComposeNode(
142+
callback,
143+
GoogleMap::setOnMyLocationClickListener,
144+
OnMyLocationClickListener { callback()?.invoke(it) }
145+
)
146+
}
147+
148+
::onPOIClick.let { callback ->
149+
MapClickListenerComposeNode(
150+
callback,
151+
GoogleMap::setOnPoiClickListener,
152+
OnPoiClickListener { callback()?.invoke(it) }
153+
)
154+
}
155+
}
156+
}
157+
158+
/**
159+
* Encapsulates the ComposeNode factory lambda as a recomposition optimization.
160+
*
161+
* @param L GoogleMap click listener type, e.g. [OnMapClickListener]
162+
* @param callback a property reference to the callback lambda, i.e.
163+
* invoking it returns the callback lambda
164+
* @param setter a reference to a GoogleMap setter method, e.g. `setOnMapClickListener()`
165+
* @param listener must include a call to `callback()` inside the listener
166+
* to use the most up-to-date recomposed version of the callback lambda;
167+
* However, the resulting callback reference might actually be null due to races;
168+
* the caller must guard against this case.
169+
*
170+
*/
171+
@Composable
172+
@NonRestartableComposable
173+
private fun <L : Any> MapClickListenerComposeNode(
174+
callback: () -> Any?,
175+
setter: GoogleMap.(L?) -> Unit,
176+
listener: L
177+
) {
178+
val mapApplier = currentComposer.applier as MapApplier
179+
180+
MapClickListenerComposeNode(callback) { MapClickListenerNode(mapApplier.map, setter, listener) }
181+
}
182+
183+
@Composable
184+
@GoogleMapComposable
185+
private fun MapClickListenerComposeNode(
186+
callback: () -> Any?,
187+
factory: () -> MapClickListenerNode<*>
188+
) {
189+
// Setting a GoogleMap listener may have side effects, so we unset it as needed.
190+
// However, the listener is reset only when the corresponding callback lambda
191+
// toggles between null and non-null. This is to avoid potential performance problems
192+
// when callbacks recompose rapidly; setting GoogleMap listeners could potentially be
193+
// expensive due to synchronization, etc. GoogleMap listeners are not designed with a
194+
// use case of rapid recomposition in mind.
195+
if (callback() != null) ComposeNode<MapClickListenerNode<*>, MapApplier>(factory) {}
196+
}

maps-compose/src/main/java/com/google/maps/android/compose/MapUpdater.kt

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,11 @@ import androidx.compose.ui.unit.Density
2626
import androidx.compose.ui.unit.LayoutDirection
2727
import com.google.android.gms.maps.GoogleMap
2828
import com.google.android.gms.maps.LocationSource
29-
import com.google.android.gms.maps.model.IndoorBuilding
3029

3130
internal class MapPropertiesNode(
3231
val map: GoogleMap,
3332
cameraPositionState: CameraPositionState,
3433
contentDescription: String?,
35-
var clickListeners: MapClickListeners,
3634
var density: Density,
3735
var layoutDirection: LayoutDirection,
3836
) : MapNode {
@@ -76,23 +74,6 @@ internal class MapPropertiesNode(
7674
map.setOnCameraMoveListener {
7775
cameraPositionState.rawPosition = map.cameraPosition
7876
}
79-
80-
map.setOnMapClickListener(clickListeners.onMapClick)
81-
map.setOnMapLongClickListener(clickListeners.onMapLongClick)
82-
map.setOnMapLoadedCallback(clickListeners.onMapLoaded)
83-
map.setOnMyLocationButtonClickListener { clickListeners.onMyLocationButtonClick?.invoke() == true }
84-
map.setOnMyLocationClickListener(clickListeners.onMyLocationClick)
85-
map.setOnPoiClickListener(clickListeners.onPOIClick)
86-
87-
map.setOnIndoorStateChangeListener(object : GoogleMap.OnIndoorStateChangeListener {
88-
override fun onIndoorBuildingFocused() {
89-
clickListeners.indoorStateChangeListener.onIndoorBuildingFocused()
90-
}
91-
92-
override fun onIndoorLevelActivated(building: IndoorBuilding) {
93-
clickListeners.indoorStateChangeListener.onIndoorLevelActivated(building)
94-
}
95-
})
9677
}
9778

9879
override fun onRemoved() {
@@ -116,7 +97,6 @@ internal inline fun MapUpdater(
11697
mergeDescendants: Boolean = false,
11798
contentDescription: String?,
11899
cameraPositionState: CameraPositionState,
119-
clickListeners: MapClickListeners,
120100
contentPadding: PaddingValues = NoPadding,
121101
locationSource: LocationSource?,
122102
mapProperties: MapProperties,
@@ -135,7 +115,6 @@ internal inline fun MapUpdater(
135115
map = map,
136116
contentDescription = contentDescription,
137117
cameraPositionState = cameraPositionState,
138-
clickListeners = clickListeners,
139118
density = density,
140119
layoutDirection = layoutDirection,
141120
)
@@ -181,6 +160,5 @@ internal inline fun MapUpdater(
181160
set(mapUiSettings.zoomGesturesEnabled) { map.uiSettings.isZoomGesturesEnabled = it }
182161

183162
update(cameraPositionState) { this.cameraPositionState = it }
184-
update(clickListeners) { this.clickListeners = it }
185163
}
186164
}

0 commit comments

Comments
 (0)