diff --git a/app/src/main/java/com/google/maps/android/compose/MarkerClusteringActivity.kt b/app/src/main/java/com/google/maps/android/compose/MarkerClusteringActivity.kt index 328076c5b..28068b79a 100644 --- a/app/src/main/java/com/google/maps/android/compose/MarkerClusteringActivity.kt +++ b/app/src/main/java/com/google/maps/android/compose/MarkerClusteringActivity.kt @@ -5,16 +5,29 @@ import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent 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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -26,6 +39,8 @@ 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.compose.clustering.Clustering +import com.google.maps.android.compose.clustering.rememberClusterManager +import com.google.maps.android.compose.clustering.rememberClusterRenderer import kotlin.random.Random private val TAG = MarkerClusteringActivity::class.simpleName @@ -54,51 +69,37 @@ fun GoogleMapClustering() { GoogleMapClustering(items = items) } -@OptIn(MapsComposeExperimentalApi::class) @Composable fun GoogleMapClustering(items: List) { + var clusteringType by remember { + mutableStateOf(ClusteringType.Default) + } GoogleMap( modifier = Modifier.fillMaxSize(), cameraPositionState = rememberCameraPositionState { position = CameraPosition.fromLatLngZoom(singapore, 6f) } ) { - Clustering( - items = items, - // Optional: Handle clicks on clusters, cluster items, and cluster item info windows - onClusterClick = { - Log.d(TAG, "Cluster clicked! $it") - false - }, - onClusterItemClick = { - Log.d(TAG, "Cluster item clicked! $it") - false - }, - onClusterItemInfoWindowClick = { - Log.d(TAG, "Cluster item info window clicked! $it") - }, - // Optional: Custom rendering for clusters - clusterContent = { cluster -> - Surface( - Modifier.size(40.dp), - shape = CircleShape, - color = Color.Blue, - contentColor = Color.White, - border = BorderStroke(1.dp, Color.White) - ) { - Box(contentAlignment = Alignment.Center) { - Text( - "%,d".format(cluster.size), - fontSize = 16.sp, - fontWeight = FontWeight.Black, - textAlign = TextAlign.Center - ) - } - } - }, - // Optional: Custom rendering for non-clustered items - clusterItemContent = null - ) + when (clusteringType) { + ClusteringType.Default -> { + DefaultClustering( + items = items, + ) + } + + ClusteringType.CustomUi -> { + CustomUiClustering( + items = items, + ) + } + + ClusteringType.CustomRenderer -> { + CustomRendererClustering( + items = items, + ) + } + } + MarkerInfoWindow( state = rememberMarkerState(position = singapore), onClick = { @@ -107,6 +108,181 @@ fun GoogleMapClustering(items: List) { } ) } + + ClusteringTypeControls( + onClusteringTypeClick = { + clusteringType = it + }, + ) +} + +@OptIn(MapsComposeExperimentalApi::class) +@Composable +private fun DefaultClustering(items: List) { + Clustering( + items = items, + // Optional: Handle clicks on clusters, cluster items, and cluster item info windows + onClusterClick = { + Log.d(TAG, "Cluster clicked! $it") + false + }, + onClusterItemClick = { + Log.d(TAG, "Cluster item clicked! $it") + false + }, + onClusterItemInfoWindowClick = { + Log.d(TAG, "Cluster item info window clicked! $it") + }, + // Optional: Custom rendering for non-clustered items + clusterItemContent = null + ) +} + +@OptIn(MapsComposeExperimentalApi::class) +@Composable +private fun CustomUiClustering(items: List) { + Clustering( + items = items, + // Optional: Handle clicks on clusters, cluster items, and cluster item info windows + onClusterClick = { + Log.d(TAG, "Cluster clicked! $it") + false + }, + onClusterItemClick = { + Log.d(TAG, "Cluster item clicked! $it") + false + }, + onClusterItemInfoWindowClick = { + Log.d(TAG, "Cluster item info window clicked! $it") + }, + // Optional: Custom rendering for clusters + clusterContent = { cluster -> + CircleContent( + modifier = Modifier.size(40.dp), + text = "%,d".format(cluster.size), + color = Color.Blue, + ) + }, + // Optional: Custom rendering for non-clustered items + clusterItemContent = null + ) +} + +@OptIn(MapsComposeExperimentalApi::class) +@Composable +fun CustomRendererClustering(items: List) { + val clusterManager = rememberClusterManager() + val renderer = rememberClusterRenderer( + clusterContent = { cluster -> + CircleContent( + modifier = Modifier.size(40.dp), + text = "%,d".format(cluster.size), + color = Color.Green, + ) + }, + clusterItemContent = { + CircleContent( + modifier = Modifier.size(20.dp), + text = "", + color = Color.Green, + ) + }, + clusterManager = clusterManager, + ) + SideEffect { + clusterManager ?: return@SideEffect + clusterManager.setOnClusterClickListener { + Log.d(TAG, "Cluster clicked! $it") + false + } + clusterManager.setOnClusterItemClickListener { + Log.d(TAG, "Cluster item clicked! $it") + false + } + clusterManager.setOnClusterItemInfoWindowClickListener { + Log.d(TAG, "Cluster item info window clicked! $it") + } + } + SideEffect { + if (clusterManager?.renderer != renderer) { + clusterManager?.renderer = renderer ?: return@SideEffect + } + } + + if (clusterManager != null) { + Clustering( + items = items, + clusterManager = clusterManager, + ) + } +} + +@Composable +private fun CircleContent( + color: Color, + text: String, + modifier: Modifier = Modifier, +) { + Surface( + modifier, + shape = CircleShape, + color = color, + contentColor = Color.White, + border = BorderStroke(1.dp, Color.White) + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text, + fontSize = 16.sp, + fontWeight = FontWeight.Black, + textAlign = TextAlign.Center + ) + } + } +} + +@Composable +private fun ClusteringTypeControls( + onClusteringTypeClick: (ClusteringType) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier + .fillMaxWidth() + .horizontalScroll(state = ScrollState(0)), + horizontalArrangement = Arrangement.Start + ) { + ClusteringType.entries.forEach { + MapButton( + text = when (it) { + ClusteringType.Default -> "Default" + ClusteringType.CustomUi -> "Custom UI" + ClusteringType.CustomRenderer -> "Custom Renderer" + }, + onClick = { onClusteringTypeClick(it) } + ) + } + } +} + +@Composable +private fun MapButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + Button( + modifier = modifier.padding(4.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.colors.onPrimary, + contentColor = MaterialTheme.colors.primary + ), + onClick = onClick + ) { + Text(text = text, style = MaterialTheme.typography.body1) + } +} + +private enum class ClusteringType { + Default, + CustomUi, + CustomRenderer, } data class MyItem( 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 cf61e87ec..69f4c8908 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 @@ -3,6 +3,7 @@ package com.google.maps.android.compose.clustering import android.os.Handler import android.os.Looper import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.SideEffect @@ -42,10 +43,38 @@ import kotlinx.coroutines.launch * @param clusterItemContent an optional Composable that is rendered for each non-clustered item. * @param clusterRenderer an optional ClusterRenderer that can be used to specify the algorithm used by the rendering. */ - @Composable @GoogleMapComposable @MapsComposeExperimentalApi +@Deprecated( + message = "If clusterRenderer is specified, clusterContent and clusterItemContent are not used; use a function that takes ClusterManager as an argument instead.", + replaceWith = ReplaceWith( + expression = """ + val clusterManager = rememberClusterManager() + LaunchedEffect(clusterManager, clusterRenderer) { + clusterManager?.renderer = clusterRenderer + } + SideEffect { + clusterManager ?: return@SideEffect + clusterManager.setOnClusterClickListener(onClusterClick) + clusterManager.setOnClusterItemClickListener(onClusterItemClick) + clusterManager.setOnClusterItemInfoWindowClickListener(onClusterItemInfoWindowClick) + clusterManager.setOnClusterItemInfoWindowLongClickListener(onClusterItemInfoWindowLongClick) + } + if (clusterManager != null) { + Clustering( + items = items, + clusterManager = clusterManager, + ) + } + """, + imports = [ + "com.google.maps.android.compose.clustering.Clustering", + "androidx.compose.runtime.SideEffect", + "com.google.maps.android.clustering.ClusterManager", + ], + ), +) public fun Clustering( items: Collection, onClusterClick: (Cluster) -> Boolean = { false }, @@ -54,18 +83,86 @@ public fun Clustering( onClusterItemInfoWindowLongClick: (T) -> Unit = { }, clusterContent: @[UiComposable Composable] ((Cluster) -> Unit)? = null, clusterItemContent: @[UiComposable Composable] ((T) -> Unit)? = null, - clusterRenderer: ClusterRenderer? = null + clusterRenderer: ClusterRenderer? = null, ) { + val clusterManager = rememberClusterManager(clusterContent, clusterItemContent, clusterRenderer) + ?: return - val clusterManager = rememberClusterManager(clusterContent, clusterItemContent, clusterRenderer) ?: return + SideEffect { + clusterManager.setOnClusterClickListener(onClusterClick) + clusterManager.setOnClusterItemClickListener(onClusterItemClick) + clusterManager.setOnClusterItemInfoWindowClickListener(onClusterItemInfoWindowClick) + clusterManager.setOnClusterItemInfoWindowLongClickListener(onClusterItemInfoWindowLongClick) + } + Clustering( + items = items, + clusterManager = clusterManager, + ) +} - ResetMapListeners(clusterManager) +/** + * Groups many items on a map based on zoom level. + * + * @param items all items to show + * @param onClusterClick a lambda invoked when the user clicks a cluster of items + * @param onClusterItemClick a lambda invoked when the user clicks a non-clustered item + * @param onClusterItemInfoWindowClick a lambda invoked when the user clicks the info window of a + * non-clustered item + * @param onClusterItemInfoWindowLongClick a lambda invoked when the user long-clicks the info + * window of a non-clustered item + * @param clusterContent an optional Composable that is rendered for each [Cluster]. + * @param clusterItemContent an optional Composable that is rendered for each non-clustered item. + */ +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun Clustering( + items: Collection, + onClusterClick: (Cluster) -> Boolean = { false }, + onClusterItemClick: (T) -> Boolean = { false }, + onClusterItemInfoWindowClick: (T) -> Unit = { }, + onClusterItemInfoWindowLongClick: (T) -> Unit = { }, + clusterContent: @[UiComposable Composable] ((Cluster) -> Unit)? = null, + clusterItemContent: @[UiComposable Composable] ((T) -> Unit)? = null, +) { + val clusterManager = rememberClusterManager() + val renderer = rememberClusterRenderer(clusterContent, clusterItemContent, clusterManager) SideEffect { + if (clusterManager?.renderer != renderer) { + clusterManager?.renderer = renderer ?: return@SideEffect + } + } + + SideEffect { + clusterManager ?: return@SideEffect clusterManager.setOnClusterClickListener(onClusterClick) clusterManager.setOnClusterItemClickListener(onClusterItemClick) clusterManager.setOnClusterItemInfoWindowClickListener(onClusterItemInfoWindowClick) clusterManager.setOnClusterItemInfoWindowLongClickListener(onClusterItemInfoWindowLongClick) } + + if (clusterManager != null) { + Clustering( + items = items, + clusterManager = clusterManager, + ) + } +} + +/** + * Groups many items on a map based on clusterManager. + * + * @param items all items to show + * @param clusterManager a [ClusterManager] that can be used to specify the algorithm used by the rendering. + */ +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun Clustering( + items: Collection, + clusterManager: ClusterManager, +) { + ResetMapListeners(clusterManager) InputHandler( onMarkerClick = clusterManager.markerManager::onMarkerClick, onInfoWindowClick = clusterManager.markerManager::onInfoWindowClick, @@ -92,6 +189,79 @@ public fun Clustering( clusterManager.cluster() } } + DisposableEffect(itemsState) { + onDispose { + clusterManager.clearItems() + clusterManager.cluster() + } + } +} + + +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun rememberClusterRenderer( + clusterManager: ClusterManager?, +): ClusterRenderer? { + val context = LocalContext.current + val clusterRendererState: MutableState?> = remember { mutableStateOf(null) } + + clusterManager ?: return null + MapEffect(context) { map -> + val renderer = DefaultClusterRenderer(context, map, clusterManager) + clusterRendererState.value = renderer + } + + return clusterRendererState.value +} + +/** + * Default Renderer for drawing Composable. + * + * @param clusterContent an optional Composable that is rendered for each [Cluster]. + * @param clusterItemContent an optional Composable that is rendered for each non-clustered item. + */ +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun rememberClusterRenderer( + clusterContent: @Composable ((Cluster) -> Unit)?, + clusterItemContent: @Composable ((T) -> Unit)?, + clusterManager: ClusterManager?, +): ClusterRenderer? { + val clusterContentState = rememberUpdatedState(clusterContent) + val clusterItemContentState = rememberUpdatedState(clusterItemContent) + val context = LocalContext.current + val viewRendererState = rememberUpdatedState(rememberComposeUiViewRenderer()) + val clusterRendererState: MutableState?> = remember { mutableStateOf(null) } + + clusterManager ?: return null + MapEffect(context) { map -> + val renderer = ComposeUiClusterRenderer( + context, + scope = this, + map, + clusterManager, + viewRendererState, + clusterContentState, + clusterItemContentState, + ) + clusterRendererState.value = renderer + } + return clusterRendererState.value +} + +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun rememberClusterManager(): ClusterManager? { + val context = LocalContext.current + val clusterManagerState: MutableState?> = remember { mutableStateOf(null) } + MapEffect(context) { map -> + clusterManagerState.value = ClusterManager(context, map) + } + return clusterManagerState.value } @OptIn(MapsComposeExperimentalApi::class) @@ -99,7 +269,7 @@ public fun Clustering( private fun rememberClusterManager( clusterContent: @Composable ((Cluster) -> Unit)?, clusterItemContent: @Composable ((T) -> Unit)?, - clusterRenderer: ClusterRenderer? = null + clusterRenderer: ClusterRenderer? = null, ): ClusterManager? { val clusterContentState = rememberUpdatedState(clusterContent) val clusterItemContentState = rememberUpdatedState(clusterItemContent)