Rework bottom sheets
This commit is contained in:
parent
e502c18de8
commit
21f263b7d0
@ -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,
|
||||||
) {
|
) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user