diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewClusteringTests.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewClusteringTests.kt index c374b453a..ec8fd2573 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewClusteringTests.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/GoogleMapViewClusteringTests.kt @@ -23,6 +23,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.test.junit4.createComposeRule @@ -164,6 +165,132 @@ class GoogleMapViewClusteringTests { } } + @OptIn(MapsComposeExperimentalApi::class) + @Test + fun testKeyedClusteringAppliesAdditionsRemovalsAndUpdates() { + val clusterManagerHolder = arrayOfNulls>(1) + val firstItem = MyItem(startingPosition, "first", "Snippet", 0f) + val items = mutableStateOf(listOf(firstItem)) + + composeTestRule.setContent { + GoogleMapView( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + ) { + Clustering( + items = items.value, + key = MyItem::title, + onClusterManager = { clusterManagerHolder[0] = it }, + ) + } + } + + val timeoutMillis = TimeUnit.SECONDS.toMillis(MAP_LOAD_TIMEOUT_SECONDS) + composeTestRule.waitUntil(timeoutMillis = timeoutMillis) { + composeTestRule.runOnUiThread { + clusterManagerHolder[0]?.algorithm?.items?.size == 1 + } + } + + val updatedPosition = LatLng(2.34, 5.67) + val addedItem = MyItem(LatLng(3.45, 6.78), "added", "Snippet", 0f) + items.value = listOf(firstItem.copy(position = updatedPosition), addedItem) + composeTestRule.waitUntil(timeoutMillis = timeoutMillis) { + composeTestRule.runOnUiThread { + val currentItems = clusterManagerHolder[0]?.algorithm?.items.orEmpty() + currentItems.size == 2 && + currentItems.any { it.title == "first" && it.position == updatedPosition } && + currentItems.any { it.title == "added" } + } + } + + items.value = listOf(addedItem) + composeTestRule.waitUntil(timeoutMillis = timeoutMillis) { + composeTestRule.runOnUiThread { + val currentItems = clusterManagerHolder[0]?.algorithm?.items.orEmpty() + currentItems.size == 1 && currentItems.single().title == "added" + } + } + } + + @OptIn(MapsComposeExperimentalApi::class) + @Test + fun testKeyedClusteringLeavesUnchangedItemsAlone() { + val clusterManagerHolder = arrayOfNulls>(1) + val initialItem = MyItem(startingPosition, "same", "Snippet", 0f) + val items = mutableStateOf(listOf(initialItem)) + + composeTestRule.setContent { + GoogleMapView( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + ) { + Clustering( + items = items.value, + key = MyItem::title, + onClusterManager = { clusterManagerHolder[0] = it }, + ) + } + } + + val timeoutMillis = TimeUnit.SECONDS.toMillis(MAP_LOAD_TIMEOUT_SECONDS) + composeTestRule.waitUntil(timeoutMillis = timeoutMillis) { + composeTestRule.runOnUiThread { + clusterManagerHolder[0]?.algorithm?.items?.size == 1 + } + } + val marker = composeTestRule.runOnUiThread { + clusterManagerHolder[0]!!.markerCollection.getMarkers().single() + } + + items.value = listOf(initialItem.copy()) + composeTestRule.waitForIdle() + + composeTestRule.runOnUiThread { + assertThat(clusterManagerHolder[0]?.algorithm?.items).containsExactly(initialItem) + assertThat(clusterManagerHolder[0]?.markerCollection?.getMarkers()).contains(marker) + } + } + + @OptIn(MapsComposeExperimentalApi::class) + @Test + fun testUnkeyedClusteringStillUpdatesItemsByEquality() { + val clusterManagerHolder = arrayOfNulls>(1) + val initialItem = MyItem(startingPosition, "first", "Snippet", 0f) + val items = mutableStateOf(listOf(initialItem)) + + composeTestRule.setContent { + GoogleMapView( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + ) { + Clustering( + items = items.value, + onClusterManager = { clusterManagerHolder[0] = it }, + ) + } + } + + val timeoutMillis = TimeUnit.SECONDS.toMillis(MAP_LOAD_TIMEOUT_SECONDS) + composeTestRule.waitUntil(timeoutMillis = timeoutMillis) { + composeTestRule.runOnUiThread { + clusterManagerHolder[0]?.algorithm?.items?.size == 1 + } + } + + val updatedItem = initialItem.copy(position = LatLng(2.34, 5.67)) + val addedItem = MyItem(LatLng(3.45, 6.78), "added", "Snippet", 0f) + items.value = listOf(updatedItem, addedItem) + composeTestRule.waitUntil(timeoutMillis = timeoutMillis) { + composeTestRule.runOnUiThread { + val currentItems = clusterManagerHolder[0]?.algorithm?.items.orEmpty() + currentItems.size == 2 && + currentItems.any { it.title == "first" && it.position == updatedItem.position } && + currentItems.any { it.title == "added" } + } + } + } + @OptIn(MapsComposeExperimentalApi::class) @Test fun testClusterItemContentUsingRememberComposeBitmapDescriptorDoesNotCrash() { 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 98f722cf6..12458905b 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,7 +18,6 @@ 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 @@ -27,6 +26,7 @@ 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.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -43,25 +43,30 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf 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.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 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 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.GoogleMapComposable import com.google.maps.android.compose.MapsComposeExperimentalApi import com.google.maps.android.compose.MarkerInfoWindow import com.google.maps.android.compose.clustering.Clustering @@ -70,10 +75,10 @@ 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 +import kotlinx.coroutines.delay private val TAG = MarkerClusteringActivity::class.simpleName @@ -107,57 +112,120 @@ fun GoogleMapClustering() { } } +@OptIn(MapsComposeExperimentalApi::class) @Composable fun GoogleMapClustering(items: List) { var clusteringType by remember { mutableStateOf(ClusteringType.Default) } + Box(Modifier.fillMaxSize()) { + if (clusteringType == ClusteringType.KeyedDiff) { + KeyedDiffClustering() + } else { + ClusteringMap { + when (clusteringType) { + ClusteringType.Default -> DefaultClustering(items) + ClusteringType.CustomUi -> CustomUiClustering(items) + ClusteringType.CustomRenderer -> CustomRendererClustering(items) + ClusteringType.Decorations -> DecorationsClustering(items) + ClusteringType.KeyedDiff -> Unit + } + } + } + + ClusteringTypeControls( + modifier = Modifier.align(Alignment.TopCenter), + onClusteringTypeClick = { clusteringType = it }, + ) + } +} + +@Composable +private fun ClusteringMap(content: @Composable @GoogleMapComposable () -> Unit) { GoogleMap( modifier = Modifier.fillMaxSize(), cameraPositionState = rememberCameraPositionState { position = CameraPosition.fromLatLngZoom(singapore2, 6f) } ) { - when (clusteringType) { - ClusteringType.Default -> { - DefaultClustering( - items = items, - ) + content() + MarkerInfoWindow( + state = rememberUpdatedMarkerState(position = singapore2), + onClick = { + Log.d(TAG, "Non-cluster marker clicked! $it") + true } + ) + } +} - ClusteringType.CustomUi -> { - CustomUiClustering( - items = items, +@OptIn(MapsComposeExperimentalApi::class) +@Composable +private fun KeyedDiffClustering(modifier: Modifier = Modifier) { + val keyedItems = remember { + mutableStateListOf().apply { + addAll(List(5_000) { index -> + val row = index / 100 + val column = index % 100 + MyItem( + position = LatLng( + singapore2.latitude + (row - 25) * 0.001f, + singapore2.longitude + (column - 50) * 0.001f, + ), + title = "item-$index", + snippet = "Frequently updated keyed cluster item", + zIndex = 0f, ) - } + }) + } + } + var updateCount by remember { mutableIntStateOf(0) } + var lastUpdatedItemIndex by remember { mutableIntStateOf(-1) } + var clusterManager by remember { mutableStateOf?>(null) } - ClusteringType.CustomRenderer -> { - CustomRendererClustering( - items = items, + LaunchedEffect(Unit) { + var nextItemIndex = 0 + while (true) { + delay(500) + repeat(100) { + val index = nextItemIndex++ % keyedItems.size + val item = keyedItems[index] + val direction = if (updateCount % 2 == 0) 1 else -1 + keyedItems[index] = item.copy( + position = LatLng( + item.position.latitude + direction * 0.0002, + item.position.longitude + direction * 0.0002, + ) ) } + lastUpdatedItemIndex = (nextItemIndex - 1) % keyedItems.size + updateCount++ + } + } - ClusteringType.Decorations -> { - DecorationsClustering( - items = items, + Box(modifier.fillMaxSize()) { + ClusteringMap { + Clustering( + items = keyedItems, + key = { item -> item.title }, + onClusterManager = { clusterManager = it }, + ) + } + val managerItems = clusterManager?.algorithm?.items.orEmpty() + val lastUpdatedItem = managerItems.firstOrNull { it.title == "item-$lastUpdatedItemIndex" } + Surface( + modifier = Modifier.align(Alignment.TopCenter).padding(top = 72.dp), + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), + ) { + Column(Modifier.padding(8.dp)) { + Text("Keyed diff demo: 5,000 items; 100 updates / 500 ms") + Text( + "Update $updateCount · ${managerItems.size} items · " + + "item $lastUpdatedItemIndex at ${lastUpdatedItem?.position}" ) } } - - MarkerInfoWindow( - state = rememberUpdatedMarkerState(position = singapore2), - onClick = { - Log.d(TAG, "Non-cluster marker clicked! $it") - true - } - ) } - - ClusteringTypeControls( - onClusteringTypeClick = { - clusteringType = it - }, - ) } @OptIn(MapsComposeExperimentalApi::class) @@ -354,6 +422,7 @@ private fun ClusteringTypeControls( ClusteringType.CustomUi -> "Custom UI" ClusteringType.CustomRenderer -> "Custom Renderer" ClusteringType.Decorations -> "Decorations" + ClusteringType.KeyedDiff -> "Keyed diff (5k)" }, onClick = { onClusteringTypeClick(it) } ) @@ -380,6 +449,7 @@ private enum class ClusteringType { CustomUi, CustomRenderer, Decorations, + KeyedDiff, } data class MyItem( diff --git a/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterItemsDiff.kt b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterItemsDiff.kt new file mode 100644 index 000000000..97a7d0c11 --- /dev/null +++ b/maps-compose-utils/src/main/java/com/google/maps/android/compose/clustering/ClusterItemsDiff.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.compose.clustering + +internal data class ClusterItemsDiff( + val added: List, + val removed: List, + val updated: List>, +) { + val hasChanges: Boolean + get() = added.isNotEmpty() || removed.isNotEmpty() || updated.isNotEmpty() +} + +internal fun clusterItemsByKey( + items: Collection, + key: ((T) -> Any)?, + onDuplicateKey: (Any) -> Unit = {}, +): Map { + val keyedItems = linkedMapOf() + items.forEach { item -> + val itemKey = key?.invoke(item) ?: item + if (key != null && itemKey in keyedItems) { + onDuplicateKey(itemKey) + } + keyedItems[itemKey] = item + } + return keyedItems +} + +internal fun diffClusterItems( + previousItems: Map, + currentItems: Map, +): ClusterItemsDiff { + val added = mutableListOf() + val removed = mutableListOf() + val updated = mutableListOf>() + + previousItems.forEach { (key, previousItem) -> + val currentItem = currentItems[key] + if (currentItem == null) { + removed += previousItem + } else if (previousItem != currentItem) { + updated += previousItem to currentItem + } + } + currentItems.forEach { (key, currentItem) -> + if (key !in previousItems) { + added += currentItem + } + } + + return ClusterItemsDiff(added, removed, updated) +} 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 f65dbdf1a..568fda5f7 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 @@ -19,6 +19,7 @@ package com.google.maps.android.compose.clustering import android.content.Context import android.os.Handler import android.os.Looper +import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -112,6 +113,10 @@ public fun ClusteringMarkerProperties( * @param clusterContentRotation the rotation of the cluster in degrees clockwise about the marker's anchor point * @param clusterItemContentRotation the rotation of the non-clustered item in degrees clockwise about the marker's anchor point * @param clusterRenderer an optional ClusterRenderer that can be used to specify the algorithm used by the rendering. + * @param key an optional stable, unique key for each item. When supplied, items with the same key + * are treated as updates when their values differ. Duplicate keys in a collection produce a warning + * and only the last item for each key is kept. Without a key, items are identified by their + * `equals` and `hashCode` implementations. */ @Composable @GoogleMapComposable @@ -161,6 +166,7 @@ public fun Clustering( clusterContentRotation: Float = 0.0f, clusterItemContentRotation: Float = 0.0f, clusterRenderer: ClusterRenderer? = null, + key: ((T) -> Any)? = null, clusterItemDecoration: @Composable @GoogleMapComposable (T) -> Unit = {}, ) { val clusterManager = rememberClusterManager( @@ -186,6 +192,7 @@ public fun Clustering( clusterManager = clusterManager, clusterItemDecoration = clusterItemDecoration, renderer = clusterManager.renderer, + key = key, ) } @@ -207,6 +214,10 @@ public fun Clustering( * @param clusterItemContentZIndex the z-index of the non-clustered item * @param clusterContentRotation the rotation of the cluster in degrees clockwise about the marker's anchor point * @param clusterItemContentRotation the rotation of the non-clustered item in degrees clockwise about the marker's anchor point + * @param key an optional stable, unique key for each item. When supplied, items with the same key + * are treated as updates when their values differ. Without a key, items are identified by their + * `equals` and `hashCode` implementations. Duplicate keys in a collection produce a warning + * and only the last item for each key is kept. */ @Composable @GoogleMapComposable @@ -225,6 +236,7 @@ public fun Clustering( clusterItemContentZIndex: Float = 0.0f, clusterContentRotation: Float = 0.0f, clusterItemContentRotation: Float = 0.0f, + key: ((T) -> Any)? = null, clusterItemDecoration: @Composable @GoogleMapComposable (T) -> Unit = {}, ) { Clustering( @@ -243,6 +255,7 @@ public fun Clustering( clusterItemContentRotation = clusterItemContentRotation, clusterItemDecoration = clusterItemDecoration, onClusterManager = null, + key = key, ) } @@ -264,6 +277,10 @@ public fun Clustering( * @param clusterItemContentZIndex the z-index of the non-clustered item * @param clusterContentRotation the rotation of the cluster in degrees clockwise about the marker's anchor point * @param clusterItemContentRotation the rotation of the non-clustered item in degrees clockwise about the marker's anchor point + * @param key an optional stable, unique key for each item. When supplied, items with the same key + * are treated as updates when their values differ. Without a key, items are identified by their + * `equals` and `hashCode` implementations. Duplicate keys in a collection produce a warning + * and only the last item for each key is kept. * @param onClusterManager an optional lambda invoked with the clusterManager as a param when both * the clusterManager and renderer are set up, allowing callers a customization hook. */ @@ -284,6 +301,7 @@ public fun Clustering( clusterItemContentZIndex: Float = 0.0f, clusterContentRotation: Float = 0.0f, clusterItemContentRotation: Float = 0.0f, + key: ((T) -> Any)? = null, clusterItemDecoration: @Composable @GoogleMapComposable (T) -> Unit = {}, onClusterManager: ((ClusterManager) -> Unit)? = null, ) { @@ -322,6 +340,7 @@ public fun Clustering( clusterManager = clusterManager, clusterItemDecoration = clusterItemDecoration, renderer = renderer, + key = key, ) } } @@ -331,6 +350,10 @@ public fun Clustering( * * @param items all items to show * @param clusterManager a [ClusterManager] that can be used to specify the algorithm used by the rendering. + * @param key an optional stable, unique key for each item. When supplied, items with the same key + * are treated as updates when their values differ. Without a key, items are identified by their + * `equals` and `hashCode` implementations. Duplicate keys in a collection produce a warning + * and only the last item for each key is kept. */ @Composable @GoogleMapComposable @@ -338,16 +361,153 @@ public fun Clustering( public fun Clustering( items: Collection, clusterManager: ClusterManager, + key: ((T) -> Any)? = null, clusterItemDecoration: @Composable @GoogleMapComposable (T) -> Unit = {}, ) { Clustering( items = items, clusterManager = clusterManager, clusterItemDecoration = clusterItemDecoration, - renderer = null + renderer = null, + key = key, ) } +/** Retains the pre-key JVM signature for already compiled callers. */ +@Deprecated("Retained for binary compatibility", level = DeprecationLevel.HIDDEN) +@Suppress("DEPRECATION") +@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, + clusterContentAnchor: Offset = Offset(0.5f, 1.0f), + clusterItemContentAnchor: Offset = Offset(0.5f, 1.0f), + clusterContentZIndex: Float = 0.0f, + clusterItemContentZIndex: Float = 0.0f, + clusterContentRotation: Float = 0.0f, + clusterItemContentRotation: Float = 0.0f, + clusterRenderer: ClusterRenderer? = null, + clusterItemDecoration: @Composable @GoogleMapComposable (T) -> Unit = {}, +): Unit = Clustering( + items = items, + onClusterClick = onClusterClick, + onClusterItemClick = onClusterItemClick, + onClusterItemInfoWindowClick = onClusterItemInfoWindowClick, + onClusterItemInfoWindowLongClick = onClusterItemInfoWindowLongClick, + clusterContent = clusterContent, + clusterItemContent = clusterItemContent, + clusterContentAnchor = clusterContentAnchor, + clusterItemContentAnchor = clusterItemContentAnchor, + clusterContentZIndex = clusterContentZIndex, + clusterItemContentZIndex = clusterItemContentZIndex, + clusterContentRotation = clusterContentRotation, + clusterItemContentRotation = clusterItemContentRotation, + clusterRenderer = clusterRenderer, + key = null, + clusterItemDecoration = clusterItemDecoration, +) + +/** Retains the pre-key JVM signature for already compiled callers. */ +@Deprecated("Retained for binary compatibility", level = DeprecationLevel.HIDDEN) +@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, + clusterContentAnchor: Offset = Offset(0.5f, 1.0f), + clusterItemContentAnchor: Offset = Offset(0.5f, 1.0f), + clusterContentZIndex: Float = 0.0f, + clusterItemContentZIndex: Float = 0.0f, + clusterContentRotation: Float = 0.0f, + clusterItemContentRotation: Float = 0.0f, + clusterItemDecoration: @Composable @GoogleMapComposable (T) -> Unit = {}, +): Unit = Clustering( + items = items, + onClusterClick = onClusterClick, + onClusterItemClick = onClusterItemClick, + onClusterItemInfoWindowClick = onClusterItemInfoWindowClick, + onClusterItemInfoWindowLongClick = onClusterItemInfoWindowLongClick, + clusterContent = clusterContent, + clusterItemContent = clusterItemContent, + clusterContentAnchor = clusterContentAnchor, + clusterItemContentAnchor = clusterItemContentAnchor, + clusterContentZIndex = clusterContentZIndex, + clusterItemContentZIndex = clusterItemContentZIndex, + clusterContentRotation = clusterContentRotation, + clusterItemContentRotation = clusterItemContentRotation, + key = null, + clusterItemDecoration = clusterItemDecoration, +) + +/** Retains the pre-key JVM signature for already compiled callers. */ +@Deprecated("Retained for binary compatibility", level = DeprecationLevel.HIDDEN) +@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, + clusterContentAnchor: Offset = Offset(0.5f, 1.0f), + clusterItemContentAnchor: Offset = Offset(0.5f, 1.0f), + clusterContentZIndex: Float = 0.0f, + clusterItemContentZIndex: Float = 0.0f, + clusterContentRotation: Float = 0.0f, + clusterItemContentRotation: Float = 0.0f, + clusterItemDecoration: @Composable @GoogleMapComposable (T) -> Unit = {}, + onClusterManager: ((ClusterManager) -> Unit)? = null, +): Unit = Clustering( + items = items, + onClusterClick = onClusterClick, + onClusterItemClick = onClusterItemClick, + onClusterItemInfoWindowClick = onClusterItemInfoWindowClick, + onClusterItemInfoWindowLongClick = onClusterItemInfoWindowLongClick, + clusterContent = clusterContent, + clusterItemContent = clusterItemContent, + clusterContentAnchor = clusterContentAnchor, + clusterItemContentAnchor = clusterItemContentAnchor, + clusterContentZIndex = clusterContentZIndex, + clusterItemContentZIndex = clusterItemContentZIndex, + clusterContentRotation = clusterContentRotation, + clusterItemContentRotation = clusterItemContentRotation, + key = null, + clusterItemDecoration = clusterItemDecoration, + onClusterManager = onClusterManager, +) + +/** Retains the pre-key JVM signature for already compiled callers. */ +@Deprecated("Retained for binary compatibility", level = DeprecationLevel.HIDDEN) +@Composable +@GoogleMapComposable +@MapsComposeExperimentalApi +public fun Clustering( + items: Collection, + clusterManager: ClusterManager, + clusterItemDecoration: @Composable @GoogleMapComposable (T) -> Unit = {}, +): Unit = Clustering( + items = items, + clusterManager = clusterManager, + key = null, + clusterItemDecoration = clusterItemDecoration, +) + @Composable @GoogleMapComposable @MapsComposeExperimentalApi @@ -356,6 +516,7 @@ internal fun Clustering( clusterManager: ClusterManager, clusterItemDecoration: @Composable @GoogleMapComposable (T) -> Unit = {}, renderer: ClusterRenderer? = null, + key: ((T) -> Any)? = null, ) { ResetMapListeners(clusterManager) InputHandler( @@ -376,12 +537,39 @@ internal fun Clustering( } } val itemsState = rememberUpdatedState(items) - LaunchedEffect(itemsState) { - snapshotFlow { itemsState.value.toList() } - .collect { items -> - clusterManager.clearItems() - clusterManager.addItems(items) - clusterManager.cluster() + val keyState = rememberUpdatedState(key) + LaunchedEffect(clusterManager, itemsState, keyState) { + var previousKeyedItems: Map? = null + var previousUsedExplicitKey: Boolean? = null + snapshotFlow { itemsState.value.toList() to keyState.value } + .collect { (items, itemKey) -> + val currentKeyedItems = clusterItemsByKey(items, itemKey) { duplicateKey -> + Log.w("Clustering", "Duplicate item key: $duplicateKey. Keeping the last item.") + } + val previousItems = previousKeyedItems + val usesExplicitKey = itemKey != null + + if (previousItems == null || previousUsedExplicitKey != usesExplicitKey) { + // Reset on the first update or when switching identity strategies. + clusterManager.clearItems() + clusterManager.addItems(currentKeyedItems.values) + clusterManager.cluster() + } else { + val diff = diffClusterItems(previousItems, currentKeyedItems) + val removedItems = diff.removed + diff.updated.map { (oldItem, _) -> oldItem } + val addedItems = diff.added + diff.updated.map { (_, newItem) -> newItem } + if (removedItems.isNotEmpty()) { + clusterManager.removeItems(removedItems) + } + if (addedItems.isNotEmpty()) { + clusterManager.addItems(addedItems) + } + if (diff.hasChanges) { + clusterManager.cluster() + } + } + previousKeyedItems = currentKeyedItems + previousUsedExplicitKey = usesExplicitKey } } DisposableEffect(itemsState) { diff --git a/maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusterItemsDiffTest.kt b/maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusterItemsDiffTest.kt new file mode 100644 index 000000000..02c93bf39 --- /dev/null +++ b/maps-compose-utils/src/test/java/com/google/maps/android/compose/clustering/ClusterItemsDiffTest.kt @@ -0,0 +1,188 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.compose.clustering + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +public class ClusterItemsDiffTest { + @Test + public fun reportsAddedAndRemovedItems() { + val diff = diffClusterItems( + previousItems = mapOf("kept" to Item("kept", 1), "removed" to Item("removed", 2)), + currentItems = mapOf("kept" to Item("kept", 1), "added" to Item("added", 3)), + ) + + assertEquals(listOf(Item("added", 3)), diff.added) + assertEquals(listOf(Item("removed", 2)), diff.removed) + assertEquals(emptyList>(), diff.updated) + assertTrue(diff.hasChanges) + } + + @Test + public fun reportsUpdatedItemsByStableKey() { + val old = Item("same-key", 1) + val current = Item("same-key", 2) + val diff = diffClusterItems( + previousItems = mapOf("stable-key" to old), + currentItems = mapOf("stable-key" to current), + ) + + assertEquals(listOf(old to current), diff.updated) + assertTrue(diff.added.isEmpty()) + assertTrue(diff.removed.isEmpty()) + assertTrue(diff.hasChanges) + } + + @Test + public fun reportsUnchangedItemsAndNoChanges() { + val item = Item("same", 1) + val diff = diffClusterItems( + previousItems = mapOf("stable-key" to item), + currentItems = mapOf("stable-key" to item.copy()), + ) + + assertTrue(diff.added.isEmpty()) + assertTrue(diff.removed.isEmpty()) + assertTrue(diff.updated.isEmpty()) + assertFalse(diff.hasChanges) + } + + @Test + public fun noKeyUsesItemEqualityAndHashCodeAsIdentity() { + val unchanged = Item("same", 1) + val changed = Item("same", 2) + val current = clusterItemsByKey(listOf(unchanged.copy(), changed), key = null) + + assertEquals(mapOf(unchanged to unchanged), clusterItemsByKey(listOf(unchanged), key = null)) + assertEquals(setOf(unchanged, changed), current.keys) + assertEquals(2, current.size) + } + + @Test + public fun emptyPreviousCollectionAddsAllItems() { + val item = Item("first", 1) + val diff = diffClusterItems(emptyMap(), mapOf("first" to item)) + + assertEquals(listOf(item), diff.added) + assertTrue(diff.removed.isEmpty()) + assertTrue(diff.updated.isEmpty()) + } + + @Test + public fun emptyCollectionsHaveNoChanges() { + assertTrue(clusterItemsByKey(emptyList(), key = null).isEmpty()) + assertTrue(clusterItemsByKey(emptyList(), key = { it.id }).isEmpty()) + val diff = diffClusterItems(emptyMap(), emptyMap()) + + assertTrue(diff.added.isEmpty()) + assertTrue(diff.removed.isEmpty()) + assertTrue(diff.updated.isEmpty()) + assertFalse(diff.hasChanges) + } + + @Test + public fun emptyCurrentCollectionRemovesAllItems() { + val item = Item("first", 1) + val diff = diffClusterItems(mapOf("first" to item), emptyMap()) + + assertEquals(listOf(item), diff.removed) + assertTrue(diff.added.isEmpty()) + assertTrue(diff.updated.isEmpty()) + assertTrue(diff.hasChanges) + } + + @Test + public fun duplicateKeysKeepLastItemAndReportEachDuplicate() { + val duplicates = mutableListOf() + val first = Item("same", 1) + val last = Item("same", 3) + val other = Item("other", 4) + val keyedItems = clusterItemsByKey( + listOf(first, other, first.copy(value = 2), last), + key = { it.id }, + onDuplicateKey = { duplicates.add(it) }, + ) + + assertEquals(mapOf("same" to last, "other" to other), keyedItems) + assertEquals(listOf("same", "same"), duplicates) + } + + @Test + public fun uniqueExplicitKeysDoNotReportDuplicates() { + val duplicates = mutableListOf() + val item = Item("first", 1) + val keyedItems = clusterItemsByKey( + listOf(item), + key = { it.id }, + onDuplicateKey = { duplicates.add(it) }, + ) + + assertEquals(mapOf("first" to item), keyedItems) + assertTrue(duplicates.isEmpty()) + } + + @Test + public fun unkeyedEqualItemsAreDeduplicatedWithoutWarning() { + val duplicates = mutableListOf() + val item = Item("same", 1) + val keyedItems = clusterItemsByKey( + listOf(item, item.copy()), + key = null, + onDuplicateKey = { duplicates.add(it) }, + ) + + assertEquals(mapOf(item to item), keyedItems) + assertTrue(duplicates.isEmpty()) + } + + @Test + public fun unkeyedChangedItemsAreRemovedAndAddedRatherThanUpdated() { + val old = Item("same", 1) + val current = old.copy(value = 2) + val diff = diffClusterItems( + clusterItemsByKey(listOf(old), key = null), + clusterItemsByKey(listOf(current), key = null), + ) + + assertEquals(listOf(old), diff.removed) + assertEquals(listOf(current), diff.added) + assertTrue(diff.updated.isEmpty()) + } + + @Test + public fun reportsAddRemoveAndUpdateInSamePass() { + val removed = Item("removed", 1) + val old = Item("updated", 2) + val current = old.copy(value = 3) + val added = Item("added", 4) + val kept = Item("kept", 5) + val diff = diffClusterItems( + clusterItemsByKey(listOf(removed, old, kept), key = { it.id }), + clusterItemsByKey(listOf(current, added, kept.copy()), key = { it.id }), + ) + + assertEquals(listOf(added), diff.added) + assertEquals(listOf(removed), diff.removed) + assertEquals(listOf(old to current), diff.updated) + assertTrue(diff.hasChanges) + } + + private data class Item(val id: String, val value: Int) +}