From f89bead9f23bb104dd97aa600d44a7c0ac8a6cf8 Mon Sep 17 00:00:00 2001 From: Dale Hawkins <107309+dkhawk@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:06:52 -0600 Subject: [PATCH] feat(clustering): add SuperCluster algorithm demo and configurable popup menu controls - Integrate SuperClusterAlgorithm from android-maps-utils-clustering:5.2.0 into Maps Compose demo app. - Add SuperClusterNative (high-performance native markers with compact badge formatting) and SuperClusterCompose modes. - Replace scrolling chip bars with a floating Material 3 ClusteringControlBar featuring popup menus for algorithm and dataset selection. - Add 50,000 (50K) and 100,000 (100K) dataset buckets with realistic regional point spread. - Add directly visible Anim: ON / Anim: OFF toggle to compare 300ms marker transition animations against instant 60fps clustering. - Enable largeHeap in maps-app/src/main/AndroidManifest.xml for mega-scale marker headroom. --- gradle.properties | 1 + maps-app/src/main/AndroidManifest.xml | 1 + .../MarkerClusteringActivity.kt | 418 ++++++++++++++---- maps-compose-utils/build.gradle.kts | 1 + 4 files changed, 345 insertions(+), 76 deletions(-) diff --git a/gradle.properties b/gradle.properties index b927edd17..43f37c81d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -42,4 +42,5 @@ android.uniquePackageNames=false android.dependency.useConstraints=true android.r8.strictFullModeForKeepRules=false android.r8.optimizedResourceShrinking=false +useMavenLocal=true diff --git a/maps-app/src/main/AndroidManifest.xml b/maps-app/src/main/AndroidManifest.xml index d95e8c0ae..99b19f580 100644 --- a/maps-app/src/main/AndroidManifest.xml +++ b/maps-app/src/main/AndroidManifest.xml @@ -20,6 +20,7 @@ () } - LaunchedEffect(Unit) { - for (i in 1..10) { + var itemCount by remember { mutableStateOf(50000) } + // Generate items stably with remember(itemCount) to prevent state list thrashing + val items = remember(itemCount) { + val newItems = ArrayList(itemCount) + val rnd = Random(42) + val spread = if (itemCount >= 50000) 4.0 else if (itemCount >= 10000) 2.5 else 1.5 + for (i in 1..itemCount) { val position = LatLng( - singapore2.latitude + Random.nextFloat(), - singapore2.longitude + Random.nextFloat(), + singapore2.latitude + (rnd.nextDouble() - 0.5) * spread, + singapore2.longitude + (rnd.nextDouble() - 0.5) * spread, ) - items.add(MyItem(position, "Marker", "Snippet", 0f)) + newItems.add(MyItem(position, "Marker $i", "Snippet $i", 0f)) } + newItems } + Box( modifier = Modifier.fillMaxSize() .systemBarsPadding() ) { - GoogleMapClustering(items = items) + GoogleMapClustering( + items = items, + itemCount = itemCount, + onItemCountChange = { itemCount = it }, + ) } } @Composable -fun GoogleMapClustering(items: List) { +fun GoogleMapClustering( + items: List, + itemCount: Int, + onItemCountChange: (Int) -> Unit, +) { var clusteringType by remember { - mutableStateOf(ClusteringType.Default) + mutableStateOf(ClusteringType.SuperClusterNative) } - GoogleMap( - modifier = Modifier.fillMaxSize(), - cameraPositionState = rememberCameraPositionState { - position = CameraPosition.fromLatLngZoom(singapore2, 6f) - } - ) { - when (clusteringType) { - ClusteringType.Default -> { - DefaultClustering( - items = items, - ) - } + var enableAnimation by remember { mutableStateOf(false) } - ClusteringType.CustomUi -> { - CustomUiClustering( - items = items, - ) + Box(modifier = Modifier.fillMaxSize()) { + GoogleMap( + modifier = Modifier.fillMaxSize(), + cameraPositionState = rememberCameraPositionState { + position = CameraPosition.fromLatLngZoom(singapore2, 7f) } + ) { + when (clusteringType) { + ClusteringType.Default -> { + DefaultClustering( + items = items, + ) + } - ClusteringType.CustomRenderer -> { - CustomRendererClustering( - items = items, - ) - } + ClusteringType.CustomUi -> { + CustomUiClustering( + items = items, + ) + } - ClusteringType.Decorations -> { - DecorationsClustering( - items = items, - ) + ClusteringType.CustomRenderer -> { + CustomRendererClustering( + items = items, + ) + } + + ClusteringType.SuperClusterNative -> { + SuperClusterNativeClustering( + items = items, + enableAnimation = enableAnimation, + ) + } + + ClusteringType.SuperClusterCompose -> { + SuperClusterComposeClustering( + items = items, + enableAnimation = enableAnimation, + ) + } + + ClusteringType.Decorations -> { + DecorationsClustering( + items = items, + ) + } } + + MarkerInfoWindow( + state = rememberUpdatedMarkerState(position = singapore2), + onClick = { + Log.d(TAG, "Non-cluster marker clicked! $it") + true + } + ) } - MarkerInfoWindow( - state = rememberUpdatedMarkerState(position = singapore2), - onClick = { - Log.d(TAG, "Non-cluster marker clicked! $it") - true - } + ClusteringControlBar( + selectedType = clusteringType, + onClusteringTypeClick = { clusteringType = it }, + selectedCount = itemCount, + onCountSelected = onItemCountChange, + enableAnimation = enableAnimation, + onAnimationToggle = { enableAnimation = it }, + modifier = Modifier.align(Alignment.TopCenter), ) } - - ClusteringTypeControls( - onClusteringTypeClick = { - clusteringType = it - }, - ) } @OptIn(MapsComposeExperimentalApi::class) @@ -336,42 +387,255 @@ private fun CircleContent( } } +@OptIn(MapsComposeExperimentalApi::class) @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" - ClusteringType.Decorations -> "Decorations" - }, - onClick = { onClusteringTypeClick(it) } +fun SuperClusterNativeClustering(items: List, enableAnimation: Boolean) { + val configuration = LocalConfiguration.current + val screenHeight = configuration.screenHeightDp.dp + val screenWidth = configuration.screenWidthDp.dp + val context = LocalContext.current + val clusterManager = rememberClusterManager() + var renderer by remember { mutableStateOf?>(null) } + + LaunchedEffect(clusterManager, screenWidth, screenHeight) { + clusterManager?.setAlgorithm( + SuperClusterAlgorithm( + radius = 120.0, + extent = 512.0, + viewWidth = screenWidth.value.toInt(), + viewHeight = screenHeight.value.toInt(), + ) + ) + } + + MapEffect(clusterManager, enableAnimation) { map -> + clusterManager ?: return@MapEffect + val r = DefaultClusterRenderer(context, map, clusterManager).apply { + setAnimation(enableAnimation) + useCompactNumberFormatting = true + maxNonZeroDigits = 1 + minClusterSize = 2 + } + clusterManager.renderer = r + renderer = r + } + + if (clusterManager != null && renderer != null) { + Clustering( + items = items, + clusterManager = clusterManager, + ) + } +} + +@OptIn(MapsComposeExperimentalApi::class) +@Composable +fun SuperClusterComposeClustering(items: List, enableAnimation: Boolean) { + val configuration = LocalConfiguration.current + val screenHeight = configuration.screenHeightDp.dp + val screenWidth = configuration.screenWidthDp.dp + val clusterManager = rememberClusterManager() + + LaunchedEffect(clusterManager, screenWidth, screenHeight) { + clusterManager?.setAlgorithm( + SuperClusterAlgorithm( + radius = 120.0, + extent = 512.0, + viewWidth = screenWidth.value.toInt(), + viewHeight = screenHeight.value.toInt(), + ) + ) + } + + // Pass clusterItemContent = null so unclustered pins use lightweight native markers instead of creating thousands of ComposeViews! + val renderer = rememberClusterRenderer( + clusterContent = { cluster -> + CircleContent( + modifier = Modifier.size(46.dp), + text = "%,d".format(Locale.current.platformLocale, cluster.size), + color = Color(0xFF6200EE), ) + }, + clusterItemContent = null, + clusterManager = clusterManager, + ) + + SideEffect { + clusterManager ?: return@SideEffect + (clusterManager.renderer as? DefaultClusterRenderer)?.setAnimation(enableAnimation) + clusterManager.setOnClusterClickListener { + Log.d(TAG, "SuperCluster clicked! size=${it.size} items=${it.items.size}") + false } } + SideEffect { + if (clusterManager?.renderer != renderer) { + clusterManager?.renderer = renderer ?: return@SideEffect + } + } + + if (clusterManager != null) { + Clustering( + items = items, + clusterManager = clusterManager, + ) + } } @Composable -private fun MapButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) { - Button( - modifier = modifier.padding(4.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.onPrimary, - contentColor = MaterialTheme.colorScheme.primary +private fun ClusteringControlBar( + selectedType: ClusteringType, + onClusteringTypeClick: (ClusteringType) -> Unit, + selectedCount: Int, + onCountSelected: (Int) -> Unit, + enableAnimation: Boolean, + onAnimationToggle: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + var algoMenuExpanded by remember { mutableStateOf(false) } + var countMenuExpanded by remember { mutableStateOf(false) } + + Card( + modifier = modifier + .padding(top = 10.dp, start = 8.dp, end = 8.dp), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f), ), - onClick = onClick + elevation = CardDefaults.cardElevation(defaultElevation = 6.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) ) { - Text(text = text, style = MaterialTheme.typography.bodyLarge) + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + // 1. Algorithm Dropdown Popup Menu + Box { + FilledTonalButton( + onClick = { algoMenuExpanded = true }, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), + shape = RoundedCornerShape(14.dp), + ) { + Text( + text = when (selectedType) { + ClusteringType.SuperClusterNative -> "SuperCluster ⚡" + ClusteringType.SuperClusterCompose -> "Compose Badges" + ClusteringType.CustomRenderer -> "View-Based" + ClusteringType.CustomUi -> "Custom UI" + ClusteringType.Default -> "Default" + ClusteringType.Decorations -> "Decorations" + }, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.width(2.dp)) + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = "Select Algorithm", + modifier = Modifier.size(18.dp) + ) + } + + DropdownMenu( + expanded = algoMenuExpanded, + onDismissRequest = { algoMenuExpanded = false } + ) { + ClusteringType.entries.forEach { type -> + val isSelected = type == selectedType + DropdownMenuItem( + text = { + Text( + text = when (type) { + ClusteringType.SuperClusterNative -> "SuperCluster ⚡ (Native Fast)" + ClusteringType.SuperClusterCompose -> "SuperCluster ⚡ (Compose Badges)" + ClusteringType.CustomRenderer -> "Custom Renderer (View-Based)" + ClusteringType.CustomUi -> "Custom UI" + ClusteringType.Default -> "Default (Distance-Based)" + ClusteringType.Decorations -> "Decorations" + }, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + ) + }, + trailingIcon = if (isSelected) { + { Icon(Icons.Default.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary) } + } else null, + onClick = { + onClusteringTypeClick(type) + algoMenuExpanded = false + } + ) + } + } + } + + // 2. Dataset Size Dropdown Popup Menu (including 50K and 100K) + Box { + FilledTonalButton( + onClick = { countMenuExpanded = true }, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), + shape = RoundedCornerShape(14.dp), + ) { + Text( + text = when { + selectedCount >= 1000 -> "${selectedCount / 1000}K items" + else -> "$selectedCount items" + }, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.width(2.dp)) + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = "Select Dataset Size", + modifier = Modifier.size(18.dp) + ) + } + + DropdownMenu( + expanded = countMenuExpanded, + onDismissRequest = { countMenuExpanded = false } + ) { + listOf(10, 500, 2500, 10000, 25000, 50000, 100000).forEach { count -> + val isSelected = count == selectedCount + DropdownMenuItem( + text = { + Text( + text = when { + count >= 1000 -> "%,d items (${count / 1000}K)".format(count) + else -> "$count items" + }, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + ) + }, + trailingIcon = if (isSelected) { + { Icon(Icons.Default.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary) } + } else null, + onClick = { + onCountSelected(count) + countMenuExpanded = false + } + ) + } + } + } + + // 3. Immediately Visible Animations Toggle + FilterChip( + selected = enableAnimation, + onClick = { onAnimationToggle(!enableAnimation) }, + label = { + Text( + text = if (enableAnimation) "Anim: ON" else "Anim: OFF", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + ) + }, + shape = RoundedCornerShape(14.dp), + ) + } } } @@ -379,6 +643,8 @@ private enum class ClusteringType { Default, CustomUi, CustomRenderer, + SuperClusterNative, + SuperClusterCompose, Decorations, } diff --git a/maps-compose-utils/build.gradle.kts b/maps-compose-utils/build.gradle.kts index 4999c9bd4..fcda8afc8 100644 --- a/maps-compose-utils/build.gradle.kts +++ b/maps-compose-utils/build.gradle.kts @@ -84,6 +84,7 @@ dependencies { implementation(libs.kotlin) implementation(libs.kotlinx.coroutines.android) api(libs.maps.utils) + api("com.google.maps.android:android-maps-utils-clustering:5.2.0") testImplementation(libs.test.junit) }