From 844d3ff253f2ac8e0a04e2b9286baaf8eae25a0e Mon Sep 17 00:00:00 2001 From: Chris Arriola Date: Mon, 6 Jun 2022 11:25:58 -0700 Subject: [PATCH] feat: Create MapEffect Change-Id: I6612683d4c67d350dd0b66310e98108b564b73e3 --- README.md | 32 +++++++ app/build.gradle | 6 +- app/src/main/AndroidManifest.xml | 7 +- .../maps/android/compose/MainActivity.kt | 7 ++ .../android/compose/MapClusteringActivity.kt | 96 +++++++++++++++++++ .../android/compose/MapInColumnActivity.kt | 1 - app/src/main/res/values/strings.xml | 1 + maps-compose/build.gradle | 1 + .../google/maps/android/compose/GoogleMap.kt | 1 - .../google/maps/android/compose/MapEffect.kt | 96 +++++++++++++++++++ .../compose/MapsComposeExperimentalApi.kt | 13 +++ 11 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/google/maps/android/compose/MapClusteringActivity.kt create mode 100644 maps-compose/src/main/java/com/google/maps/android/compose/MapEffect.kt create mode 100644 maps-compose/src/main/java/com/google/maps/android/compose/MapsComposeExperimentalApi.kt diff --git a/README.md b/README.md index ceddfb99..5340b882 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,38 @@ MarkerInfoWindow( } ``` +#### Obtaining Access to the raw GoogleMap (Experimental) + +Certain use cases require extending the `GoogleMap` object to decorate / augment +the map. For example, while marker clustering is not yet supported by Maps Compose +(see [Issue #44](https://github.com/googlemaps/android-maps-compose/issues/44)), +it is desirable to use the available [utility library](https://github.com/googlemaps/android-maps-utils) +to perform clustering in the interim. Doing so requires access to the Maps SDK +`GoogleMap` object which you can obtain with the `MapEffect` composable. + +```kotlin +GoogleMap( + // ... +) { + val context = LocalContext.current + var clusterManager by remember { mutableStateOf?>(null) } + MapEffect(items) { map -> + clusterManager = ClusterManager(context, map) + clusterManager?.addItems(items) + } +} +``` + +Note, however, that `MapEffect` is designed as an escape hatch and has certain +gotchas. The `GoogleMap` composable provided by the Maps Compose library manages +properties while the `GoogleMap` is in composition, and so, setting properties +on the `GoogleMap` instance provided in the `MapEffect` composable may have +unintended consequences. For instance, using the utility library to perform +clustering as shown in the example above will break `onClick` events from +being propagated on `Marker` composables. So, if you are using clustering as +shown above, stick with adding markers through the `ClusterManager` and don't +use `Marker` composables (unless you don't care about `onClick` events). + ## Sample App This repository includes a [sample app](app). diff --git a/app/build.gradle b/app/build.gradle index 6ae0c4b5..76929284 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -33,6 +33,7 @@ android { kotlinOptions { jvmTarget = '1.8' + freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn' } } @@ -45,6 +46,7 @@ dependencies { implementation 'com.google.android.material:material:1.5.0' implementation 'com.google.maps.android:maps-ktx:3.3.0' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation 'com.google.maps.android:android-maps-utils:2.3.0' androidTestImplementation "androidx.test:core:$androidx_test_version" androidTestImplementation "androidx.test:rules:$androidx_test_version" @@ -60,9 +62,9 @@ dependencies { // module. // However, this should remain uncommented on the `main` branch so that // the maven declaration of Maps Compose can be used as a snippet. - // implementation project(':maps-compose') + implementation project(':maps-compose') // [END_EXCLUDE] - implementation "com.google.maps.android:maps-compose:2.2.0" + //implementation "com.google.maps.android:maps-compose:2.2.0" implementation 'com.google.android.gms:play-services-maps:18.0.2' } // [END maps_android_compose_dependency] diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0120d514..ed5a0400 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -42,10 +42,13 @@ + android:exported="false" /> + android:exported="false"/> + 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 02c49847..e2240f54 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 @@ -67,6 +67,13 @@ class MainActivity : ComponentActivity() { }) { Text(getString(R.string.map_in_column_activity)) } + Spacer(modifier = Modifier.padding(5.dp)) + Button( + onClick = { + context.startActivity(Intent(context, MapClusteringActivity::class.java)) + }) { + Text(getString(R.string.map_clustering_activity)) + } } } } diff --git a/app/src/main/java/com/google/maps/android/compose/MapClusteringActivity.kt b/app/src/main/java/com/google/maps/android/compose/MapClusteringActivity.kt new file mode 100644 index 00000000..c0b37184 --- /dev/null +++ b/app/src/main/java/com/google/maps/android/compose/MapClusteringActivity.kt @@ -0,0 +1,96 @@ +package com.google.maps.android.compose + +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.clustering.ClusterItem +import com.google.maps.android.clustering.ClusterManager +import kotlin.random.Random + +private val singapore = LatLng(1.35, 103.87) +private val singapore2 = LatLng(2.50, 103.87) +private val TAG = MapClusteringActivity::class.simpleName + +class MapClusteringActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + GoogleMapClustering() + } + } +} + +@Composable +fun GoogleMapClustering() { + val items = remember { mutableStateListOf() } + LaunchedEffect(Unit) { + for (i in 1..10) { + val position = LatLng( + singapore2.latitude + Random.nextFloat(), + singapore2.longitude + Random.nextFloat(), + ) + items.add(MyItem(position, "Marker", "Snippet")) + } + } + GoogleMapClustering(items = items) +} + +@OptIn(MapsComposeExperimentalApi::class) +@Composable +fun GoogleMapClustering(items: List) { + val cameraPositionState = rememberCameraPositionState { + position = CameraPosition.fromLatLngZoom(singapore, 10f) + } + GoogleMap( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState + ) { + val context = LocalContext.current + var clusterManager by remember { mutableStateOf?>(null) } + MapEffect(items) { map -> + clusterManager = ClusterManager(context, map) + clusterManager?.addItems(items) + } + LaunchedEffect(key1 = cameraPositionState.isMoving) { + if (!cameraPositionState.isMoving) { + clusterManager?.cluster() + } + } + MarkerInfoWindow( + state = rememberMarkerState(position = singapore), + onClick = { + // This won't work :( + Log.d(TAG, "I was clicked $it") + true + } + ) + } +} + +data class MyItem( + val itemPosition: LatLng, + val itemTitle: String, + val itemSnippet: String, +) : ClusterItem { + override fun getPosition(): LatLng = + itemPosition + + override fun getTitle(): String = + itemTitle + + override fun getSnippet(): String = + itemSnippet +} \ No newline at end of file diff --git a/app/src/main/java/com/google/maps/android/compose/MapInColumnActivity.kt b/app/src/main/java/com/google/maps/android/compose/MapInColumnActivity.kt index 8aad53c2..6c42b790 100644 --- a/app/src/main/java/com/google/maps/android/compose/MapInColumnActivity.kt +++ b/app/src/main/java/com/google/maps/android/compose/MapInColumnActivity.kt @@ -48,7 +48,6 @@ private val defaultCameraPosition = CameraPosition.fromLatLngZoom(singapore, 11f class MapInColumnActivity : ComponentActivity() { - @OptIn(ExperimentalComposeUiApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c8fb093c..666bb621 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,4 +19,5 @@ "Maps Compose Demos \uD83D\uDDFA" Basic Map Activity Map In Column Activity + Map Clustering \ No newline at end of file diff --git a/maps-compose/build.gradle b/maps-compose/build.gradle index 0e2dd7d6..a29ec320 100644 --- a/maps-compose/build.gradle +++ b/maps-compose/build.gradle @@ -30,6 +30,7 @@ android { kotlinOptions { jvmTarget = '1.8' freeCompilerArgs += '-Xexplicit-api=strict' + freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn' } } 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 0111a53c..edbad287 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 @@ -124,7 +124,6 @@ public fun GoogleMap( mapProperties = currentMapProperties, mapUiSettings = currentUiSettings, ) - currentContent?.invoke() } } diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/MapEffect.kt b/maps-compose/src/main/java/com/google/maps/android/compose/MapEffect.kt new file mode 100644 index 00000000..6cac92b7 --- /dev/null +++ b/maps-compose/src/main/java/com/google/maps/android/compose/MapEffect.kt @@ -0,0 +1,96 @@ +package com.google.maps.android.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ExperimentalComposeApi +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.currentComposer +import com.google.android.gms.maps.GoogleMap +import kotlinx.coroutines.CoroutineScope + +/** + * A side-effect backed by a [LaunchedEffect] which will launch [block] and provide the underlying + * managed [GoogleMap] object into the composition's [CoroutineContext]. This effect will be + * re-launched when a different [key1] is provided. + * + * Note: This effect should be used with caution as the [GoogleMap]'s properties is managed by the + * [_root_ide_package_.com.google.maps.android.compose.GoogleMap()] composable function. However, + * there are use cases when obtaining a raw reference to the map is desirable for extensibility + * (e.g. using the utility library for clustering). + */ +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun MapEffect(key1: Any?, block: suspend CoroutineScope.(GoogleMap) -> Unit) { + val map = (currentComposer.applier as MapApplier).map + LaunchedEffect(key1 = key1) { + block(map) + } +} + +/** + * A side-effect backed by a [LaunchedEffect] which will launch [block] and provide the underlying + * managed [GoogleMap] object into the composition's [CoroutineContext]. This effect will be + * re-launched when a different [key1] or [key2] is provided. + * + * Note: This effect should be used with caution as the [GoogleMap]'s properties is managed by the + * [_root_ide_package_.com.google.maps.android.compose.GoogleMap()] composable function. However, + * there are use cases when obtaining a raw reference to the map is desirable for extensibility + * (e.g. using the utility library for clustering). + */ +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun MapEffect(key1: Any?, key2: Any?, block: suspend CoroutineScope.(GoogleMap) -> Unit) { + val map = (currentComposer.applier as MapApplier).map + LaunchedEffect(key1 = key1, key2 = key2) { + block(map) + } +} + +/** + * A side-effect backed by a [LaunchedEffect] which will launch [block] and provide the underlying + * managed [GoogleMap] object into the composition's [CoroutineContext]. This effect will be + * re-launched when a different [key1], [key2], or [key3] is provided. + * + * Note: This effect should be used with caution as the [GoogleMap]'s properties is managed by the + * [_root_ide_package_.com.google.maps.android.compose.GoogleMap()] composable function. However, + * there are use cases when obtaining a raw reference to the map is desirable for extensibility + * (e.g. using the utility library for clustering). + */ +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun MapEffect( + key1: Any?, + key2: Any?, + key3: Any?, + block: suspend CoroutineScope.(GoogleMap) -> Unit +) { + val map = (currentComposer.applier as MapApplier).map + LaunchedEffect(key1 = key1, key2 = key2, key3 = key3) { + block(map) + } +} + +/** + * A side-effect backed by a [LaunchedEffect] which will launch [block] and provide the underlying + * managed [GoogleMap] object into the composition's [CoroutineContext]. This effect will be + * re-launched with any different [keys]. + * + * Note: This effect should be used with caution as the [GoogleMap]'s properties is managed by the + * [_root_ide_package_.com.google.maps.android.compose.GoogleMap()] composable function. However, + * there are use cases when obtaining a raw reference to the map is desirable for extensibility + * (e.g. using the utility library for clustering). + */ +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun MapEffect( + vararg keys: Any?, + block: suspend CoroutineScope.(GoogleMap) -> Unit +) { + val map = (currentComposer.applier as MapApplier).map + LaunchedEffect(keys = keys) { + block(map) + } +} diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/MapsComposeExperimentalApi.kt b/maps-compose/src/main/java/com/google/maps/android/compose/MapsComposeExperimentalApi.kt new file mode 100644 index 00000000..41949f85 --- /dev/null +++ b/maps-compose/src/main/java/com/google/maps/android/compose/MapsComposeExperimentalApi.kt @@ -0,0 +1,13 @@ +package com.google.maps.android.compose + +/** + * Marks declarations that are still **experimental**. + * + */ +@MustBeDocumented +@Retention(value = AnnotationRetention.BINARY) +@RequiresOptIn( + level = RequiresOptIn.Level.WARNING, + message = "Targets marked by this annotation may contain breaking changes in the future as their design is still incubating." +) +public annotation class MapsComposeExperimentalApi \ No newline at end of file