From 40cfef5f1e6a59059db0108c4ee208b89d4ebe5e Mon Sep 17 00:00:00 2001 From: Cilly Leang Date: Fri, 28 Nov 2025 21:20:45 +1100 Subject: [PATCH] refactor(ui): use compose-unstyled bottomsheet --- composeApp/build.gradle.kts | 1 + .../lava/banksia/ui/layout/AppBottomSheet.kt | 184 ++++++++++++++++++ .../moe/lava/banksia/ui/layout/InfoPanel.kt | 9 +- .../moe/lava/banksia/ui/screens/MapScreen.kt | 119 +++-------- gradle/libs.versions.toml | 2 + 5 files changed, 214 insertions(+), 101 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/layout/AppBottomSheet.kt diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 42ff67c..d362efc 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -53,6 +53,7 @@ kotlin { implementation(compose.ui) implementation(compose.components.resources) implementation(compose.components.uiToolingPreview) + implementation(libs.composeunstyled) implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/layout/AppBottomSheet.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/layout/AppBottomSheet.kt new file mode 100644 index 0000000..571f363 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/layout/AppBottomSheet.kt @@ -0,0 +1,184 @@ +package moe.lava.banksia.ui.layout + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.backhandler.PredictiveBackHandler +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.composables.core.BottomSheet +import com.composables.core.BottomSheetState +import com.composables.core.DragIndication +import com.composables.core.SheetDetent +import com.composables.core.rememberBottomSheetState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.roundToInt + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun AppBottomSheet( + sheetState: SheetStateWrapper, + onDismiss: () -> Unit, + content: @Composable () -> Unit, +) { + var peekHeightMultiplier by remember { mutableFloatStateOf(1f) } + var sheetEnabled by remember { mutableStateOf(true) } + val scope = rememberCoroutineScope() + BottomSheet( + state = sheetState.state, + enabled = sheetEnabled, + modifier = Modifier.fillMaxSize() + // TODO: This recomposes; find a better solution using Modifier.layout + .padding( + top = 24.dp * (1f - peekHeightMultiplier), + end = 24.dp * (1f - peekHeightMultiplier), + bottom = 0.dp, + start = 24.dp * (1f - peekHeightMultiplier), + ) + .shadow(4.dp, RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)) + .clip(RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)) + .background(MaterialTheme.colorScheme.surface) + .fillMaxWidth() + .imePadding(), + ) { + Column(Modifier.fillMaxSize().alpha(peekHeightMultiplier)) { + DragIndication( + Modifier + .padding(vertical = 12.dp) + .height(4.dp) + .width(32.dp) + .align(Alignment.CenterHorizontally) + .background(MaterialTheme.colorScheme.onSurfaceVariant, RoundedCornerShape(100)) + ) + content() + } + } + + PredictiveBackHandler(!sheetState.hidden) { progress -> + sheetEnabled = false + try { + progress.collect { backEvent -> + if (sheetState.peeking) { + peekHeightMultiplier = 1F - backEvent.progress + } + } + if (sheetState.expanded) { + scope.launch { sheetState.peek() } + } else if (sheetState.peeking) { + scope.launch { + sheetState.hide() + peekHeightMultiplier = 1F + onDismiss() + } + } + } catch (_: CancellationException) { + peekHeightMultiplier = 1F + } + sheetEnabled = true + } +} + +class SheetStateWrapper( + val state: BottomSheetState, + private val scope: CoroutineScope, + private var p1: MutableState, + private var p2: MutableState, + private val peek1: SheetDetent, + private val peek2: SheetDetent, +) { + companion object { + private val saver = Saver, Float>( + save = { it.value.value }, + restore = { mutableStateOf(it.dp) } + ) + + @Composable + fun create(): SheetStateWrapper { + val p1 = rememberSaveable(saver = saver) { mutableStateOf(0.dp) } + val p2 = rememberSaveable(saver = saver) { mutableStateOf(0.dp) } + val scope = rememberCoroutineScope() + + val peek1 = SheetDetent(identifier = "peek1") { containerHeight, sheetHeight -> + val res = (p1.value + 40.dp) + res + } + val peek2 = SheetDetent(identifier = "peek2") { containerHeight, sheetHeight -> + val res = (p2.value + 40.dp) + res + } + val internalState = rememberBottomSheetState( + initialDetent = SheetDetent.Hidden, + detents = listOf(SheetDetent.Hidden, peek1, peek2, SheetDetent.FullyExpanded) + ) + return remember { SheetStateWrapper(internalState, scope, p1, p2, peek1, peek2) } + } + } + + @Suppress("NOTHING_TO_INLINE") + private inline fun stateEither(detent: SheetDetent) = state.currentDetent == detent || state.targetDetent == detent + + private var peek: SheetDetent = peek1 + + val current get() = state.currentDetent + val target get() = state.targetDetent + val expanded get() = stateEither(SheetDetent.FullyExpanded) + val peeking get() = stateEither(peek1) || stateEither(peek2) + val hidden get() = stateEither(SheetDetent.Hidden) + val offset get() = state.offset + + val bottomInset: Int @Composable get() { + return if (!hidden) { + val sheetOffset = state.offset.roundToInt() + val insets = WindowInsets.safeDrawing.getBottom(LocalDensity.current) + (sheetOffset - insets) + .coerceAtLeast(0) + .coerceIn(0, with(LocalDensity.current) { 500.dp.roundToPx() }) + } else 0 + } + + fun hide() { state.targetDetent = SheetDetent.Hidden } + fun peek() { state.targetDetent = peek } + fun peekTo(target: Dp) { + if (peek == peek1) { + p2.value = target + peek = peek2 + } else { + p1.value = target + peek = peek1 + } + scope.launch { + state.animateTo(peek) + p1.value = target + p2.value = target + state.invalidateDetents() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/layout/InfoPanel.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/layout/InfoPanel.kt index 7d34bce..159402d 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/layout/InfoPanel.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/layout/InfoPanel.kt @@ -7,13 +7,10 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeContent -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsBottomHeight -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -48,8 +45,6 @@ fun InfoPanel( Modifier .fillMaxWidth() .padding(horizontal = 24.dp) - .heightIn(max = 250.dp) - .verticalScroll(rememberScrollState()) .onSizeChanged { onPeekHeightChange(with(localDensity) { it.height.toDp().coerceAtMost(250.dp) }) } @@ -64,7 +59,7 @@ fun InfoPanel( if (state.loading) CircularProgressIndicator( - modifier = Modifier.width(32.dp).align(Alignment.CenterEnd) + modifier = Modifier.size(32.dp).align(Alignment.TopEnd) ) } Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeContent)) diff --git a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/screens/MapScreen.kt b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/screens/MapScreen.kt index d4e27e6..085fb25 100644 --- a/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/screens/MapScreen.kt +++ b/composeApp/src/commonMain/kotlin/moe/lava/banksia/ui/screens/MapScreen.kt @@ -4,25 +4,17 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.add import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeContent -import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material3.BottomSheetDefaults -import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.SearchBarDefaults -import androidx.compose.material3.SheetValue -import androidx.compose.material3.rememberBottomSheetScaffoldState -import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -31,10 +23,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.backhandler.PredictiveBackHandler -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.icerock.moko.geo.compose.BindLocationTrackerEffect import dev.icerock.moko.geo.compose.LocationTrackerAccuracy @@ -42,17 +31,16 @@ import dev.icerock.moko.geo.compose.rememberLocationTrackerFactory import kotlinx.coroutines.launch import moe.lava.banksia.resources.Res import moe.lava.banksia.resources.my_location_24 +import moe.lava.banksia.ui.layout.AppBottomSheet import moe.lava.banksia.ui.layout.InfoPanel import moe.lava.banksia.ui.layout.Searcher +import moe.lava.banksia.ui.layout.SheetStateWrapper import moe.lava.banksia.ui.platform.BanksiaTheme import moe.lava.banksia.ui.platform.maps.Maps -import moe.lava.banksia.ui.platform.maps.getScreenHeight import moe.lava.banksia.ui.state.InfoPanelState import moe.lava.banksia.util.Point import org.jetbrains.compose.resources.painterResource import org.koin.compose.viewmodel.koinViewModel -import kotlin.coroutines.cancellation.CancellationException -import kotlin.math.roundToInt val MELBOURNE = Point(-37.8136, 144.9631) @@ -73,64 +61,19 @@ fun MapScreen( val mapState by viewModel.mapState.collectAsStateWithLifecycle() val searchState by viewModel.searchState.collectAsStateWithLifecycle() - val scaffoldState = rememberBottomSheetScaffoldState( - bottomSheetState = rememberStandardBottomSheetState( - initialValue = SheetValue.Hidden, - skipHiddenState = false - ) - ) - - val sheetState = scaffoldState.bottomSheetState - val extInsets = if ( - sheetState.currentValue != SheetValue.Hidden || - sheetState.targetValue != SheetValue.Hidden - ) { - val offset = runCatching { sheetState.requireOffset() } - val scaffoldOffset = offset.getOrDefault(0.0f).roundToInt() - (getScreenHeight() - scaffoldOffset - WindowInsets.safeDrawing.getBottom( - LocalDensity.current)).coerceAtLeast(0) - } else 0 + val sheetState = SheetStateWrapper.create() + var searchExpandedState by rememberSaveable { mutableStateOf(false) } LaunchedEffect(infoState) { - if (infoState !is InfoPanelState.None) - scope.launch { scaffoldState.bottomSheetState.partialExpand() } - else - scope.launch { scaffoldState.bottomSheetState.hide() } + if (infoState !is InfoPanelState.None) { + sheetState.peek() + } else { + sheetState.hide() + } } - var searchExpandedState by rememberSaveable { mutableStateOf(false) } - var sheetSwipeEnabled by rememberSaveable { mutableStateOf(true) } - var handleHeight by remember { mutableStateOf(0.dp) } - var peekHeight by remember { mutableStateOf(0.dp) } - var peekHeightMultiplier by remember { mutableFloatStateOf(1F) } - BanksiaTheme { - BottomSheetScaffold( - scaffoldState = scaffoldState, - sheetPeekHeight = (handleHeight + peekHeight) * peekHeightMultiplier, - modifier = Modifier.fillMaxSize(), - sheetContent = { - InfoPanel( - state = infoState, - onEvent = viewModel::handleEvent, - onPeekHeightChange = { peekHeight = it }, - ) - }, - sheetDragHandle = { - val density = LocalDensity.current - Box( - Modifier - .fillMaxWidth() - .padding(horizontal = 10.dp) - .onSizeChanged { - handleHeight = with(density) { it.height.toDp() } - } - ) { - BottomSheetDefaults.DragHandle(modifier = Modifier.align(Alignment.Center)) - } - }, - sheetSwipeEnabled = sheetSwipeEnabled, - ) { + Scaffold { Maps( modifier = Modifier.fillMaxSize(), state = mapState, @@ -138,7 +81,7 @@ fun MapScreen( cameraPositionFlow = viewModel.cameraChangeEmitter, extInsets = WindowInsets(top = with(LocalDensity.current) { SearchBarDefaults.InputFieldHeight.roundToPx() - }, bottom = extInsets), + }, bottom = sheetState.bottomInset), setLastKnownLocation = viewModel::setLastKnownLocation, ) Searcher( @@ -147,39 +90,16 @@ fun MapScreen( expanded = searchExpandedState, onExpandedChange = { searchExpandedState = it - if (it) - scope.launch { scaffoldState.bottomSheetState.hide() } + if (it) scope.launch { sheetState.hide() } }, ) - PredictiveBackHandler(scaffoldState.bottomSheetState.currentValue != SheetValue.Hidden) { progress -> - sheetSwipeEnabled = false - try { - progress.collect { backEvent -> - if (scaffoldState.bottomSheetState.currentValue == SheetValue.PartiallyExpanded) { - peekHeightMultiplier = 1F - backEvent.progress - } - } - if (scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded) - scope.launch { scaffoldState.bottomSheetState.partialExpand() } - else if (scaffoldState.bottomSheetState.currentValue == SheetValue.PartiallyExpanded) - scope.launch { - scaffoldState.bottomSheetState.hide() - peekHeightMultiplier = 1F - viewModel.handleEvent(MapScreenEvent.DismissState) - } - } catch (_: CancellationException) { - peekHeightMultiplier = 1F - } - sheetSwipeEnabled = true - } - Box( modifier = Modifier .fillMaxSize() .windowInsetsPadding( WindowInsets.safeContent.add( - WindowInsets(bottom = extInsets) + WindowInsets(bottom = sheetState.bottomInset) ) ), contentAlignment = Alignment.BottomEnd @@ -191,6 +111,17 @@ fun MapScreen( Icon(painterResource(Res.drawable.my_location_24), "Move to current location") } } + + AppBottomSheet( + sheetState = sheetState, + onDismiss = { viewModel.handleEvent(MapScreenEvent.DismissState) } + ) { + InfoPanel( + state = infoState, + onEvent = viewModel::handleEvent, + onPeekHeightChange = { ph -> sheetState.peekTo(ph) }, + ) + } } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 89da037..279cc3d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ androidx-lifecycle = "2.9.6" androidx-material = "1.12.0" androidx-test-junit = "1.2.1" compose-multiplatform = "1.9.3" +composeunstyled = "1.49.2" coroutines = "1.10.2" geo = "0.8.0" junit = "4.13.2" @@ -32,6 +33,7 @@ room = "2.8.4" secretsGradlePlugin = "2.0.1" [libraries] +composeunstyled = { module = "com.composables:composeunstyled", version.ref = "composeunstyled" } moko-geo = { module = "dev.icerock.moko:geo", version.ref = "geo" } moko-geo-compose = { module = "dev.icerock.moko:geo-compose", version.ref = "geo" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }