mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-06 04:55:07 -04:00
fix(map): replace MarkerComposable with Canvas-rendered bitmaps (#5702)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user