Skip to content

Commit

Permalink
feat: Add experimental MapEffect composable
Browse files Browse the repository at this point in the history
Change-Id: I6612683d4c67d350dd0b66310e98108b564b73e3
  • Loading branch information
arriolac committed Jun 9, 2022
1 parent e4e6796 commit 4ccbc2d
Show file tree
Hide file tree
Showing 11 changed files with 259 additions and 6 deletions.
34 changes: 34 additions & 0 deletions README.md
Expand Up @@ -153,6 +153,40 @@ 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<ClusterManager<MyItem>?>(null) }
MapEffect(items) { map ->
if (clusterManager == null) {
clusterManager = ClusterManager<MyItem>(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).
Expand Down
6 changes: 4 additions & 2 deletions app/build.gradle
Expand Up @@ -33,6 +33,7 @@ android {

kotlinOptions {
jvmTarget = '1.8'
freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn'
}
}

Expand All @@ -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"
Expand All @@ -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]
Expand Down
7 changes: 5 additions & 2 deletions app/src/main/AndroidManifest.xml
Expand Up @@ -42,10 +42,13 @@
</activity>
<activity
android:name=".BasicMapActivity"
android:exported="true" />
android:exported="false" />
<activity
android:name=".MapInColumnActivity"
android:exported="true"/>
android:exported="false"/>
<activity
android:name=".MapClusteringActivity"
android:exported="false"/>

<!-- Used by createComponentActivity() for unit testing -->
<activity android:name="androidx.activity.ComponentActivity" />
Expand Down
Expand Up @@ -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))
}
}
}
}
Expand Down
@@ -0,0 +1,98 @@
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<MyItem>() }
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<MyItem>) {
val cameraPositionState = rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(singapore, 10f)
}
GoogleMap(
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraPositionState
) {
val context = LocalContext.current
var clusterManager by remember { mutableStateOf<ClusterManager<MyItem>?>(null) }
MapEffect(items) { map ->
if (clusterManager == null) {
clusterManager = ClusterManager<MyItem>(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
}
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Expand Up @@ -19,4 +19,5 @@
<string name="main_activity_title">"Maps Compose Demos \uD83D\uDDFA"</string>
<string name="basic_map_activity">Basic Map Activity</string>
<string name="map_in_column_activity">Map In Column Activity</string>
<string name="map_clustering_activity">Map Clustering</string>
</resources>
1 change: 1 addition & 0 deletions maps-compose/build.gradle
Expand Up @@ -30,6 +30,7 @@ android {
kotlinOptions {
jvmTarget = '1.8'
freeCompilerArgs += '-Xexplicit-api=strict'
freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn'
}
}

Expand Down
Expand Up @@ -124,7 +124,6 @@ public fun GoogleMap(
mapProperties = currentMapProperties,
mapUiSettings = currentUiSettings,
)

currentContent?.invoke()
}
}
Expand Down
@@ -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)
}
}
@@ -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

0 comments on commit 4ccbc2d

Please sign in to comment.