diff --git a/maps-app/src/main/java/com/google/maps/android/compose/markerexamples/MarkerClusteringActivity.kt b/maps-app/src/main/java/com/google/maps/android/compose/markerexamples/MarkerClusteringActivity.kt index 98f722cf..6d4e5b71 100644 --- a/maps-app/src/main/java/com/google/maps/android/compose/markerexamples/MarkerClusteringActivity.kt +++ b/maps-app/src/main/java/com/google/maps/android/compose/markerexamples/MarkerClusteringActivity.kt @@ -18,22 +18,22 @@ package com.google.maps.android.compose.markerexamples import android.os.Bundle import android.util.Log -import androidx.compose.ui.text.intl.Locale import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme @@ -48,11 +48,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -61,8 +62,10 @@ import com.google.android.gms.maps.model.LatLng import com.google.maps.android.clustering.ClusterItem import com.google.maps.android.clustering.algo.NonHierarchicalViewBasedAlgorithm import com.google.maps.android.clustering.view.DefaultClusterRenderer +import com.google.maps.android.compose.Circle import com.google.maps.android.compose.GoogleMap import com.google.maps.android.compose.MapsComposeExperimentalApi +import com.google.maps.android.compose.MarkerComposable import com.google.maps.android.compose.MarkerInfoWindow import com.google.maps.android.compose.clustering.Clustering import com.google.maps.android.compose.clustering.ClusteringMarkerProperties @@ -70,8 +73,6 @@ import com.google.maps.android.compose.clustering.rememberClusterManager import com.google.maps.android.compose.clustering.rememberClusterRenderer import com.google.maps.android.compose.rememberCameraPositionState import com.google.maps.android.compose.rememberUpdatedMarkerState -import com.google.maps.android.compose.Circle -import com.google.maps.android.compose.singapore import com.google.maps.android.compose.singapore2 import kotlin.random.Random @@ -100,7 +101,8 @@ fun GoogleMapClustering() { } } Box( - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxSize() .systemBarsPadding() ) { GoogleMapClustering(items = items) @@ -142,6 +144,10 @@ fun GoogleMapClustering(items: List) { items = items, ) } + + ClusteringType.ComposableMarker -> { + ComposableMarkerClustering(items) + } } MarkerInfoWindow( @@ -239,15 +245,6 @@ fun CustomRendererClustering(items: List) { val screenHeight = configuration.screenHeightDp.dp val screenWidth = configuration.screenWidthDp.dp val clusterManager = rememberClusterManager() - - // Here the clusterManager is being customized with a NonHierarchicalViewBasedAlgorithm. - // This speeds up by a factor the rendering of items on the screen. - clusterManager?.setAlgorithm( - NonHierarchicalViewBasedAlgorithm( - screenWidth.value.toInt(), - screenHeight.value.toInt() - ) - ) val renderer = rememberClusterRenderer( clusterContent = { cluster -> CircleContent( @@ -265,6 +262,24 @@ fun CustomRendererClustering(items: List) { }, clusterManager = clusterManager, ) + var rendererConfigured by remember(clusterManager, renderer) { mutableStateOf(false) } + + LaunchedEffect(clusterManager, renderer, screenWidth, screenHeight) { + val currentClusterManager = clusterManager ?: return@LaunchedEffect + val currentRenderer = renderer ?: return@LaunchedEffect + + if (currentClusterManager.renderer !== currentRenderer) { + currentClusterManager.renderer = currentRenderer + } + // This algorithm limits clustering work to items visible on the screen. + currentClusterManager.setAlgorithm( + NonHierarchicalViewBasedAlgorithm( + screenWidth.value.toInt(), + screenHeight.value.toInt() + ) + ) + rendererConfigured = true + } SideEffect { clusterManager ?: return@SideEffect @@ -280,19 +295,13 @@ fun CustomRendererClustering(items: List) { Log.d(TAG, "Cluster item info window clicked! $it") } } - SideEffect { - if (clusterManager?.renderer != renderer) { - clusterManager?.renderer = renderer ?: return@SideEffect - } - } - if (clusterManager != null) { + if (clusterManager != null && rendererConfigured) { Clustering( items = items, clusterManager = clusterManager, ) } - } @OptIn(MapsComposeExperimentalApi::class) @@ -312,6 +321,32 @@ private fun DecorationsClustering(items: List) { ) } +@OptIn(MapsComposeExperimentalApi::class) +@Composable +private fun ComposableMarkerClustering(items: List) { + Clustering( + items = items, + clusterItemContent = { item -> + @Suppress("COMPOSE_APPLIER_CALL_MISMATCH") + MarkerComposable( + title = item.title, + snippet = item.snippet, + onClick = { marker -> + Log.d(TAG, "Composable cluster marker clicked! $marker") + false + }, + ) { + Icon( + imageVector = Icons.Default.LocationOn, + contentDescription = null, + tint = Color.Yellow, + modifier = Modifier.size(20.dp), + ) + } + }, + ) +} + @Composable private fun CircleContent( color: Color, @@ -341,10 +376,8 @@ private fun ClusteringTypeControls( onClusteringTypeClick: (ClusteringType) -> Unit, modifier: Modifier = Modifier, ) { - Row( - modifier - .fillMaxWidth() - .horizontalScroll(state = ScrollState(0)), + FlowRow( + modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start ) { ClusteringType.entries.forEach { @@ -354,6 +387,7 @@ private fun ClusteringTypeControls( ClusteringType.CustomUi -> "Custom UI" ClusteringType.CustomRenderer -> "Custom Renderer" ClusteringType.Decorations -> "Decorations" + ClusteringType.ComposableMarker -> "Composable Markers" }, onClick = { onClusteringTypeClick(it) } ) @@ -380,6 +414,7 @@ private enum class ClusteringType { CustomUi, CustomRenderer, Decorations, + ComposableMarker, } data class MyItem( diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt index 102c98eb..82b89612 100644 --- a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterRenderer.kt @@ -16,53 +16,65 @@ package com.google.maps.android.compose.clustering -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.snapshotFlow - import android.content.Context -import android.graphics.Bitmap import android.graphics.Canvas import android.view.View import android.view.ViewGroup import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.platform.AbstractComposeView import androidx.core.graphics.applyCanvas import androidx.core.graphics.createBitmap import androidx.core.view.doOnAttach import androidx.core.view.doOnDetach -import androidx.compose.ui.geometry.Offset +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.model.BitmapDescriptor import com.google.android.gms.maps.model.BitmapDescriptorFactory +import com.google.android.gms.maps.model.Marker import com.google.android.gms.maps.model.MarkerOptions import com.google.maps.android.clustering.Cluster import com.google.maps.android.clustering.ClusterItem import com.google.maps.android.clustering.ClusterManager import com.google.maps.android.clustering.view.DefaultClusterRenderer import com.google.maps.android.compose.ComposeUiViewRenderer +import com.google.maps.android.compose.LocalMarkerComposableHost +import com.google.maps.android.compose.MarkerComposableHost +import com.google.maps.android.compose.MarkerComposableProperties import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.android.awaitFrame -import kotlinx.coroutines.isActive import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LifecycleRegistry -import androidx.lifecycle.setViewTreeLifecycleOwner -import androidx.savedstate.SavedStateRegistry -import androidx.savedstate.SavedStateRegistryController -import androidx.savedstate.SavedStateRegistryOwner -import androidx.savedstate.setViewTreeSavedStateRegistryOwner internal interface ClusterRendererItemState { val unclusteredItems: State> } +internal interface ClusterRendererMarkerEventHandler { + fun onInfoWindowClose(marker: Marker) + + fun onMarkerDragStart(marker: Marker) + + fun onMarkerDrag(marker: Marker) + + fun onMarkerDragEnd(marker: Marker) +} + /** * Implementation of [ClusterRenderer] that renders marker bitmaps from Compose UI content. * [clusterContentState] renders clusters, and [clusterItemContentState] renders non-clustered @@ -85,8 +97,8 @@ internal class ComposeUiClusterRenderer( ) : DefaultClusterRenderer( context, map, - clusterManager -), ClusterRendererItemState { + clusterManager, +), ClusterRendererItemState, ClusterRendererMarkerEventHandler { override val unclusteredItems = mutableStateOf(emptySet()) @@ -105,6 +117,7 @@ internal class ComposeUiClusterRenderer( } override val savedStateRegistry: SavedStateRegistry get() = controller.savedStateRegistry override val lifecycle: Lifecycle get() = fakeLifecycleOwner.lifecycle + init { fakeLifecycleOwner.lifecycleRegistry.currentState = Lifecycle.State.RESUMED } @@ -136,13 +149,22 @@ internal class ComposeUiClusterRenderer( rotation = props.rotation ?: clusterContentRotationState.value } } + is ViewKey.Item -> { getMarker(key.item)?.apply { val props = viewInfo.view.properties - val anchor = props.anchor ?: clusterItemContentAnchorState.value + val markerProperties = viewInfo.view.markerComposableProperties + val anchor = props.anchor + ?: markerProperties?.anchor + ?: clusterItemContentAnchorState.value setAnchor(anchor.x, anchor.y) - zIndex = props.zIndex ?: clusterItemContentZIndexState.value - rotation = props.rotation ?: clusterItemContentRotationState.value + + zIndex = props.zIndex + ?: markerProperties?.zIndex + ?: clusterItemContentZIndexState.value + rotation = props.rotation + ?: markerProperties?.rotation + ?: clusterItemContentRotationState.value } } } @@ -151,6 +173,74 @@ internal class ComposeUiClusterRenderer( } } + override fun setOnClusterItemClickListener( + listener: ClusterManager.OnClusterItemClickListener? + ) { + super.setOnClusterItemClickListener { item -> + val marker = getMarker(item) + val handledByMarker = marker != null && keysToViews[ViewKey.Item(item)] + ?.view + ?.markerComposableProperties + ?.onClick + ?.invoke(marker) == true + handledByMarker || listener?.onClusterItemClick(item) == true + } + } + + override fun setOnClusterItemInfoWindowClickListener( + listener: ClusterManager.OnClusterItemInfoWindowClickListener? + ) { + super.setOnClusterItemInfoWindowClickListener { item -> + getMarker(item)?.let { marker -> + keysToViews[ViewKey.Item(item)]?.view?.markerComposableProperties + ?.onInfoWindowClick?.invoke(marker) + } + listener?.onClusterItemInfoWindowClick(item) + } + } + + override fun setOnClusterItemInfoWindowLongClickListener( + listener: ClusterManager.OnClusterItemInfoWindowLongClickListener? + ) { + super.setOnClusterItemInfoWindowLongClickListener { item -> + getMarker(item)?.let { marker -> + keysToViews[ViewKey.Item(item)]?.view?.markerComposableProperties + ?.onInfoWindowLongClick?.invoke(marker) + } + listener?.onClusterItemInfoWindowLongClick(item) + } + } + + override fun onInfoWindowClose(marker: Marker) { + val item = getClusterItem(marker) ?: return + keysToViews[ViewKey.Item(item)]?.view?.markerComposableProperties + ?.onInfoWindowClose?.invoke(marker) + } + + override fun onMarkerDragStart(marker: Marker) { + markerComposableProperties(marker)?.onMarkerDragStart(marker) + } + + override fun onMarkerDrag(marker: Marker) { + markerComposableProperties(marker)?.onMarkerDrag(marker) + } + + override fun onMarkerDragEnd(marker: Marker) { + markerComposableProperties(marker)?.onMarkerDragEnd(marker) + } + + override fun onRemove() { + keysToViews.values.forEach { it.onRemove() } + keysToViews.clear() + unclusteredItems.value = emptySet() + super.onRemove() + } + + private fun markerComposableProperties(marker: Marker): MarkerComposableProperties? { + val item = getClusterItem(marker) ?: return null + return keysToViews[ViewKey.Item(item)]?.view?.markerComposableProperties + } + override fun onClustersChanged(clusters: Set>) { super.onClustersChanged(clusters) unclusteredItems.value = clusters.filter { !shouldRenderAsCluster(it) } @@ -199,6 +289,7 @@ internal class ComposeUiClusterRenderer( private fun createAndAddView(key: ViewKey): ViewInfo { val view = InvalidatingComposeView( context, + supportsMarkerComposable = key is ViewKey.Item, getRotationOverride = { when (key) { is ViewKey.Cluster -> clusterContentRotationState.value @@ -237,6 +328,7 @@ internal class ComposeUiClusterRenderer( val viewInfo = ViewInfo( view, onRemove = { + view.detachMarker() rerenderJob.cancel() renderHandle.dispose() }, @@ -266,7 +358,9 @@ internal class ComposeUiClusterRenderer( view.doOnAttach { view.doOnDetach { close() } } - awaitClose() + awaitClose { + view.onInvalidate = null + } } .collectLatest { when (key) { @@ -279,13 +373,12 @@ internal class ComposeUiClusterRenderer( rotation = view.properties.rotation ?: clusterContentRotationState.value } } + is ViewKey.Item -> { getMarker(key.item)?.apply { setIcon(renderViewToBitmapDescriptor(view)) - val anchor = view.properties.anchor ?: clusterItemContentAnchorState.value - setAnchor(anchor.x, anchor.y) - zIndex = view.properties.zIndex ?: clusterItemContentZIndexState.value - rotation = view.properties.rotation ?: clusterItemContentRotationState.value + view.attachMarker(this) + applyClusterItemProperties(view) } } } @@ -330,13 +423,88 @@ internal class ComposeUiClusterRenderer( markerOptions.icon(renderViewToBitmapDescriptor(viewInfo.view)) val props = viewInfo.view.properties - val anchor = props.anchor ?: clusterItemContentAnchorState.value + val markerProperties = viewInfo.view.markerComposableProperties + markerProperties?.applyTo(markerOptions) + + val anchor = props.anchor + ?: markerProperties?.anchor + ?: clusterItemContentAnchorState.value + markerOptions.anchor(anchor.x, anchor.y) - markerOptions.zIndex(props.zIndex ?: clusterItemContentZIndexState.value) - markerOptions.rotation(props.rotation ?: clusterItemContentRotationState.value) + markerOptions.zIndex( + props.zIndex ?: markerProperties?.zIndex ?: clusterItemContentZIndexState.value + ) + markerOptions.rotation( + props.rotation ?: markerProperties?.rotation + ?: clusterItemContentRotationState.value + ) + } + } + + override fun onClusterItemUpdated(item: T, marker: Marker) { + val markerProperties = keysToViews[ViewKey.Item(item)]?.view?.markerComposableProperties + if (markerProperties == null) { + super.onClusterItemUpdated(item, marker) + } else if (marker.position != item.position) { + marker.position = item.position + } + } + + override fun onClusterItemRendered(clusterItem: T, marker: Marker) { + super.onClusterItemRendered(clusterItem, marker) + keysToViews[ViewKey.Item(clusterItem)]?.view?.let { view -> + view.attachMarker(marker) + marker.applyClusterItemProperties(view) } } + private fun Marker.applyClusterItemProperties(view: InvalidatingComposeView) { + val markerProperties = view.markerComposableProperties + val anchor = view.properties.anchor + ?: markerProperties?.anchor + ?: clusterItemContentAnchorState.value + setAnchor(anchor.x, anchor.y) + + zIndex = view.properties.zIndex + ?: markerProperties?.zIndex + ?: clusterItemContentZIndexState.value + rotation = view.properties.rotation + ?: markerProperties?.rotation + ?: clusterItemContentRotationState.value + + markerProperties ?: return + + alpha = markerProperties.alpha + isDraggable = markerProperties.draggable + isFlat = markerProperties.flat + setInfoWindowAnchor( + markerProperties.infoWindowAnchor.x, + markerProperties.infoWindowAnchor.y, + ) + val infoWindowContentChanged = + title != markerProperties.title || snippet != markerProperties.snippet + snippet = markerProperties.snippet + title = markerProperties.title + tag = markerProperties.tag + isVisible = markerProperties.visible + + if (infoWindowContentChanged && isInfoWindowShown) { + showInfoWindow() + } + } + + private fun MarkerComposableProperties.applyTo(markerOptions: MarkerOptions) { + markerOptions + .contentDescription(contentDescription) + .alpha(alpha) + .draggable(draggable) + .flat(flat) + .infoWindowAnchor(infoWindowAnchor.x, infoWindowAnchor.y) + .snippet(snippet) + .title(title) + .visible(visible) + } + private fun renderViewToBitmapDescriptor(view: AbstractComposeView): BitmapDescriptor { /* AndroidComposeView triggers LayoutNode's layout phase in the View draw phase, so trigger a draw to an empty canvas to force that */ @@ -381,6 +549,7 @@ internal class ComposeUiClusterRenderer( */ private class InvalidatingComposeView( context: Context, + supportsMarkerComposable: Boolean, private val getRotationOverride: () -> Float, private val getAnchor: () -> Offset, private val getZIndex: () -> Float, @@ -388,18 +557,86 @@ internal class ComposeUiClusterRenderer( ) : AbstractComposeView(context) { val properties = ClusteringMarkerProperties() + private var markerComposable: Pair? = null + private var attachedMarker: Marker? = null + private var attachedMarkerProperties: MarkerComposableProperties? = null + private val markerComposableHost = if (supportsMarkerComposable) { + object : MarkerComposableHost { + override fun registerMarkerComposable( + key: Any, + properties: MarkerComposableProperties, + ) { + val registered = markerComposable + if (registered?.first === key && registered.second === properties) return + check(registered == null || registered.first === key) { + "clusterItemContent can contain at most one MarkerComposable." + } + markerComposable = key to properties + updateAttachedMarkerState() + invalidate() + } + + override fun unregisterMarkerComposable(key: Any) { + if (markerComposable?.first !== key) return + markerComposable = null + updateAttachedMarkerState() + invalidate() + } + } + } else { + null + } + + val markerComposableProperties: MarkerComposableProperties? + get() = markerComposable?.second + var onInvalidate: (() -> Unit)? = null + fun attachMarker(marker: Marker) { + if (attachedMarker !== marker) { + detachMarker() + attachedMarker = marker + } + updateAttachedMarkerState() + } + + fun detachMarker() { + val marker = attachedMarker ?: return + attachedMarkerProperties?.detachState(marker) + attachedMarkerProperties = null + attachedMarker = null + } + + private fun updateAttachedMarkerState() { + val marker = attachedMarker ?: return + val newProperties = markerComposableProperties + if (attachedMarkerProperties === newProperties) { + newProperties?.attachState(marker) + return + } + attachedMarkerProperties?.detachState(marker) + newProperties?.attachState(marker) + attachedMarkerProperties = newProperties + } + @Composable override fun Content() { val rotation = getRotationOverride() val anchor = getAnchor() val zIndex = getZIndex() - LaunchedEffect(properties.anchor, properties.zIndex, properties.rotation, rotation, anchor, zIndex) { + LaunchedEffect( + properties.anchor, + properties.zIndex, + properties.rotation, + rotation, + anchor, + zIndex, + ) { invalidate() } CompositionLocalProvider( - LocalClusteringMarkerProperties provides properties + LocalClusteringMarkerProperties provides properties, + LocalMarkerComposableHost provides markerComposableHost, ) { content() } diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/Clustering.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/Clustering.kt index f65dbdf1..e54f1413 100644 --- a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/Clustering.kt +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/Clustering.kt @@ -357,14 +357,34 @@ internal fun Clustering( clusterItemDecoration: @Composable @GoogleMapComposable (T) -> Unit = {}, renderer: ClusterRenderer? = null, ) { + // Functions rather than values: clusterManager.renderer may be replaced after this + // composition, and the marker callbacks below must reach whichever renderer is current when + // the event arrives. + fun resolveRenderer(): ClusterRenderer = renderer ?: clusterManager.renderer + + fun markerEventHandler(): ClusterRendererMarkerEventHandler? = + resolveRenderer() as? ClusterRendererMarkerEventHandler + ResetMapListeners(clusterManager) InputHandler( onMarkerClick = clusterManager.markerManager::onMarkerClick, onInfoWindowClick = clusterManager.markerManager::onInfoWindowClick, + onInfoWindowClose = { marker -> + markerEventHandler()?.onInfoWindowClose(marker) + }, onInfoWindowLongClick = clusterManager.markerManager::onInfoWindowLongClick, - onMarkerDrag = clusterManager.markerManager::onMarkerDrag, - onMarkerDragEnd = clusterManager.markerManager::onMarkerDragEnd, - onMarkerDragStart = clusterManager.markerManager::onMarkerDragStart, + onMarkerDrag = { marker -> + markerEventHandler()?.onMarkerDrag(marker) + clusterManager.markerManager.onMarkerDrag(marker) + }, + onMarkerDragEnd = { marker -> + markerEventHandler()?.onMarkerDragEnd(marker) + clusterManager.markerManager.onMarkerDragEnd(marker) + }, + onMarkerDragStart = { marker -> + markerEventHandler()?.onMarkerDragStart(marker) + clusterManager.markerManager.onMarkerDragStart(marker) + }, ) val cameraPositionState = currentCameraPositionState LaunchedEffect(cameraPositionState) { @@ -391,7 +411,10 @@ internal fun Clustering( } } - val actualRenderer = renderer ?: clusterManager.renderer + // Unlike the marker callbacks above, this is a one-off read per composition: replacing + // clusterManager.renderer afterwards does not recompose, so decorations stay bound to the + // renderer seen here. + val actualRenderer = resolveRenderer() @Suppress("UNCHECKED_CAST") val unclusteredItems by (actualRenderer as? ClusterRendererItemState)?.unclusteredItems ?: remember { mutableStateOf(emptySet()) } diff --git a/maps-compose/src/main/java/com/google/maps/android/compose/Marker.kt b/maps-compose/src/main/java/com/google/maps/android/compose/Marker.kt index f2333f8c..154e2c3d 100644 --- a/maps-compose/src/main/java/com/google/maps/android/compose/Marker.kt +++ b/maps-compose/src/main/java/com/google/maps/android/compose/Marker.kt @@ -15,11 +15,15 @@ package com.google.maps.android.compose import android.view.View +import androidx.annotation.RestrictTo import androidx.compose.runtime.Composable import androidx.compose.runtime.ComposeNode import androidx.compose.runtime.CompositionContext +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Immutable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.currentComposer import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -29,6 +33,7 @@ import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.StateFactoryMarker +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.UiComposable import androidx.compose.ui.geometry.Offset import com.google.android.gms.maps.model.AdvancedMarkerOptions @@ -39,6 +44,82 @@ import com.google.android.gms.maps.model.Marker import com.google.android.gms.maps.model.PinConfig import com.google.maps.android.ktx.addMarker +/** Hosts [MarkerComposable] content for a marker owned outside the current composition. */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +public interface MarkerComposableHost { + public fun registerMarkerComposable(key: Any, properties: MarkerComposableProperties) + + public fun unregisterMarkerComposable(key: Any) +} + +/** The [MarkerComposableHost] supplied by a component that owns the rendered marker. */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +public val LocalMarkerComposableHost: ProvidableCompositionLocal = + staticCompositionLocalOf { null } + +/** Properties and callbacks contributed by a hosted [MarkerComposable]. */ +@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) +public class MarkerComposableProperties internal constructor( + private val state: MarkerState, + public val contentDescription: String?, + public val alpha: Float, + public val anchor: Offset, + public val draggable: Boolean, + public val flat: Boolean, + public val infoWindowAnchor: Offset, + public val rotation: Float, + public val snippet: String?, + public val tag: Any?, + public val title: String?, + public val visible: Boolean, + public val zIndex: Float, + public val onClick: (Marker) -> Boolean, + public val onInfoWindowClick: (Marker) -> Unit, + public val onInfoWindowClose: (Marker) -> Unit, + public val onInfoWindowLongClick: (Marker) -> Unit, +) { + public fun attachState(marker: Marker) { + if (state.marker === marker) { + state.position = marker.position + return + } + check(state.marker == null) { + "A MarkerState used by MarkerComposable in Clustering.clusterItemContent may only " + + "be associated with one cluster item at a time." + } + state.position = marker.position + state.marker = marker + } + + public fun detachState(marker: Marker) { + if (state.marker === marker) { + state.marker = null + } + } + + @Suppress("DEPRECATION") + public fun onMarkerDragStart(marker: Marker) { + state.isDragging = true + state.position = marker.position + state.dragState = DragState.START + } + + @Suppress("DEPRECATION") + public fun onMarkerDrag(marker: Marker) { + state.isDragging = true + state.position = marker.position + state.dragState = DragState.DRAG + } + + @Suppress("DEPRECATION") + public fun onMarkerDragEnd(marker: Marker) { + state.isDragging = true + state.position = marker.position + state.isDragging = false + state.dragState = DragState.END + } +} + internal class MarkerNode( val compositionContext: CompositionContext, val marker: Marker, @@ -292,6 +373,12 @@ public fun Marker( * * This composable must have a non-zero size in both dimensions * + * In `Clustering.clusterItemContent`, this configures the cluster-owned marker. Its position is + * controlled by the cluster item. + * Its `anchor`, `zIndex`, and `rotation` take precedence over the corresponding `Clustering` + * parameters; values supplied by `ClusteringMarkerProperties` take precedence over both. + * Its [MarkerState] and callbacks are connected only by Clustering's Compose UI renderer. + * * @param keys unique keys representing the state of this Marker. Any changes to one of the key will * trigger a rendering of the content composable and thus the rendering of an updated marker. * @param state the [MarkerState] to be used to control or observe the marker @@ -340,6 +427,63 @@ public fun MarkerComposable( onInfoWindowLongClick: (Marker) -> Unit = {}, content: @UiComposable @Composable () -> Unit, ) { + val markerHost = LocalMarkerComposableHost.current + if (markerHost != null) { + val properties = remember( + *keys, + state, + contentDescription, + alpha, + anchor, + draggable, + flat, + infoWindowAnchor, + rotation, + snippet, + tag, + title, + visible, + zIndex, + onClick, + onInfoWindowClick, + onInfoWindowClose, + onInfoWindowLongClick, + ) { + MarkerComposableProperties( + state = state, + contentDescription = contentDescription, + alpha = alpha, + anchor = anchor, + draggable = draggable, + flat = flat, + infoWindowAnchor = infoWindowAnchor, + rotation = rotation, + snippet = snippet, + tag = tag, + title = title, + visible = visible, + zIndex = zIndex, + onClick = onClick, + onInfoWindowClick = onInfoWindowClick, + onInfoWindowClose = onInfoWindowClose, + onInfoWindowLongClick = onInfoWindowLongClick, + ) + } + @Suppress("COMPOSE_APPLIER_CALL_MISMATCH") + HostedMarkerComposable( + host = markerHost, + properties = properties, + content = content, + ) + return + } + + // Non-hosted markers require the map applier. + check(currentComposer.applier is MapApplier) { + "MarkerComposable must be used inside a GoogleMap content lambda or a supported " + + "marker host such as Clustering.clusterItemContent." + } + val icon = rememberComposeBitmapDescriptor(*keys) { content() } MarkerImpl( @@ -364,6 +508,26 @@ public fun MarkerComposable( ) } +/** Registers marker properties with [host] while emitting [content] without a [MarkerNode]. */ +@Composable +@UiComposable +private fun HostedMarkerComposable( + host: MarkerComposableHost, + properties: MarkerComposableProperties, + content: @UiComposable @Composable () -> Unit, +) { + val registrationKey = remember(host) { Any() } + SideEffect { + host.registerMarkerComposable(registrationKey, properties) + } + DisposableEffect(host, registrationKey) { + onDispose { + host.unregisterMarkerComposable(registrationKey) + } + } + content() +} + /** * A composable for a marker on the map wherein its entire info window can be * customized. If this customization is not required, use @@ -442,6 +606,8 @@ public fun MarkerInfoWindow( * customized. If this customization is not required, use * [com.google.maps.android.compose.Marker]. * + * This composable is not supported in `Clustering.clusterItemContent`. + * * @param keys unique keys representing the state of this Marker. Any changes to one of the key will * trigger a rendering of the content composable and thus the rendering of an updated marker. * @param state the [MarkerState] to be used to control or observe the marker @@ -488,6 +654,11 @@ public fun MarkerInfoWindowComposable( infoContent: (@UiComposable @Composable (Marker) -> Unit)? = null, content: @UiComposable @Composable () -> Unit, ) { + check(currentComposer.applier is MapApplier) { + "MarkerInfoWindowComposable must be used inside a GoogleMap content lambda. It is not " + + "supported in Clustering.clusterItemContent; use MarkerComposable there instead." + } + val icon = rememberComposeBitmapDescriptor(*keys) { content() } MarkerImpl(