refactor(map): adopt maps-compose 8.4.0 stock clustering, drop custom renderer workaround (#6301)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James RichandClaude Opus 4.8 authored and GitHub committed 2026-07-16 13:06:51 -05:00
1 parent 8403fc5ef2
commit a0484e7707
4 files changed
+63 -129

No files matched your search

@@ -1348,7 +1348,14 @@ private fun Layer.safeRemoveLayerFromMap() {
private fun Layer.safeAddLayerToMap() {
try {
if (!isLayerOnMap) addLayerToMap()
// maps-utils 5.0.0 dropped isLayerOnMap() from the Layer base class; both concrete layers still expose it.
val isOnMap =
when (this) {
is GeoJsonLayer -> isLayerOnMap()
is KmlLayer -> isLayerOnMap()
else -> false
}
if (!isOnMap) addLayerToMap()
} catch (e: Exception) {
Logger.withTag("MapView").e(e) { "Error adding map layer" }
}
@@ -1368,15 +1375,15 @@ private fun GeoJsonLayer.applySimpleStyleSpec() {
val stroke = feature.cssColor("stroke") ?: feature.cssColor("color")
val fillOpacity = feature.getProperty("fill-opacity")?.toFloatOrNull()
val strokeWidth = feature.getProperty("stroke-width")?.toFloatOrNull() ?: DEFAULT_GEOJSON_STROKE_WIDTH
when (feature.geometry?.geometryType) {
when (feature.getGeometry()?.getGeometryType()) {
"Polygon",
"MultiPolygon",
->
feature.polygonStyle =
GeoJsonPolygonStyle().apply {
fill?.let { fillColor = it.resolveFillAlpha(fillOpacity) }
stroke?.let { strokeColor = it }
this.strokeWidth = strokeWidth
stroke?.let { setStrokeColor(it) }
setStrokeWidth(strokeWidth)
}
"LineString",
@@ -1385,7 +1392,7 @@ private fun GeoJsonLayer.applySimpleStyleSpec() {
feature.lineStringStyle =
GeoJsonLineStringStyle().apply {
stroke?.let { color = it }
width = strokeWidth
setWidth(strokeWidth)
}
else -> Unit // Points keep the default marker.
@@ -16,51 +16,33 @@
*/
package org.meshtastic.app.map.component
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.model.BitmapDescriptor
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.ClusterManager
import com.google.maps.android.clustering.view.DefaultClusterRenderer
import com.google.maps.android.compose.Circle
import com.google.maps.android.compose.MapEffect
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.clustering.Clustering
import com.google.maps.android.compose.clustering.rememberClusterManager
import com.google.maps.android.compose.rememberComposeBitmapDescriptor
import com.google.maps.android.compose.clustering.ClusteringMarkerProperties
import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.feature.map.BaseMapViewModel
private const val MIN_CLUSTER_SIZE = 10
/**
* Renders node markers with clustering.
* Renders node markers with clustering via the library's [Clustering] composable.
*
* Marker bitmaps are generated **in the maps compose scope** via [rememberComposeBitmapDescriptor], which composes each
* chip in a `ComposeView` parented to the live host view (a real, attached view that has valid `ViewTreeLifecycleOwner`
* / `SavedStateRegistryOwner`) and renders it synchronously to a [BitmapDescriptor].
* Each unclustered node is composed as a [PulsingNodeChip] (`clusterItemContent`), with its z-index forwarded through
* [ClusteringMarkerProperties]; cluster bubbles keep the library's default rendering. Native info windows
* (title/snippet from [NodeClusterItem]) and click interactions are handled by the library renderer, and precision
* circles are drawn for the currently-unclustered items via `clusterItemDecoration`.
*
* This deliberately avoids the clustering library's `clusterItemContent` path: that renderer
* ([com.google.maps.android.compose.clustering.ComposeUiClusterRenderer]) composes each item in a *detached*
* `ComposeView` that only carries a fake lifecycle owner and no `SavedStateRegistryOwner`, so it crashes when the
* surrounding Navigation 3 / popup hierarchy lacks those owners (the top Crashlytics FATAL). A custom
* [DefaultClusterRenderer] subclass assigns the pre-baked bitmaps instead, keeping native info windows (title/snippet
* from [NodeClusterItem]) and click interactions intact.
* Requires maps-compose >= 8.4.0: earlier versions composed cluster items into a detached `ComposeView` that carried no
* lifecycle/saved-state owners, crashing under the Navigation 3 hierarchy (fixed upstream in
* googlemaps/android-maps-compose#930, which this file previously worked around with a custom [DefaultClusterRenderer]
* assigning pre-baked bitmaps).
*/
@OptIn(MapsComposeExperimentalApi::class)
@Suppress("NestedBlockDepth")
@Composable
fun NodeClusterMarkers(
nodeClusterItems: List<NodeClusterItem>,
@@ -68,48 +50,16 @@ fun NodeClusterMarkers(
navigateToNodeDetails: (Int) -> Unit,
onClusterClick: (Cluster<NodeClusterItem>) -> Boolean,
) {
val context = LocalContext.current
val clusterManager = rememberClusterManager<NodeClusterItem>()
// Bake each node's marker icon in-scope. Keyed by node so a bitmap is only re-rendered when that node
// actually changes. The descriptors are stashed in a snapshot map the renderer reads at render time.
val iconDescriptors = remember { mutableStateMapOf<Int, BitmapDescriptor>() }
nodeClusterItems.forEach { item ->
key(item.node.num) {
val descriptor = rememberComposeBitmapDescriptor(item.node) { PulsingNodeChip(node = item.node) }
DisposableEffect(descriptor) {
iconDescriptors[item.node.num] = descriptor
onDispose { iconDescriptors.remove(item.node.num) }
}
}
}
if (clusterManager != null) {
val rendererState = remember { mutableStateOf<NodeClusterRenderer?>(null) }
// The renderer needs the GoogleMap instance, only available inside the map scope.
MapEffect(clusterManager) { map ->
val renderer = NodeClusterRenderer(context, map, clusterManager) { iconDescriptors[it] }
clusterManager.renderer = renderer
rendererState.value = renderer
}
// Keep listeners current — the lambdas can change across recompositions.
SideEffect {
clusterManager.setOnClusterClickListener { cluster -> onClusterClick(cluster) }
clusterManager.setOnClusterItemInfoWindowClickListener { item -> navigateToNodeDetails(item.node.num) }
}
// Re-cluster once the renderer is attached and freshly-baked icons arrive, so markers pick up the bitmaps
// even when the cluster manager became available after the icons were rendered.
val renderer = rendererState.value
LaunchedEffect(renderer, iconDescriptors.size) { if (renderer != null) clusterManager.cluster() }
Clustering(items = nodeClusterItems, clusterManager = clusterManager)
// Precision circles for the currently-unclustered items (the renderer tracks them as the zoom changes).
if (mapFilterState.showPrecisionCircle) {
renderer?.unclusteredItems?.value?.forEach { item ->
Clustering(
items = nodeClusterItems,
onClusterClick = onClusterClick,
onClusterItemInfoWindowClick = { item -> navigateToNodeDetails(item.node.num) },
clusterItemContent = { item ->
ClusteringMarkerProperties(zIndex = item.zIndex)
PulsingNodeChip(node = item.node)
},
clusterItemDecoration = { item ->
if (mapFilterState.showPrecisionCircle) {
item.getPrecisionMeters()?.let { precisionMeters ->
if (precisionMeters > 0) {
Circle(
@@ -123,44 +73,14 @@ fun NodeClusterMarkers(
}
}
}
}
}
}
/**
* [DefaultClusterRenderer] that assigns the pre-baked [BitmapDescriptor]s (rendered in the maps compose scope) to
* non-clustered item markers, and exposes the set of currently-unclustered items so the caller can decorate them (e.g.
* precision circles). Cluster bubbles keep the library's default rendering.
*/
private class NodeClusterRenderer(
context: Context,
map: GoogleMap,
clusterManager: ClusterManager<NodeClusterItem>,
private val iconProvider: (Int) -> BitmapDescriptor?,
) : DefaultClusterRenderer<NodeClusterItem>(context, map, clusterManager) {
val unclusteredItems = mutableStateOf<Set<NodeClusterItem>>(emptySet())
init {
minClusterSize = MIN_CLUSTER_SIZE
}
override fun onClustersChanged(clusters: Set<Cluster<NodeClusterItem>>) {
super.onClustersChanged(clusters)
unclusteredItems.value = clusters.filterNot { shouldRenderAsCluster(it) }.flatMap { it.items }.toSet()
}
override fun onBeforeClusterItemRendered(item: NodeClusterItem, markerOptions: MarkerOptions) {
// super sets title/snippet from the ClusterItem, which drives the native info window.
super.onBeforeClusterItemRendered(item, markerOptions)
iconProvider(item.node.num)?.let { markerOptions.icon(it) }
markerOptions.zIndex(item.getZIndex())
}
override fun onClusterItemUpdated(item: NodeClusterItem, marker: Marker) {
// super keeps title/snippet (and the open info window) in sync.
super.onClusterItemUpdated(item, marker)
iconProvider(item.node.num)?.let { marker.setIcon(it) }
marker.zIndex = item.getZIndex()
}
},
onClusterManager = { clusterManager ->
// The library renderer extends DefaultClusterRenderer; raise its clustering threshold once.
val renderer = clusterManager.renderer as? DefaultClusterRenderer<*>
if (renderer != null && renderer.minClusterSize != MIN_CLUSTER_SIZE) {
renderer.minClusterSize = MIN_CLUSTER_SIZE
clusterManager.cluster()
}
},
)
}
@@ -27,24 +27,32 @@ data class NodeClusterItem(
val nodeSnippet: String,
val myNodeNum: Int? = null,
) : ClusterItem {
override fun getPosition(): LatLng = nodePosition
override val position: LatLng
get() = nodePosition
override fun getTitle(): String = nodeTitle
override val title: String
get() = nodeTitle
override fun getSnippet(): String = nodeSnippet
override val snippet: String
get() = nodeSnippet
override fun getZIndex(): Float = when {
node.num == myNodeNum -> 5.0f
override val zIndex: Float
get() =
when {
node.num == myNodeNum -> 5.0f
// My node is always highest
node.isFavorite -> 5.0f
// My node is always highest
node.isFavorite -> 5.0f
// Favorites are equally high priority
else -> 4.0f
}
// Favorites are equally high priority
else -> 4.0f
}
fun getPrecisionMeters(): Double? {
val precisionMap =
fun getPrecisionMeters(): Double? = PRECISION_METERS_BY_BITS[node.position.precision_bits]
companion object {
// Allocated once: this lookup runs for every unclustered item on each cluster pass.
private val PRECISION_METERS_BY_BITS =
mapOf(
10 to 23345.484932,
11 to 11672.7369,
@@ -57,6 +65,5 @@ data class NodeClusterItem(
18 to 91.182212,
19 to 45.58554,
)
return precisionMap[this.node.position.precision_bits]
}
}
+1 -1
View File
@@ -62,7 +62,7 @@ androidx-compose-bom-aligned = "1.11.4"
jetbrains-adaptive = "1.3.0-beta02"
# Google
maps-compose = "8.3.1"
maps-compose = "8.4.0"
# ML Kit
mlkit-barcode-scanning = "17.3.0"