Rework bottom sheets

This commit is contained in:
MM20 2023-08-15 20:56:18 +02:00
parent e502c18de8
commit 21f263b7d0
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389

View File

@ -3,8 +3,13 @@ package de.mm20.launcher2.ui.component
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.gestures.animateTo
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -15,11 +20,15 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeContent
import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.layout.windowInsetsBottomHeight
@ -56,11 +65,12 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.PopupProperties
import de.mm20.launcher2.ui.ktx.toDp import de.mm20.launcher2.ui.ktx.toDp
import de.mm20.launcher2.ui.ktx.toPixels import de.mm20.launcher2.ui.ktx.toPixels
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Composable @Composable
@ -74,21 +84,29 @@ fun BottomSheetDialog(
content: @Composable (paddingValues: PaddingValues) -> Unit, content: @Composable (paddingValues: PaddingValues) -> Unit,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val swipeState = remember {
SwipeableState( var isOpenAnimationFinished by remember { mutableStateOf(false) }
val draggableState = remember {
AnchoredDraggableState(
initialValue = SwipeState.Dismiss, initialValue = SwipeState.Dismiss,
confirmStateChange = { positionalThreshold = { it * 0.5f },
if (it == SwipeState.Dismiss) { velocityThreshold = { 200f },
if (dismissible()) { animationSpec = spring(),
onDismissRequest() confirmValueChange = {
} it != SwipeState.Dismiss || dismissible()
else return@SwipeableState false }
}
return@SwipeableState true
},
) )
} }
LaunchedEffect(draggableState.currentValue) {
if (isOpenAnimationFinished && draggableState.currentValue == SwipeState.Dismiss) {
onDismissRequest()
} else {
isOpenAnimationFinished = true
}
}
val nestedScrollConnection = remember { val nestedScrollConnection = remember {
object : NestedScrollConnection { object : NestedScrollConnection {
@ -96,7 +114,7 @@ fun BottomSheetDialog(
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.toFloat() val delta = available.toFloat()
return if (delta < 0 && source == NestedScrollSource.Drag) { return if (delta < 0 && source == NestedScrollSource.Drag) {
swipeState.performDrag(delta).toOffset() draggableState.dispatchRawDelta(delta).toOffset()
} else { } else {
Offset.Zero Offset.Zero
} }
@ -108,7 +126,7 @@ fun BottomSheetDialog(
source: NestedScrollSource source: NestedScrollSource
): Offset { ): Offset {
return if (source == NestedScrollSource.Drag) { return if (source == NestedScrollSource.Drag) {
swipeState.performDrag(available.toFloat()).toOffset() draggableState.dispatchRawDelta(available.toFloat()).toOffset()
} else { } else {
Offset.Zero Offset.Zero
} }
@ -116,8 +134,8 @@ fun BottomSheetDialog(
override suspend fun onPreFling(available: Velocity): Velocity { override suspend fun onPreFling(available: Velocity): Velocity {
val toFling = Offset(available.x, available.y).toFloat() val toFling = Offset(available.x, available.y).toFloat()
return if (toFling < 0 && swipeState.offset.value > 0) { return if (toFling < 0 && draggableState.offset > draggableState.anchors.minAnchor()) {
swipeState.performFling(velocity = toFling) draggableState.settle(velocity = toFling)
// since we go to the anchor with tween settling, consume all for the best UX // since we go to the anchor with tween settling, consume all for the best UX
available available
} else { } else {
@ -126,7 +144,7 @@ fun BottomSheetDialog(
} }
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
swipeState.performFling(velocity = Offset(available.x, available.y).toFloat()) draggableState.settle(velocity = Offset(available.x, available.y).toFloat())
return available return available
} }
@ -139,23 +157,25 @@ fun BottomSheetDialog(
CompositionLocalProvider( CompositionLocalProvider(
LocalAbsoluteTonalElevation provides 0.dp, LocalAbsoluteTonalElevation provides 0.dp,
) { ) {
Dialog( Popup(
properties = DialogProperties( properties = PopupProperties(
dismissOnBackPress = dismissible(), dismissOnBackPress = dismissible(),
dismissOnClickOutside = dismissible(), dismissOnClickOutside = dismissible(),
usePlatformDefaultWidth = false, usePlatformDefaultWidth = false,
decorFitsSystemWindows = false, focusable = true,
), ),
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
) { ) {
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.consumeWindowInsets(WindowInsets.systemBars),
propagateMinConstraints = true, propagateMinConstraints = true,
contentAlignment = Alignment.BottomCenter contentAlignment = Alignment.BottomCenter
) { ) {
val maxHeightPx = maxHeight.toPixels() val maxHeight = maxHeight
val scrimAlpha by animateFloatAsState( val scrimAlpha by animateFloatAsState(
if (swipeState.targetValue == SwipeState.Dismiss) 0f else 0.32f, if (draggableState.targetValue == SwipeState.Dismiss) 0f else 0.32f,
label = "Scrim alpha" label = "Scrim alpha"
) )
@ -166,7 +186,7 @@ fun BottomSheetDialog(
detectTapGestures { detectTapGestures {
if (dismissible()) { if (dismissible()) {
scope.launch { scope.launch {
swipeState.animateTo(SwipeState.Dismiss) draggableState.animateTo(SwipeState.Dismiss)
onDismissRequest() onDismissRequest()
} }
} }
@ -174,53 +194,60 @@ fun BottomSheetDialog(
} }
) )
Column( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.wrapContentHeight(Alignment.Bottom) .fillMaxHeight()
.clipToBounds(), .clipToBounds(),
verticalArrangement = Arrangement.Bottom contentAlignment = Alignment.TopCenter,
) { ) {
var height by remember { var sheetHeight by remember {
mutableStateOf(maxHeightPx) mutableStateOf(0f)
} }
LaunchedEffect(null) { val maxHeightPx = maxHeight.toPixels()
swipeState.animateTo(SwipeState.Peek) LaunchedEffect(maxHeightPx, sheetHeight) {
} val oldValue = draggableState.currentValue
val hasPeekAnchor = sheetHeight > 0f
val heightDp = height.toDp() val hasFullAnchor = sheetHeight > maxHeightPx * 0.5f
val peekHeight = (height - maxHeightPx / 2).coerceAtLeast(0f) // If the sheet was hidden, move it to peek. Otherwise, try to keep the previous state, if possible.
val anchors = mutableMapOf( val newValue = when {
peekHeight to SwipeState.Peek, oldValue == SwipeState.Dismiss && hasPeekAnchor -> SwipeState.Peek
height to SwipeState.Dismiss, oldValue == SwipeState.Peek && hasPeekAnchor -> SwipeState.Peek
).also { oldValue == SwipeState.Full && hasFullAnchor -> SwipeState.Full
if (peekHeight > 0f) { oldValue == SwipeState.Full && hasPeekAnchor -> SwipeState.Peek
it[0f] = SwipeState.Full else -> SwipeState.Dismiss
}
draggableState.updateAnchors(
DraggableAnchors {
SwipeState.Dismiss at 0f
if (hasPeekAnchor) SwipeState.Peek at -min(maxHeightPx * 0.5f, sheetHeight)
if (hasFullAnchor) SwipeState.Full at -min(maxHeightPx, sheetHeight)
},
)
if (newValue != oldValue) {
draggableState.animateTo(newValue)
} }
} }
Surface( Surface(
modifier = Modifier modifier = Modifier
.nestedScroll(nestedScrollConnection) .nestedScroll(nestedScrollConnection)
.onSizeChanged { .onSizeChanged {
height = it.height.toFloat() sheetHeight = it.height.toFloat()
} }
.offset { IntOffset(0, swipeState.offset.value.roundToInt()) } .offset {
.swipeable( IntOffset(0,
swipeState, maxHeightPx.toInt() +
anchors = anchors, (draggableState.offset
.takeIf { !it.isNaN() }
?.roundToInt() ?: 0)
)
}
.anchoredDraggable(
state = draggableState,
orientation = Orientation.Vertical, orientation = Orientation.Vertical,
thresholds = { _, to ->
if (to == SwipeState.Dismiss) {
FixedThreshold(heightDp - 48.dp)
} else {
FractionalThreshold(0.5f)
}
},
resistance = null,
) )
.fillMaxWidth() .fillMaxWidth(),
.weight(1f, false),
shape = MaterialTheme.shapes.extraLarge.copy( shape = MaterialTheme.shapes.extraLarge.copy(
bottomStart = CornerSize(0), bottomStart = CornerSize(0),
bottomEnd = CornerSize(0), bottomEnd = CornerSize(0),
@ -246,8 +273,12 @@ fun BottomSheetDialog(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.wrapContentHeight() .then(
.animateContentSize(), if (confirmButton != null || dismissButton != null) Modifier.padding(
bottom = 64.dp
) else Modifier
)
.wrapContentHeight(),
propagateMinConstraints = true, propagateMinConstraints = true,
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
@ -258,10 +289,20 @@ fun BottomSheetDialog(
} }
if (confirmButton != null || dismissButton != null) { if (confirmButton != null || dismissButton != null) {
val elevation by animateDpAsState(if (swipeState.offset.value == 0f) 0.dp else 1.dp) val elevation = 1.dp
val heightPx = -64.dp.toPixels()
Surface( Surface(
modifier = Modifier modifier = Modifier
.wrapContentHeight() .height(64.dp)
.offset {
IntOffset(
0,
maxHeightPx.toInt() +
(draggableState.offset
.takeIf { !it.isNaN() }
?.roundToInt() ?: 0).coerceAtLeast(heightPx.toInt())
)
}
.fillMaxWidth(), .fillMaxWidth(),
tonalElevation = elevation, tonalElevation = elevation,
) { ) {