refactor(ui): use compose-unstyled bottomsheet

This commit is contained in:
Cilly Leang 2025-11-28 21:20:45 +11:00
parent d4425f5b12
commit 40cfef5f1e
Signed by: cilly
GPG key ID: 6500251E087653C9
5 changed files with 214 additions and 101 deletions

View file

@ -53,6 +53,7 @@ kotlin {
implementation(compose.ui) implementation(compose.ui)
implementation(compose.components.resources) implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview) implementation(compose.components.uiToolingPreview)
implementation(libs.composeunstyled)
implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)

View file

@ -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<Dp>,
private var p2: MutableState<Dp>,
private val peek1: SheetDetent,
private val peek2: SheetDetent,
) {
companion object {
private val saver = Saver<MutableState<Dp>, 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()
}
}
}

View file

@ -7,13 +7,10 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContent 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.layout.windowInsetsBottomHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -48,8 +45,6 @@ fun InfoPanel(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 24.dp) .padding(horizontal = 24.dp)
.heightIn(max = 250.dp)
.verticalScroll(rememberScrollState())
.onSizeChanged { .onSizeChanged {
onPeekHeightChange(with(localDensity) { it.height.toDp().coerceAtMost(250.dp) }) onPeekHeightChange(with(localDensity) { it.height.toDp().coerceAtMost(250.dp) })
} }
@ -64,7 +59,7 @@ fun InfoPanel(
if (state.loading) if (state.loading)
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.width(32.dp).align(Alignment.CenterEnd) modifier = Modifier.size(32.dp).align(Alignment.TopEnd)
) )
} }
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeContent)) Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeContent))

View file

@ -4,25 +4,17 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.add import androidx.compose.foundation.layout.add
import androidx.compose.foundation.layout.fillMaxSize 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.safeContent
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding 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.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBarDefaults 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -31,10 +23,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier 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.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.icerock.moko.geo.compose.BindLocationTrackerEffect import dev.icerock.moko.geo.compose.BindLocationTrackerEffect
import dev.icerock.moko.geo.compose.LocationTrackerAccuracy import dev.icerock.moko.geo.compose.LocationTrackerAccuracy
@ -42,17 +31,16 @@ import dev.icerock.moko.geo.compose.rememberLocationTrackerFactory
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import moe.lava.banksia.resources.Res import moe.lava.banksia.resources.Res
import moe.lava.banksia.resources.my_location_24 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.InfoPanel
import moe.lava.banksia.ui.layout.Searcher 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.BanksiaTheme
import moe.lava.banksia.ui.platform.maps.Maps 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.ui.state.InfoPanelState
import moe.lava.banksia.util.Point import moe.lava.banksia.util.Point
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.roundToInt
val MELBOURNE = Point(-37.8136, 144.9631) val MELBOURNE = Point(-37.8136, 144.9631)
@ -73,64 +61,19 @@ fun MapScreen(
val mapState by viewModel.mapState.collectAsStateWithLifecycle() val mapState by viewModel.mapState.collectAsStateWithLifecycle()
val searchState by viewModel.searchState.collectAsStateWithLifecycle() val searchState by viewModel.searchState.collectAsStateWithLifecycle()
val scaffoldState = rememberBottomSheetScaffoldState( val sheetState = SheetStateWrapper.create()
bottomSheetState = rememberStandardBottomSheetState( var searchExpandedState by rememberSaveable { mutableStateOf(false) }
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
LaunchedEffect(infoState) { LaunchedEffect(infoState) {
if (infoState !is InfoPanelState.None) if (infoState !is InfoPanelState.None) {
scope.launch { scaffoldState.bottomSheetState.partialExpand() } sheetState.peek()
else } else {
scope.launch { scaffoldState.bottomSheetState.hide() } 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 { BanksiaTheme {
BottomSheetScaffold( Scaffold {
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,
) {
Maps( Maps(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
state = mapState, state = mapState,
@ -138,7 +81,7 @@ fun MapScreen(
cameraPositionFlow = viewModel.cameraChangeEmitter, cameraPositionFlow = viewModel.cameraChangeEmitter,
extInsets = WindowInsets(top = with(LocalDensity.current) { extInsets = WindowInsets(top = with(LocalDensity.current) {
SearchBarDefaults.InputFieldHeight.roundToPx() SearchBarDefaults.InputFieldHeight.roundToPx()
}, bottom = extInsets), }, bottom = sheetState.bottomInset),
setLastKnownLocation = viewModel::setLastKnownLocation, setLastKnownLocation = viewModel::setLastKnownLocation,
) )
Searcher( Searcher(
@ -147,39 +90,16 @@ fun MapScreen(
expanded = searchExpandedState, expanded = searchExpandedState,
onExpandedChange = { onExpandedChange = {
searchExpandedState = it searchExpandedState = it
if (it) if (it) scope.launch { sheetState.hide() }
scope.launch { scaffoldState.bottomSheetState.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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.windowInsetsPadding( .windowInsetsPadding(
WindowInsets.safeContent.add( WindowInsets.safeContent.add(
WindowInsets(bottom = extInsets) WindowInsets(bottom = sheetState.bottomInset)
) )
), ),
contentAlignment = Alignment.BottomEnd contentAlignment = Alignment.BottomEnd
@ -191,6 +111,17 @@ fun MapScreen(
Icon(painterResource(Res.drawable.my_location_24), "Move to current location") 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) },
)
}
} }
} }
} }

View file

@ -12,6 +12,7 @@ androidx-lifecycle = "2.9.6"
androidx-material = "1.12.0" androidx-material = "1.12.0"
androidx-test-junit = "1.2.1" androidx-test-junit = "1.2.1"
compose-multiplatform = "1.9.3" compose-multiplatform = "1.9.3"
composeunstyled = "1.49.2"
coroutines = "1.10.2" coroutines = "1.10.2"
geo = "0.8.0" geo = "0.8.0"
junit = "4.13.2" junit = "4.13.2"
@ -32,6 +33,7 @@ room = "2.8.4"
secretsGradlePlugin = "2.0.1" secretsGradlePlugin = "2.0.1"
[libraries] [libraries]
composeunstyled = { module = "com.composables:composeunstyled", version.ref = "composeunstyled" }
moko-geo = { module = "dev.icerock.moko:geo", version.ref = "geo" } moko-geo = { module = "dev.icerock.moko:geo", version.ref = "geo" }
moko-geo-compose = { module = "dev.icerock.moko:geo-compose", 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" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }