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.Canvas
import android.graphics.Point
import android.graphics.drawable.BitmapDrawable
import android.os.Handler
import android.os.Looper
@@ -22,8 +23,14 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
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.Modifier
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.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.core.graphics.createBitmap
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)
@Composable
private fun MapScreenContent(
@@ -106,10 +138,22 @@ private fun MapScreenContent(
showMap: Boolean
) {
val context = LocalContext.current
val modalBottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val centerOnMarker = uiState.selectedMarkerId?.let { 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()) {
if (showMap) {
@@ -117,7 +161,7 @@ private fun MapScreenContent(
items = uiState.items,
onMarkerClick = { onEvent(Event.MarkerClicked(it)) },
centerOnMarker = centerOnMarker,
sheetHeightFraction = sheetHeightFraction,
sheetHeightFraction = mapSheetHeightFraction,
clusterEnabled = true,
modifier = Modifier.fillMaxSize()
)
@@ -165,10 +209,9 @@ private fun MapScreenContent(
}
uiState.devicePanelState?.let { panelData ->
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = { onEvent(Event.DevicePanelClosed) },
sheetState = sheetState
sheetState = modalBottomSheetState
) {
MapDevicePanelContent(
data = panelData,
@@ -200,6 +243,22 @@ private const val MapClusterDebounceMs = 150L
private const val MapClusterDistanceDp = 48f
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
private fun AirMQMap(
items: List<MapMarker>,
@@ -216,6 +275,9 @@ private fun AirMQMap(
val latestOnMarkerClick = rememberUpdatedState(onMarkerClick)
val latestClusterEnabled = rememberUpdatedState(clusterEnabled)
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) }
@@ -279,26 +341,55 @@ private fun AirMQMap(
}
val mapAnimationSpeedMs = 200L
LaunchedEffect(centerOnMarker, sheetHeightFraction) {
centerOnMarker?.let { marker ->
val markerZoomLevel = 15.5
// Zoom/pan starts as soon as the marker is selected (same time as the bottom sheet), not after it opens.
LaunchedEffect(centerOnMarker?.id) {
val marker = centerOnMarker ?: return@LaunchedEffect
val markerGeo = GeoPoint(marker.latitude, marker.longitude)
val zoomLevel = 15.5
if (sheetHeightFraction > 0f) {
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 {
val height = mapView.height
val width = mapView.width
if (height > 0 && width > 0) {
val sheetHeightPx = (height * sheetHeightFraction).toInt()
val offsetCenterY = height / 2 + sheetHeightPx / 2
if (height <= 0) return@post
val sheetHeightPx = (height * frac).toInt()
val targetY = (height - sheetHeightPx) / 2
val projection = mapView.projection
val offsetGeo = projection.fromPixels(width / 2, offsetCenterY)
mapView.controller.animateTo(offsetGeo, zoomLevel, mapAnimationSpeedMs)
val p = Point()
projection.toPixels(markerGeo, p)
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