Skip to content

Commit

Permalink
feat: Use AndroidView overload which re-uses MapView to improve perfo…
Browse files Browse the repository at this point in the history
…rmance in lazy layouts (#436)

* 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 <[email protected]>

---------

Co-authored-by: Enrique López Mañas <[email protected]>
  • Loading branch information
philip-segerfast and kikoso authored Jul 9, 2024
1 parent ea4ae91 commit 0447f43
Show file tree
Hide file tree
Showing 7 changed files with 492 additions and 126 deletions.
3 changes: 3 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@
<activity
android:name=".MapInColumnActivity"
android:exported="false"/>
<activity
android:name=".MapsInLazyColumnActivity"
android:exported="false"/>
<activity
android:name=".markerexamples.MarkerClusteringActivity"
android:exported="false"/>
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/java/com/google/maps/android/compose/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MapListItem>) {
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")
}
}
}
}
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,5 @@
<string name="street_view">Street View</string>
<string name="custom_location_button">Custom Location Button</string>
<string name="accessibility_button">Accessibility</string>
<string name="maps_in_lazy_column_activity">Maps in LazyColumn</string>
</resources>
Loading

0 comments on commit 0447f43

Please sign in to comment.