WIP: MapScreen.kt changes
This commit is contained in:
@@ -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,28 +341,57 @@ private fun AirMQMap(
|
||||
}
|
||||
|
||||
val mapAnimationSpeedMs = 200L
|
||||
LaunchedEffect(centerOnMarker, sheetHeightFraction) {
|
||||
centerOnMarker?.let { marker ->
|
||||
val markerGeo = GeoPoint(marker.latitude, marker.longitude)
|
||||
val zoomLevel = 15.5
|
||||
if (sheetHeightFraction > 0f) {
|
||||
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)
|
||||
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
|
||||
val projection = mapView.projection
|
||||
val offsetGeo = projection.fromPixels(width / 2, offsetCenterY)
|
||||
mapView.controller.animateTo(offsetGeo, zoomLevel, mapAnimationSpeedMs)
|
||||
}
|
||||
if (height <= 0) return@post
|
||||
val sheetHeightPx = (height * frac).toInt()
|
||||
val targetY = (height - sheetHeightPx) / 2
|
||||
val projection = mapView.projection
|
||||
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
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user