Skip to content

Commit

Permalink
feat: Add experimental MapEffect composable (#140)
Browse files Browse the repository at this point in the history
* feat: Add experimental MapEffect composable

Change-Id: I6612683d4c67d350dd0b66310e98108b564b73e3

* Update app/src/main/java/com/google/maps/android/compose/MapClusteringActivity.kt

Co-authored-by: Sean Barbeau <sjbarbeau@gmail.com>

* PR feedback.

Change-Id: I84922456c00314ab3a622385accd088980fa0695

Co-authored-by: Sean Barbeau <sjbarbeau@gmail.com>
  • Loading branch information
arriolac and barbeau authored Jun 14, 2022
1 parent e4e6796 commit 8ed7eb4
Show file tree
Hide file tree
Showing 11 changed files with 271 additions and 5 deletions.
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,52 @@ 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)
}

MarkerInfoWindow(
state = rememberMarkerState(position = LatLng(1.35, 103.87)),
onClick = {
// This won't work :(
Log.d("MapEffect", "I cannot be clicked :( $it")
true
}
)

}
```

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 as shown in the comment above. So, if
you are using clustering, stick with adding markers through the `ClusterManager`
and don't use `Marker` composables (unless you don't care about `onClick`
events). Clustering is the only use-case tested with `MapEffect`, there may be
gotchas depending on what features you use in the utility library.

## Sample App

This repository includes a [sample app](app).
Expand Down
4 changes: 3 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
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 @@ -62,7 +64,7 @@ dependencies {
// the maven declaration of Maps Compose can be used as a snippet.
// 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.1"
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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.GoogleMap
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?.onCameraIdle()
}
}
MarkerInfoWindow(
state = rememberMarkerState(position = singapore),
onClick = {
// This won't work :(
Log.d(TAG, "I cannot be 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
}
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ android {
kotlinOptions {
jvmTarget = '1.8'
freeCompilerArgs += '-Xexplicit-api=strict'
freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn'
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,6 @@ public fun GoogleMap(
mapProperties = currentMapProperties,
mapUiSettings = currentUiSettings,
)

currentContent?.invoke()
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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 8ed7eb4

Please sign in to comment.