WIP: MapScreen.kt changes

This commit is contained in:
2026-04-19 10:00:06 +02:00
parent 89ce2e1afa
commit bf83336bbc

View File

@@ -2,6 +2,7 @@ package org.db3.airmq.features.map
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Point
import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.BitmapDrawable
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
@@ -22,8 +23,14 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -63,6 +70,8 @@ import org.osmdroid.views.overlay.Marker
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.core.graphics.createBitmap import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.toDrawable import androidx.core.graphics.drawable.toDrawable
@@ -98,6 +107,29 @@ fun MapScreen(
) )
} }
/**
* Bottom-sheet height fraction for map camera padding (0 = none, 0.5 = half screen).
* Uses [SheetState] so the map reacts as soon as open/close *animations* start, not only when
* values settle: closing uses target Hidden while still expanded; opening must not keep 0 inset
* while both values are still Hidden (that delayed the camera until the sheet finished opening).
*/
@OptIn(ExperimentalMaterial3Api::class)
private fun mapSheetFractionForDevicePanel(
devicePanelPresent: Boolean,
sheetState: SheetState
): Float {
if (!devicePanelPresent) return 0f
return when {
sheetState.currentValue == SheetValue.Expanded &&
sheetState.targetValue == SheetValue.Expanded -> 0.5f
sheetState.targetValue == SheetValue.Hidden &&
sheetState.currentValue == SheetValue.Expanded -> 0f
sheetState.targetValue == SheetValue.Expanded &&
sheetState.currentValue == SheetValue.Hidden -> 0.5f
else -> 0.5f
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun MapScreenContent( private fun MapScreenContent(
@@ -106,10 +138,22 @@ private fun MapScreenContent(
showMap: Boolean showMap: Boolean
) { ) {
val context = LocalContext.current val context = LocalContext.current
val modalBottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val centerOnMarker = uiState.selectedMarkerId?.let { id -> val centerOnMarker = uiState.selectedMarkerId?.let { id ->
uiState.items.find { it.id == id } uiState.items.find { it.id == id }
} }
val sheetHeightFraction = if (uiState.devicePanelState != null) 0.5f else 0f // Map padding tracks sheet motion: interpolate with SheetState.progress during open/close so the
// camera offset stays in sync with the panel animation (not only after VM/sheet settle).
val mapSheetHeightFraction = mapSheetFractionForDevicePanel(
devicePanelPresent = uiState.devicePanelState != null,
sheetState = modalBottomSheetState
)
LaunchedEffect(uiState.devicePanelState?.id) {
if (uiState.devicePanelState != null) {
modalBottomSheetState.show()
}
}
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
if (showMap) { if (showMap) {
@@ -117,7 +161,7 @@ private fun MapScreenContent(
items = uiState.items, items = uiState.items,
onMarkerClick = { onEvent(Event.MarkerClicked(it)) }, onMarkerClick = { onEvent(Event.MarkerClicked(it)) },
centerOnMarker = centerOnMarker, centerOnMarker = centerOnMarker,
sheetHeightFraction = sheetHeightFraction, sheetHeightFraction = mapSheetHeightFraction,
clusterEnabled = true, clusterEnabled = true,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
@@ -165,10 +209,9 @@ private fun MapScreenContent(
} }
uiState.devicePanelState?.let { panelData -> uiState.devicePanelState?.let { panelData ->
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { onEvent(Event.DevicePanelClosed) }, onDismissRequest = { onEvent(Event.DevicePanelClosed) },
sheetState = sheetState sheetState = modalBottomSheetState
) { ) {
MapDevicePanelContent( MapDevicePanelContent(
data = panelData, data = panelData,
@@ -200,6 +243,22 @@ private const val MapClusterDebounceMs = 150L
private const val MapClusterDistanceDp = 48f private const val MapClusterDistanceDp = 48f
private const val MapClusterZoomPaddingPx = 64 private const val MapClusterZoomPaddingPx = 64
/** Extra time after [mapAnimationSpeedMs] so osmdroid ValueAnimator + layout can finish before we read Projection. */
private const val MapCameraSettleExtraMs = 48L
/**
* Waits until [MapView.isAnimating] is false (zoom/pan animation) or [maxWaitMs], then one more frame.
*/
private suspend fun MapView.awaitMapAnimationSettled(maxWaitMs: Long = 600L) {
val step = 16L
var waited = 0L
while (isAnimating() && waited < maxWaitMs) {
delay(step)
waited += step
}
delay(MapCameraSettleExtraMs)
}
@Composable @Composable
private fun AirMQMap( private fun AirMQMap(
items: List<MapMarker>, items: List<MapMarker>,
@@ -216,6 +275,9 @@ private fun AirMQMap(
val latestOnMarkerClick = rememberUpdatedState(onMarkerClick) val latestOnMarkerClick = rememberUpdatedState(onMarkerClick)
val latestClusterEnabled = rememberUpdatedState(clusterEnabled) val latestClusterEnabled = rememberUpdatedState(clusterEnabled)
val latestCenterOnMarker = rememberUpdatedState(centerOnMarker) val latestCenterOnMarker = rememberUpdatedState(centerOnMarker)
val latestSheetHeightFraction = rememberUpdatedState(sheetHeightFraction)
/** Tracks sheet inset so we can run a one-shot recenter when the panel fully dismisses (fraction 0). */
var previousSheetFraction by remember { mutableFloatStateOf(0f) }
val initialCameraDone = remember { AtomicBoolean(false) } val initialCameraDone = remember { AtomicBoolean(false) }
@@ -279,28 +341,57 @@ private fun AirMQMap(
} }
val mapAnimationSpeedMs = 200L val mapAnimationSpeedMs = 200L
LaunchedEffect(centerOnMarker, sheetHeightFraction) { val markerZoomLevel = 15.5
centerOnMarker?.let { marker ->
val markerGeo = GeoPoint(marker.latitude, marker.longitude) // Zoom/pan starts as soon as the marker is selected (same time as the bottom sheet), not after it opens.
val zoomLevel = 15.5 LaunchedEffect(centerOnMarker?.id) {
if (sheetHeightFraction > 0f) { val marker = centerOnMarker ?: return@LaunchedEffect
val markerGeo = GeoPoint(marker.latitude, marker.longitude)
mapView.controller.animateTo(markerGeo, markerZoomLevel, mapAnimationSpeedMs)
coroutineScope {
launch {
var waited = 0
while (latestSheetHeightFraction.value <= 0f && waited < 500) {
delay(16)
waited += 16
}
val frac = latestSheetHeightFraction.value
if (frac <= 0f) return@launch
delay(mapAnimationSpeedMs)
mapView.awaitMapAnimationSettled()
mapView.post { mapView.post {
val height = mapView.height val height = mapView.height
val width = mapView.width if (height <= 0) return@post
if (height > 0 && width > 0) { val sheetHeightPx = (height * frac).toInt()
val sheetHeightPx = (height * sheetHeightFraction).toInt() val targetY = (height - sheetHeightPx) / 2
val offsetCenterY = height / 2 + sheetHeightPx / 2 val projection = mapView.projection
val projection = mapView.projection val p = Point()
val offsetGeo = projection.fromPixels(width / 2, offsetCenterY) projection.toPixels(markerGeo, p)
mapView.controller.animateTo(offsetGeo, zoomLevel, mapAnimationSpeedMs) mapView.scrollBy(0, p.y - targetY)
}
} }
} else {
mapView.controller.animateTo(markerGeo, zoomLevel, mapAnimationSpeedMs)
} }
} }
} }
// When the device panel dismisses, recenter on the marker for the full map (no sheet inset).
LaunchedEffect(sheetHeightFraction, centerOnMarker?.id) {
val marker = centerOnMarker
if (marker == null) {
previousSheetFraction = sheetHeightFraction
return@LaunchedEffect
}
val wasSheet = previousSheetFraction > 0f
val isSheet = sheetHeightFraction > 0f
if (wasSheet && !isSheet) {
mapView.controller.animateTo(
GeoPoint(marker.latitude, marker.longitude),
markerZoomLevel,
mapAnimationSpeedMs
)
}
previousSheetFraction = sheetHeightFraction
}
// Overlay rebuild must be keyed off composable inputs: AndroidView's `update` runs without // Overlay rebuild must be keyed off composable inputs: AndroidView's `update` runs without
// recording snapshot reads when values are only read inside View.post {}, so map items can // recording snapshot reads when values are only read inside View.post {}, so map items can
// load without a rebuild until something else (e.g. pan → debounced rebuild) retriggers it. // load without a rebuild until something else (e.g. pan → debounced rebuild) retriggers it.