From 0447f439326fea701a77e0e6920d0ab25857eb5d Mon Sep 17 00:00:00 2001 From: Philip Segerfast Date: Tue, 9 Jul 2024 22:21:33 +0200 Subject: [PATCH] feat: Use AndroidView overload which re-uses MapView to improve performance in lazy layouts (#436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Modify GoogleMap to use AndroidView overload with onReset lambda This will make sure that the underlying MapView is re-used in a LazyColumn (and elsewhere) which will significantly improve scrolling performance in LazyColumn. If this overload isn't used the MapView will be destroyed every time it leaves composition. The MapView will still be destroyed when the parent node leaves the composition. * Update androidx.compose.ui:ui library to fix AndroidView bug Issue: https://issuetracker.google.com/issues/267642562 * Add MapsInLazyColumnActivity * Hoist list items state * Bump compose-ui dependency * Update libs.versions.toml * Show list of countries in MapsInLazyColumnActivity * Cleanup * Remove compose-ui dependency version override * Update libs.versions.toml * Add parameter to GoogleMap to allow user to re-use the underlying MapView * Update libs.versions.toml * Update MapsInLazyColumnActivity.kt * Fix merge issues * Refactor the GoogleMap composable to support reuse of underlying map+ remove LaunchedEffect block * Replace ComposeNode usages with ReusableComposeNode * Refactor GoogleMap to save Composition + MapClickListeners + other related things in MapView using setTag() This also includes some additional things for debugging/testing/demonstrations purposes which will be removed before merging * Add compose material library to build.gradle temporarily for demo purposes * Implement ComposeNodeLifecycleCallback for MapClickListenerNode + set/remove listener on reuse/deactivation * Update GoogleMap.kt * Update MapsInLazyColumnActivity.kt * Update ids.xml * Update GoogleMap.kt * Update MapClickListeners.kt * Update GoogleMap.kt * Make so that MapView lifecycle state "moves" through lifecycle states instead of setting the state directly. * Move MapViewLifecycleController into its own file * Update MapViewLifecycleController.kt * Remove composition reuse logic as it's moved to another PR * Simplify MapViewLifecycleController logic * Re-register componentCallbacks if context has changed * Store map tag data in default tag instead of in separate tags by resource ids * Use CoroutineStart.UNDISPATCHED for creating composition * Don't remove lifecycle from MapViewLifecycleController on detach to observe onDestroy event * Reformat code * Move disposingComposition function to StreetView.kt as it's no longer used in GoogleMap.kt * Use awaitCancellation instead of infinite delay * Fix lifecycle event error message * Use else branch instead of ON_ANY in nextLifecycleEvent * Change name of setCompositionAsync to launchComposition and make it more idiomatic * Undo import changes * Undo unrelated import changes * Undo unrelated refactoring * Clarify that dependency is temporary * Create IncrementalLifecycleApplier This class is simpler and uses better practices than MapViewLifecycleController. Instead of modifying a MapView directly it invokes events on an internface. Also it utilizes Lifecycle.State which is more clear and idiomatic than only managing `Lifecycle.Event`s. Also, instead of relying on custom lifecycle logic, standard Lifecycle.Event utils are used. * Delete MapViewLifecycleController and replace with IncrementalLifecycleApplier * Resolve requirements * Add some logs for IncrementalLifecycleApplier * Remove `currentLifecycleState` parameter for IncrementalLifecycleApplier * Make some functions in IncrementalLifecycleApplier pure and move to companion object * Add support to override lifecycle state Used to set lifecycle state to Created when mapView is detached + restore once reattached * Remove LifecycleApplier stuff from GoogleMap * Add show/hide button on MapsInLazyColumnActivity * Use LifecycleRegistry solution instead of custom solution * Delete IncrementalLifecycleApplier.kt * Update GoogleMap.kt * Move between lifecycle states using custom approach inspired by LifecycleRegistry#sync() * Move LifecycleEventObserver into its own class * Rename moveForward/Backward to moveUp/Down * Make moveToLifecycleState more clear * Make lifecycleOwner nullable and nullify on detach * Rename attachStateListener to onAttachStateListener for clarity * Add moveToBaseState method to avoid reaching Lifecycle.State.CREATED prematurely * Replace mapView.tag onRelease lambda with lifecycleObserver reference + add moveToDestroyedState method * Remove @Synchronized annotation * Make tagData() method a bit more clear * Remove LocalContext.current + mapViewContext * Make MapTagData + MapLifecycleEventObserver private * Update GoogleMap.kt * Merge componentCallbacks into registerAndSaveNewComponentCallbacks * inline MapView.createComposition (move code to call site) * Inline unregisterLifecycleObserver * Replace `isCompositionSet` with nullable `subcompositionJob` * Rename `mapUpdaterScope` to `parentCompositionScope` * Make MapTagData fully immutable + remove all logs + some related refactoring * Remove debugging stuff * Add some comments in AndroidView->factory * Remove "Create Composition" comment * Remove compose material dependency for maps-compose module * Some improvements to MapsInLazyColumnActivity + add more debugging controls * Convert tagData() to extension property * Move `composition` inside `try` block * Formatting * Add comment on CoroutineStart.UNDISPATCHED * Add KDoc to MapTagData * Remove Suppress annotation * Rename launchComposition to launchSubcomposition * Remove obvious comments * Move `lifecycleOwner` inside `object : View.OnAttachStateChangeListener` * Fix comment * Convert registerComponentCallbacks to a top-level class * Inline componentCallbacks class * Store reference to `Lifecycle` instead of `LifecycleOwner` * Update comment * Fix typo * Remove unused TAG constant * Demo - Set buildingFocused state on initial composition * Make maps in MapsInLazyColumnActivity pannable * Incorporate changes from #522 + remove MapClickListeners property from MapApplier [see desc.] - Wrap MapUpdater parameters inside a MapUpdaterState data class - Remove MapClickListeners property from MapApplier - Pass MapClickListeners directly to MapClickListenerUpdater * Revert MapClickListeners change + fix parameter order convention * Use `mapUpdaterState.cameraPositionState` instead of `currentCameraPositionState` * Minor fixes * Update app/src/main/AndroidManifest.xml Co-authored-by: Enrique López Mañas --------- Co-authored-by: Enrique López Mañas --- app/src/main/AndroidManifest.xml | 3 + .../maps/android/compose/MainActivity.kt | 12 + .../compose/MapsInLazyColumnActivity.kt | 260 ++++++++++++++ app/src/main/res/values/strings.xml | 1 + .../google/maps/android/compose/GoogleMap.kt | 316 +++++++++++------- .../google/maps/android/compose/MapUpdater.kt | 15 +- .../android/compose/streetview/StreetView.kt | 11 +- 7 files changed, 492 insertions(+), 126 deletions(-) create mode 100644 app/src/main/java/com/google/maps/android/compose/MapsInLazyColumnActivity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c3b5aa637..0063f72db 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -48,6 +48,9 @@ + diff --git a/app/src/main/java/com/google/maps/android/compose/MainActivity.kt b/app/src/main/java/com/google/maps/android/compose/MainActivity.kt index 42ef0c0e7..ea0c1a17b 100644 --- a/app/src/main/java/com/google/maps/android/compose/MainActivity.kt +++ b/app/src/main/java/com/google/maps/android/compose/MainActivity.kt @@ -102,6 +102,18 @@ class MainActivity : ComponentActivity() { Text(getString(R.string.map_in_column_activity)) } Spacer(modifier = Modifier.padding(5.dp)) + Button( + onClick = { + context.startActivity( + Intent( + context, + MapsInLazyColumnActivity::class.java + ) + ) + }) { + Text(getString(R.string.maps_in_lazy_column_activity)) + } + Spacer(modifier = Modifier.padding(5.dp)) Button( onClick = { context.startActivity( diff --git a/app/src/main/java/com/google/maps/android/compose/MapsInLazyColumnActivity.kt b/app/src/main/java/com/google/maps/android/compose/MapsInLazyColumnActivity.kt new file mode 100644 index 000000000..c53d3b6b1 --- /dev/null +++ b/app/src/main/java/com/google/maps/android/compose/MapsInLazyColumnActivity.kt @@ -0,0 +1,260 @@ +package com.google.maps.android.compose + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.ProvideTextStyle +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.android.gms.maps.GoogleMap +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.IndoorBuilding +import com.google.android.gms.maps.model.LatLng + +private data class CountryLocation(val name: String, val latLng: LatLng, val zoom: Float) + +private typealias MapItemId = String + +// From https://developers.google.com/public-data/docs/canonical/countries_csv +private val countries = listOf( + CountryLocation("Hong Kong", LatLng(22.396428, 114.109497), 5f), + CountryLocation("Madison Square Garden (has indoor mode)", LatLng(40.7504656, -73.9937246), 19.33f), + CountryLocation("Bolivia", LatLng(-16.290154, -63.588653), 5f), + CountryLocation("Ecuador", LatLng(-1.831239, -78.183406), 5f), + CountryLocation("Sweden", LatLng(60.128161, 18.643501), 5f), + CountryLocation("Eritrea", LatLng(15.179384, 39.782334), 5f), + CountryLocation("Portugal", LatLng(39.399872, -8.224454), 5f), + CountryLocation("Belgium", LatLng(50.503887, 4.469936), 5f), + CountryLocation("Slovakia", LatLng(48.669026, 19.699024), 5f), + CountryLocation("El Salvador", LatLng(13.794185, -88.89653), 5f), + CountryLocation("Bhutan", LatLng(27.514162, 90.433601), 5f), + CountryLocation("Saint Lucia", LatLng(13.909444, -60.978893), 5f), + CountryLocation("Uganda", LatLng(1.373333, 32.290275), 5f), + CountryLocation("South Africa", LatLng(-30.559482, 22.937506), 5f), + CountryLocation("Spain", LatLng(40.463667, -3.74922), 5f), + CountryLocation("Georgia", LatLng(42.315407, 43.356892), 5f), + CountryLocation("Burundi", LatLng(-3.373056, 29.918886), 5f) +) + +private data class MapListItem( + val title: String, + val location: LatLng, + val zoom: Float, + val id: MapItemId +) + +private val allItems = countries.mapIndexed { index, country -> + MapListItem(country.name, country.latLng, country.zoom, "MapInLazyColumn#$index") +} + +class MapsInLazyColumnActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + var showLazyColumn by rememberSaveable { mutableStateOf(true) } + var visibleItems by rememberSaveable { mutableStateOf(allItems) } + + fun setItemCount(count: Int) { + visibleItems = allItems.take(count.coerceIn(0, allItems.size)) + } + + Column { + Row( + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + TextButton(onClick = { setItemCount(0) }) { + Text(text = "Clear") + } + TextButton(onClick = { setItemCount(visibleItems.size - 1) }) { + Text(text = "Remove") + } + TextButton(onClick = { showLazyColumn = !showLazyColumn }) { + Text(text = if (showLazyColumn) "Hide" else "Show") + } + TextButton(onClick = { setItemCount(visibleItems.size + 1) }) { + Text(text = "Add") + } + TextButton(onClick = { setItemCount(allItems.size) }) { + Text(text = "Fill") + } + } + if (showLazyColumn) { + Box(Modifier.border(1.dp, Color.LightGray.copy(0.5f))) { + MapsInLazyColumn(visibleItems) + } + } + } + } + } +} + +@Composable +private fun MapsInLazyColumn(mapItems: List) { + val lazyListState = rememberLazyListState() + + val cameraPositionStates = mapItems.associate { item -> + item.id to rememberCameraPositionState( + key = item.id, + init = { position = CameraPosition.fromLatLngZoom(item.location, item.zoom) } + ) + } + val visibleItemIds by remember(lazyListState) { + derivedStateOf { + lazyListState.layoutInfo.visibleItemsInfo.map { it.key as MapItemId } + } + } + val anyMapMoving by remember(cameraPositionStates) { + derivedStateOf { + visibleItemIds.any { cameraPositionStates[it]?.isMoving == true } + } + } + + Box { + LazyColumn( + state = lazyListState, + userScrollEnabled = !anyMapMoving + ) { + items(mapItems, key = { it.id }) { item -> + val cameraPositionState = cameraPositionStates[item.id]!! + + Box( + Modifier + .fillMaxWidth() + .height(300.dp), + contentAlignment = Alignment.Center + ) { + MapCard(item, cameraPositionState) + } + } + } + } +} + +@OptIn(MapsComposeExperimentalApi::class) +@Composable +private fun MapCard(item: MapListItem, cameraPositionState: CameraPositionState) { + Card( + Modifier.padding(16.dp), + elevation = 4.dp + ) { + var mapLoaded by remember { mutableStateOf(false) } + var buildingFocused: Boolean? by remember { mutableStateOf(null) } + var focusedBuildingInvocationCount by remember { mutableIntStateOf(0) } + var activatedIndoorLevel: String? by remember { mutableStateOf(null) } + var activatedIndoorLevelInvocationCount by remember { mutableIntStateOf(0) } + var onMapClickCount by remember { mutableIntStateOf(0) } + + var map: GoogleMap? by remember { mutableStateOf(null) } + + fun updateIndoorLevel() { + activatedIndoorLevel = map!!.focusedBuilding?.run { levels.getOrNull(activeLevelIndex)?.name } + } + + Box { + GoogleMap( + onMapClick = { + onMapClickCount++ + }, + properties = remember { + MapProperties( + isBuildingEnabled = true, + isIndoorEnabled = true + ) + }, + cameraPositionState = cameraPositionState, + onMapLoaded = { mapLoaded = true }, + indoorStateChangeListener = object : IndoorStateChangeListener { + override fun onIndoorBuildingFocused() { + super.onIndoorBuildingFocused() + focusedBuildingInvocationCount++ + buildingFocused = (map!!.focusedBuilding != null) + updateIndoorLevel() + } + + override fun onIndoorLevelActivated(building: IndoorBuilding) { + super.onIndoorLevelActivated(building) + activatedIndoorLevelInvocationCount++ + updateIndoorLevel() + } + } + ) { + MapEffect(Unit) { googleMap -> + map = googleMap + updateIndoorLevel() + buildingFocused = (googleMap.focusedBuilding != null) + } + } + + AnimatedVisibility(!mapLoaded, enter = fadeIn(), exit = fadeOut()) { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + @Composable + fun TextWithBackground(text: String, fontWeight: FontWeight = FontWeight.Medium) { + Text( + modifier = Modifier.background(Color.White.copy(0.7f)), + text = text, + fontWeight = fontWeight, + fontSize = 10.sp + ) + } + + Column( + modifier = Modifier.align(Alignment.BottomStart) + ) { + TextWithBackground(item.title, fontWeight = FontWeight.Bold) + TextWithBackground("Map loaded: $mapLoaded") + TextWithBackground("Map click count: $onMapClickCount") + TextWithBackground("Building focused: $buildingFocused") + TextWithBackground("Building focused invocation count: $focusedBuildingInvocationCount") + TextWithBackground("Indoor level: $activatedIndoorLevel") + TextWithBackground("Indoor level invocation count: $activatedIndoorLevelInvocationCount") + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6638f6ee6..3993bcbab 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -32,4 +32,5 @@ Street View Custom Location Button Accessibility + Maps in LazyColumn \ No newline at end of file diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt b/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt index df54fcd88..1c948c4c1 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/GoogleMap.kt @@ -18,27 +18,29 @@ import android.content.ComponentCallbacks import android.content.res.Configuration import android.location.Location import android.os.Bundle +import android.view.View import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.Composable import androidx.compose.runtime.Composition import androidx.compose.runtime.CompositionContext import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCompositionContext +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.findViewTreeLifecycleOwner import com.google.android.gms.maps.GoogleMapOptions import com.google.android.gms.maps.LocationSource import com.google.android.gms.maps.MapView @@ -46,7 +48,11 @@ import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.MapColorScheme import com.google.android.gms.maps.model.PointOfInterest import com.google.maps.android.ktx.awaitMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch /** * A compose container for a [MapView]. @@ -100,12 +106,6 @@ public fun GoogleMap( return } - val context = LocalContext.current - val mapView = remember { MapView(context, googleMapOptionsFactory()) } - - AndroidView(modifier = modifier, factory = { mapView }) - MapLifecycle(mapView) - // rememberUpdatedState and friends are used here to make these values observable to // the subcomposition without providing a new content function each recomposition val mapClickListeners = remember { MapClickListeners() }.also { @@ -117,125 +117,155 @@ public fun GoogleMap( it.onMyLocationClick = onMyLocationClick it.onPOIClick = onPOIClick } - val currentContentDescription by rememberUpdatedState(contentDescription) - val currentLocationSource by rememberUpdatedState(locationSource) - val currentCameraPositionState by rememberUpdatedState(cameraPositionState) - val currentContentPadding by rememberUpdatedState(contentPadding) - val currentUiSettings by rememberUpdatedState(uiSettings) - val currentMapProperties by rememberUpdatedState(properties) - val currentColorScheme by rememberUpdatedState(mapColorScheme) + + val mapUpdaterState = remember { + MapUpdaterState( + mergeDescendants, + contentDescription, + cameraPositionState, + contentPadding, + locationSource, + properties, + uiSettings, + mapColorScheme?.value, + ) + }.also { + it.mergeDescendants = mergeDescendants + it.contentDescription = contentDescription + it.cameraPositionState = cameraPositionState + it.contentPadding = contentPadding + it.locationSource = locationSource + it.mapProperties = properties + it.mapUiSettings = uiSettings + it.mapColorScheme = mapColorScheme?.value + } val parentComposition = rememberCompositionContext() val currentContent by rememberUpdatedState(content) - LaunchedEffect(Unit) { - disposingComposition { - mapView.newComposition(parentComposition, mapClickListeners) { - MapUpdater( - mergeDescendants = mergeDescendants, - contentDescription = currentContentDescription, - cameraPositionState = currentCameraPositionState, - contentPadding = currentContentPadding, - locationSource = currentLocationSource, - mapProperties = currentMapProperties, - mapUiSettings = currentUiSettings, - colorMapScheme = currentColorScheme?.value - ) + var subcompositionJob by remember { mutableStateOf(null) } + val parentCompositionScope = rememberCoroutineScope() - MapClickListenerUpdater() + AndroidView( + modifier = modifier, + factory = { context -> + MapView(context, googleMapOptionsFactory()).also { mapView -> + val componentCallbacks = object : ComponentCallbacks { + override fun onConfigurationChanged(newConfig: Configuration) {} + override fun onLowMemory() { mapView.onLowMemory() } + } + context.registerComponentCallbacks(componentCallbacks) - CompositionLocalProvider( - LocalCameraPositionState provides currentCameraPositionState, - currentContent + val lifecycleObserver = MapLifecycleEventObserver(mapView) + + mapView.tag = MapTagData(componentCallbacks, lifecycleObserver) + + // Only register for [lifecycleOwner]'s lifecycle events while MapView is attached + val onAttachStateListener = object : View.OnAttachStateChangeListener { + private var lifecycle: Lifecycle? = null + + override fun onViewAttachedToWindow(mapView: View) { + lifecycle = mapView.findViewTreeLifecycleOwner()!!.lifecycle.also { + it.addObserver(lifecycleObserver) + } + } + + override fun onViewDetachedFromWindow(v: View) { + lifecycle?.removeObserver(lifecycleObserver) + lifecycle = null + lifecycleObserver.moveToBaseState() + } + } + + mapView.addOnAttachStateChangeListener(onAttachStateListener) + } + }, + onReset = { /* View is detached. */ }, + onRelease = { mapView -> + val (componentCallbacks, lifecycleObserver) = mapView.tagData + mapView.context.unregisterComponentCallbacks(componentCallbacks) + lifecycleObserver.moveToDestroyedState() + mapView.tag = null + }, + update = { mapView -> + if (subcompositionJob == null) { + subcompositionJob = parentCompositionScope.launchSubcomposition( + mapUpdaterState, + parentComposition, + mapView, + mapClickListeners, + currentContent, ) } } - } -} - -internal suspend inline fun disposingComposition(factory: () -> Composition) { - val composition = factory() - try { - awaitCancellation() - } finally { - composition.dispose() - } + ) } -private suspend inline fun MapView.newComposition( - parent: CompositionContext, +/** + * Create and apply the [content] compositions to the map + + * dispose the [Composition] when the parent composable is disposed. + * */ +private fun CoroutineScope.launchSubcomposition( + mapUpdaterState: MapUpdaterState, + parentComposition: CompositionContext, + mapView: MapView, mapClickListeners: MapClickListeners, - noinline content: @Composable () -> Unit -): Composition { - val map = awaitMap() - return Composition( - MapApplier(map, this, mapClickListeners), parent - ).apply { - setContent(content) - } -} + content: @Composable @GoogleMapComposable () -> Unit, +): Job { + // Use [CoroutineStart.UNDISPATCHED] to kick off GoogleMap loading immediately + return launch(start = CoroutineStart.UNDISPATCHED) { + val map = mapView.awaitMap() + val composition = Composition( + applier = MapApplier(map, mapView, mapClickListeners), + parent = parentComposition + ) -/** - * Registers lifecycle observers to the local [MapView]. - */ -@Composable -private fun MapLifecycle(mapView: MapView) { - val context = LocalContext.current - val lifecycle = LocalLifecycleOwner.current.lifecycle - val previousState = remember { mutableStateOf(Lifecycle.Event.ON_CREATE) } - DisposableEffect(context, lifecycle, mapView) { - val mapLifecycleObserver = mapView.lifecycleObserver(previousState) - val callbacks = mapView.componentCallbacks() - - lifecycle.addObserver(mapLifecycleObserver) - context.registerComponentCallbacks(callbacks) - - onDispose { - lifecycle.removeObserver(mapLifecycleObserver) - context.unregisterComponentCallbacks(callbacks) - } - } - DisposableEffect(mapView) { - onDispose { - mapView.onDestroy() - mapView.removeAllViews() - } - } -} + try { + composition.setContent { + MapUpdater(mapUpdaterState) -private fun MapView.lifecycleObserver(previousState: MutableState): LifecycleEventObserver = - LifecycleEventObserver { _, event -> - event.targetState - when (event) { - Lifecycle.Event.ON_CREATE -> { - // Skip calling mapView.onCreate if the lifecycle did not go through onDestroy - in - // this case the GoogleMap composable also doesn't leave the composition. So, - // recreating the map does not restore state properly which must be avoided. - if (previousState.value != Lifecycle.Event.ON_STOP) { - this.onCreate(Bundle()) - } - } + MapClickListenerUpdater() - Lifecycle.Event.ON_START -> this.onStart() - Lifecycle.Event.ON_RESUME -> this.onResume() - Lifecycle.Event.ON_PAUSE -> this.onPause() - Lifecycle.Event.ON_STOP -> this.onStop() - Lifecycle.Event.ON_DESTROY -> { - //handled in onDispose + CompositionLocalProvider( + LocalCameraPositionState provides mapUpdaterState.cameraPositionState, + content + ) } - - else -> throw IllegalStateException() + awaitCancellation() + } finally { + composition.dispose() } - previousState.value = event } +} -private fun MapView.componentCallbacks(): ComponentCallbacks = - object : ComponentCallbacks { - override fun onConfigurationChanged(config: Configuration) {} +@Stable +internal class MapUpdaterState( + mergeDescendants: Boolean, + contentDescription: String?, + cameraPositionState: CameraPositionState, + contentPadding: PaddingValues, + locationSource: LocationSource?, + mapProperties: MapProperties, + mapUiSettings: MapUiSettings, + mapColorScheme: Int?, +) { + var mergeDescendants by mutableStateOf(mergeDescendants) + var contentDescription by mutableStateOf(contentDescription) + var cameraPositionState by mutableStateOf(cameraPositionState) + var contentPadding by mutableStateOf(contentPadding) + var locationSource by mutableStateOf(locationSource) + var mapProperties by mutableStateOf(mapProperties) + var mapUiSettings by mutableStateOf(mapUiSettings) + var mapColorScheme by mutableStateOf(mapColorScheme) +} - override fun onLowMemory() { - this@componentCallbacks.onLowMemory() - } - } +/** Used to store things in the tag which must be retrievable across recompositions */ +private data class MapTagData( + val componentCallbacks: ComponentCallbacks, + val lifecycleObserver: MapLifecycleEventObserver +) + +private val MapView.tagData: MapTagData + get() = tag as MapTagData public typealias GoogleMapFactory = @Composable () -> Unit @@ -276,6 +306,68 @@ public fun googleMapFactory( } } +private class MapLifecycleEventObserver(private val mapView: MapView) : LifecycleEventObserver { + private var currentLifecycleState: Lifecycle.State = Lifecycle.State.INITIALIZED + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + when (event) { + // [mapView.onDestroy] is only invoked from AndroidView->onRelease. + Lifecycle.Event.ON_DESTROY -> moveToBaseState() + else -> moveToLifecycleState(event.targetState) + } + } + + /** + * Move down to [Lifecycle.State.CREATED] but only if [currentLifecycleState] is actually above that. + * It's theoretically possible that [currentLifecycleState] is still in [Lifecycle.State.INITIALIZED] state. + * */ + fun moveToBaseState() { + if (currentLifecycleState > Lifecycle.State.CREATED) { + moveToLifecycleState(Lifecycle.State.CREATED) + } + } + + fun moveToDestroyedState() { + if (currentLifecycleState > Lifecycle.State.INITIALIZED) { + moveToLifecycleState(Lifecycle.State.DESTROYED) + } + } + + private fun moveToLifecycleState(targetState: Lifecycle.State) { + while (currentLifecycleState != targetState) { + when { + currentLifecycleState < targetState -> moveUp() + currentLifecycleState > targetState -> moveDown() + } + } + } + + private fun moveDown() { + val event = Lifecycle.Event.downFrom(currentLifecycleState) + ?: error("no event down from $currentLifecycleState") + invokeEvent(event) + } + + private fun moveUp() { + val event = Lifecycle.Event.upFrom(currentLifecycleState) + ?: error("no event up from $currentLifecycleState") + invokeEvent(event) + } + + private fun invokeEvent(event: Lifecycle.Event) { + when (event) { + Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle()) + Lifecycle.Event.ON_START -> mapView.onStart() + Lifecycle.Event.ON_RESUME -> mapView.onResume() + Lifecycle.Event.ON_PAUSE -> mapView.onPause() + Lifecycle.Event.ON_STOP -> mapView.onStop() + Lifecycle.Event.ON_DESTROY -> mapView.onDestroy() + else -> error("Unsupported lifecycle event: $event") + } + currentLifecycleState = event.targetState + } +} + /** * Enum representing a 1-1 mapping to [com.google.android.gms.maps.model.MapColorScheme]. * @@ -287,4 +379,4 @@ public enum class ComposeMapColorScheme(public val value: Int) { LIGHT(MapColorScheme.LIGHT), DARK(MapColorScheme.DARK), FOLLOW_SYSTEM(MapColorScheme.FOLLOW_SYSTEM); -} \ No newline at end of file +} diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/MapUpdater.kt b/maps-compose/src/main/java/com/google/maps/android/compose/MapUpdater.kt index 44a0b5df1..d30298434 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/MapUpdater.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/MapUpdater.kt @@ -25,8 +25,6 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection import com.google.android.gms.maps.GoogleMap -import com.google.android.gms.maps.LocationSource -import com.google.android.gms.maps.model.MapColorScheme internal class MapPropertiesNode( val map: GoogleMap, @@ -97,16 +95,7 @@ public val DefaultMapContentPadding: PaddingValues = PaddingValues() @SuppressLint("MissingPermission") @Suppress("NOTHING_TO_INLINE") @Composable -internal inline fun MapUpdater( - mergeDescendants: Boolean = false, - contentDescription: String?, - cameraPositionState: CameraPositionState, - contentPadding: PaddingValues = DefaultMapContentPadding, - locationSource: LocationSource?, - mapProperties: MapProperties, - mapUiSettings: MapUiSettings, - colorMapScheme: Int?, -) { +internal inline fun MapUpdater(mapUpdaterState: MapUpdaterState) = with(mapUpdaterState) { val map = (currentComposer.applier as MapApplier).map val mapView = (currentComposer.applier as MapApplier).mapView if (mergeDescendants) { @@ -141,7 +130,7 @@ internal inline fun MapUpdater( set(mapProperties.mapType) { map.mapType = it.value } set(mapProperties.maxZoomPreference) { map.setMaxZoomPreference(it) } set(mapProperties.minZoomPreference) { map.setMinZoomPreference(it) } - set(colorMapScheme) { + set(mapColorScheme) { if (it != null) { map.mapColorScheme = it } diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/streetview/StreetView.kt b/maps-compose/src/main/java/com/google/maps/android/compose/streetview/StreetView.kt index e73c7d53f..98227a241 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/streetview/StreetView.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/streetview/StreetView.kt @@ -38,9 +38,9 @@ import androidx.lifecycle.LifecycleEventObserver import com.google.android.gms.maps.StreetViewPanoramaOptions import com.google.android.gms.maps.StreetViewPanoramaView import com.google.android.gms.maps.model.StreetViewPanoramaOrientation -import com.google.maps.android.compose.disposingComposition import com.google.maps.android.ktx.MapsExperimentalFeature import com.google.maps.android.ktx.awaitStreetViewPanorama +import kotlinx.coroutines.awaitCancellation /** * A composable for displaying a Street View for a given location. A location might not be available for a given @@ -131,6 +131,15 @@ private fun StreetViewLifecycle(streetView: StreetViewPanoramaView) { } } +private suspend inline fun disposingComposition(factory: () -> Composition) { + val composition = factory() + try { + awaitCancellation() + } finally { + composition.dispose() + } +} + private suspend inline fun StreetViewPanoramaView.newComposition( parent: CompositionContext, noinline content: @Composable () -> Unit