refactor(ui): use compose-unstyled bottomsheet
This commit is contained in:
parent
d4425f5b12
commit
40cfef5f1e
5 changed files with 214 additions and 101 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue