fix(map): replace MarkerComposable with Canvas-rendered bitmaps (#5702)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-06-01 12:29:31 -05:00
committed by GitHub
parent cc3b88d005
commit 60feec646b
4 changed files with 140 additions and 35 deletions

View File

@@ -78,7 +78,7 @@ import com.google.maps.android.compose.MapProperties
import com.google.maps.android.compose.MapType
import com.google.maps.android.compose.MapUiSettings
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.MarkerComposable
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.MarkerInfoWindowComposable
import com.google.maps.android.compose.Polyline
import com.google.maps.android.compose.TileOverlay
@@ -102,6 +102,7 @@ import org.meshtastic.app.map.component.MapTypeDropdown
import org.meshtastic.app.map.component.NodeClusterMarkers
import org.meshtastic.app.map.component.NodeMapFilterDropdown
import org.meshtastic.app.map.component.WaypointMarkers
import org.meshtastic.app.map.component.rememberNodeChipDescriptor
import org.meshtastic.app.map.model.NodeClusterItem
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
@@ -125,7 +126,6 @@ import org.meshtastic.core.resources.sats
import org.meshtastic.core.resources.speed
import org.meshtastic.core.resources.timestamp
import org.meshtastic.core.resources.track_point
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.icon.Layers
import org.meshtastic.core.ui.icon.Map
import org.meshtastic.core.ui.icon.MeshtasticIcons
@@ -864,17 +864,17 @@ private fun NodeTrackOverlay(
}
if (index == sortedPositions.lastIndex) {
MarkerComposable(
val chipIcon = rememberNodeChipDescriptor(focusedNode)
Marker(
state = markerState,
icon = chipIcon,
zIndex = activeNodeZIndex,
alpha = if (isHighPriority) 1.0f else 0.9f,
onClick = {
onPositionSelected?.invoke(position.time)
false // Allow default info window behavior
},
) {
NodeChip(node = focusedNode)
}
)
} else {
MarkerInfoWindowComposable(
state = markerState,
@@ -969,7 +969,6 @@ private fun speedFromPosition(position: Position, displayUnits: DisplayUnits): S
// region --- Traceroute Map Content ---
@OptIn(MapsComposeExperimentalApi::class)
@Composable
private fun TracerouteMapContent(
forwardOffsetPoints: List<LatLng>,
@@ -998,7 +997,8 @@ private fun TracerouteMapContent(
}
displayNodes.forEach { node ->
val markerState = rememberUpdatedMarkerState(position = node.position.toLatLng())
MarkerComposable(state = markerState, zIndex = 4f) { NodeChip(node = node) }
val chipIcon = rememberNodeChipDescriptor(node)
Marker(state = markerState, icon = chipIcon, zIndex = 4f)
}
}

View File

@@ -0,0 +1,123 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.map.component
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.text.TextPaint
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
import androidx.core.graphics.createBitmap
import com.google.android.gms.maps.model.BitmapDescriptor
import com.google.android.gms.maps.model.BitmapDescriptorFactory
import org.meshtastic.core.model.Node
private const val CHIP_CORNER_RADIUS_DP = 4f
private const val CHIP_PADDING_HORIZONTAL_DP = 8f
private const val CHIP_MIN_WIDTH_DP = 64f
private const val CHIP_MIN_HEIGHT_DP = 28f
private const val CHIP_TEXT_SIZE_SP = 14f
private const val EMOJI_TEXT_SIZE_SP = 32f
private const val EMOJI_PADDING_DP = 2f
/**
* Renders a node chip marker as a [BitmapDescriptor] using Canvas — avoids the off-screen ComposeView pipeline in
* maps-compose's `MarkerComposable`/`rememberComposeBitmapDescriptor` which can crash with "The ComposeView was
* measured to have a width or height of zero" during subcomposition races (googlemaps/android-maps-compose#875).
*/
@Composable
fun rememberNodeChipDescriptor(node: Node): BitmapDescriptor {
val density = LocalDensity.current.density
val fontScale = LocalDensity.current.fontScale
return remember(node.num, node.user.short_name, node.colors, node.isIgnored) {
renderNodeChipBitmap(node, density, fontScale)
}
}
/** Renders an emoji waypoint marker as a [BitmapDescriptor] using Canvas. */
@Composable
fun rememberEmojiMarkerDescriptor(codePoint: Int): BitmapDescriptor {
val density = LocalDensity.current.density
val fontScale = LocalDensity.current.fontScale
return remember(codePoint) { renderEmojiBitmap(codePoint, density, fontScale) }
}
private fun renderNodeChipBitmap(node: Node, density: Float, fontScale: Float): BitmapDescriptor {
val (textColorInt, nodeColorInt) = node.colors
val scaledDensity = density * fontScale
val textPaint =
TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = CHIP_TEXT_SIZE_SP * scaledDensity
typeface = Typeface.DEFAULT_BOLD
color = textColorInt
textAlign = Paint.Align.CENTER
isStrikeThruText = node.isIgnored
}
val label = node.user.short_name.ifEmpty { "???" }
val textWidth = textPaint.measureText(label)
val paddingH = CHIP_PADDING_HORIZONTAL_DP * density
val minWidth = CHIP_MIN_WIDTH_DP * density
val minHeight = CHIP_MIN_HEIGHT_DP * density
val width = maxOf(minWidth, textWidth + paddingH * 2).toInt()
val height = minHeight.toInt()
val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap)
val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = nodeColorInt }
val cornerRadius = CHIP_CORNER_RADIUS_DP * density
canvas.drawRoundRect(RectF(0f, 0f, width.toFloat(), height.toFloat()), cornerRadius, cornerRadius, bgPaint)
val textX = width / 2f
val textY = (height / 2f) - ((textPaint.descent() + textPaint.ascent()) / 2f)
canvas.drawText(label, textX, textY, textPaint)
return BitmapDescriptorFactory.fromBitmap(bitmap)
}
private fun renderEmojiBitmap(codePoint: Int, density: Float, fontScale: Float): BitmapDescriptor {
val scaledDensity = density * fontScale
val textPaint =
TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = EMOJI_TEXT_SIZE_SP * scaledDensity
textAlign = Paint.Align.CENTER
}
val emoji = String(Character.toChars(codePoint))
val padding = EMOJI_PADDING_DP * density
val textWidth = textPaint.measureText(emoji)
val metrics = textPaint.fontMetrics
val textHeight = metrics.descent - metrics.ascent
val width = (textWidth + padding * 2).toInt().coerceAtLeast(1)
val height = (textHeight + padding * 2).toInt().coerceAtLeast(1)
val bitmap = createBitmap(width, height)
val canvas = Canvas(bitmap)
val textX = width / 2f
val textY = padding - metrics.ascent
canvas.drawText(emoji, textX, textY, textPaint)
return BitmapDescriptorFactory.fromBitmap(bitmap)
}

View File

@@ -16,22 +16,14 @@
*/
package org.meshtastic.app.map.component
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.rememberComposeBitmapDescriptor
import com.google.maps.android.compose.rememberUpdatedMarkerState
import kotlinx.coroutines.launch
import org.meshtastic.app.map.convertIntToEmoji
import org.meshtastic.core.model.util.GeoConstants.DEG_D
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.locked
@@ -39,7 +31,6 @@ import org.meshtastic.core.ui.util.showToast
import org.meshtastic.feature.map.BaseMapViewModel
import org.meshtastic.proto.Waypoint
@OptIn(MapsComposeExperimentalApi::class)
@Composable
fun WaypointMarkers(
displayableWaypoints: List<Waypoint>,
@@ -65,11 +56,7 @@ fun WaypointMarkers(
}
val iconCodePoint = if (waypoint.icon == 0) PUSHPIN else waypoint.icon
val emojiText = convertIntToEmoji(iconCodePoint)
val icon =
rememberComposeBitmapDescriptor(iconCodePoint) {
Text(text = emojiText, fontSize = 32.sp, modifier = Modifier.padding(2.dp))
}
val icon = rememberEmojiMarkerDescriptor(iconCodePoint)
Marker(
state = markerState,

View File

@@ -17,14 +17,12 @@
package org.meshtastic.app.node.component
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.setViewTreeLifecycleOwner
@@ -37,17 +35,15 @@ import com.google.maps.android.compose.Circle
import com.google.maps.android.compose.ComposeMapColorScheme
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MapUiSettings
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.MarkerComposable
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberUpdatedMarkerState
import org.meshtastic.app.map.component.rememberNodeChipDescriptor
import org.meshtastic.core.model.Node
import org.meshtastic.core.ui.component.NodeChip
import org.meshtastic.core.ui.component.precisionBitsToMeters
private const val DEFAULT_ZOOM = 15f
@OptIn(MapsComposeExperimentalApi::class)
@Composable
fun InlineMap(node: Node, modifier: Modifier = Modifier) {
val dark = isSystemInDarkTheme()
@@ -57,14 +53,14 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) {
else -> ComposeMapColorScheme.LIGHT
}
// Workaround for a maps-compose issue where MarkerComposable's internal ComposeView cannot find a
// ViewTreeLifecycleOwner, causing a crash on bitmap rendering. We attach the current owners to the
// window root view for the lifetime of the map.
// Defensive workaround: propagate ViewTreeLifecycleOwner to the root view so that
// any internal maps-compose ComposeView (e.g., info windows) can find the lifecycle
// when walking up the view tree.
//
// IMPORTANT: capture and restore the previous owners on dispose. This InlineMap is hosted inside the
// node-detail NavEntry, whose LocalLifecycleOwner is a transient, entry-scoped lifecycle. Leaving it
// attached to the activity root view after the entry is destroyed (e.g. navigating back to the node
// list) would make every subsequently opened Popup/DropdownMenu inherit a DESTROYED lifecycle and
// list) would make subsequently opened Popups/DropdownMenus inherit a DESTROYED lifecycle and
// render at 0x0 (invisible). See the node-list popup regression.
val view = LocalView.current
val lifecycleOwner = LocalLifecycleOwner.current
@@ -86,6 +82,7 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) {
val cameraState = rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(location, DEFAULT_ZOOM)
}
val markerIcon = rememberNodeChipDescriptor(node)
GoogleMap(
mapColorScheme = mapColorScheme,
@@ -114,9 +111,7 @@ fun InlineMap(node: Node, modifier: Modifier = Modifier) {
strokeWidth = 2f,
)
}
MarkerComposable(state = rememberUpdatedMarkerState(position = latLng)) {
NodeChip(node = node, modifier = Modifier.defaultMinSize(minWidth = 64.dp, minHeight = 28.dp))
}
Marker(state = rememberUpdatedMarkerState(position = latLng), icon = markerIcon)
}
}
}