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.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.
|
||||||
|
|||||||
Reference in New Issue
Block a user