Skip to content

Instantly share code, notes, and snippets.

@Marlinski
Created October 23, 2022 21:48
Show Gist options
  • Star 14 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Marlinski/0b043968c2f574d70ee6060aeda54882 to your computer and use it in GitHub Desktop.
Save Marlinski/0b043968c2f574d70ee6060aeda54882 to your computer and use it in GitHub Desktop.
port of BottomSheetScaffold for Material3
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.swarmlab.beta.ui.screens.components.material3
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
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.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.collapse
import androidx.compose.ui.semantics.expand
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/**
* Possible values of [BottomSheetState].
*/
enum class BottomSheetValue {
/**
* The bottom sheet is visible, but only showing its peek height.
*/
Collapsed,
/**
* The bottom sheet is visible at its maximum height.
*/
Expanded
}
/**
* State of the persistent bottom sheet in [BottomSheetScaffold].
*
* @param initialValue The initial value of the state.
* @param animationSpec The default animation that will be used to animate to a new state.
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Stable
class BottomSheetState(
initialValue: BottomSheetValue,
animationSpec: AnimationSpec<Float> = SwipeableDefaults.AnimationSpec,
confirmStateChange: (BottomSheetValue) -> Boolean = { true }
) : SwipeableState<BottomSheetValue>(
initialValue = initialValue,
animationSpec = animationSpec,
confirmStateChange = confirmStateChange
) {
/**
* Whether the bottom sheet is expanded.
*/
val isExpanded: Boolean
get() = currentValue == BottomSheetValue.Expanded
/**
* Whether the bottom sheet is collapsed.
*/
val isCollapsed: Boolean
get() = currentValue == BottomSheetValue.Collapsed
/**
* Expand the bottom sheet with animation and suspend until it if fully expanded or animation
* has been cancelled. This method will throw [CancellationException] if the animation is
* interrupted
*
* @return the reason the expand animation ended
*/
suspend fun expand() = animateTo(BottomSheetValue.Expanded)
/**
* Collapse the bottom sheet with animation and suspend until it if fully collapsed or animation
* has been cancelled. This method will throw [CancellationException] if the animation is
* interrupted
*
* @return the reason the collapse animation ended
*/
suspend fun collapse() = animateTo(BottomSheetValue.Collapsed)
companion object {
/**
* The default [Saver] implementation for [BottomSheetState].
*/
fun Saver(
animationSpec: AnimationSpec<Float>,
confirmStateChange: (BottomSheetValue) -> Boolean
): Saver<BottomSheetState, *> = Saver(
save = { it.currentValue },
restore = {
BottomSheetState(
initialValue = it,
animationSpec = animationSpec,
confirmStateChange = confirmStateChange
)
}
)
}
internal val nestedScrollConnection = this.PreUpPostDownNestedScrollConnection
}
/**
* Create a [BottomSheetState] and [remember] it.
*
* @param initialValue The initial value of the state.
* @param animationSpec The default animation that will be used to animate to a new state.
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
*/
@Composable
fun rememberBottomSheetState(
initialValue: BottomSheetValue,
animationSpec: AnimationSpec<Float> = SwipeableDefaults.AnimationSpec,
confirmStateChange: (BottomSheetValue) -> Boolean = { true }
): BottomSheetState {
return rememberSaveable(
animationSpec,
saver = BottomSheetState.Saver(
animationSpec = animationSpec,
confirmStateChange = confirmStateChange
)
) {
BottomSheetState(
initialValue = initialValue,
animationSpec = animationSpec,
confirmStateChange = confirmStateChange
)
}
}
/**
* State of the [BottomSheetScaffold] composable.
*
* @param drawerState The state of the navigation drawer.
* @param bottomSheetState The state of the persistent bottom sheet.
* @param snackbarHostState The [SnackbarHostState] used to show snackbars inside the scaffold.
*/
@Stable
class BottomSheetScaffoldState(
val drawerState: DrawerState,
val bottomSheetState: BottomSheetState,
val snackbarHostState: SnackbarHostState
)
/**
* Create and [remember] a [BottomSheetScaffoldState].
*
* @param drawerState The state of the navigation drawer.
* @param bottomSheetState The state of the persistent bottom sheet.
* @param snackbarHostState The [SnackbarHostState] used to show snackbars inside the scaffold.
*/
@Composable
fun rememberBottomSheetScaffoldState(
drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed),
bottomSheetState: BottomSheetState = rememberBottomSheetState(BottomSheetValue.Collapsed),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
): BottomSheetScaffoldState {
return remember(drawerState, bottomSheetState, snackbarHostState) {
BottomSheetScaffoldState(
drawerState = drawerState,
bottomSheetState = bottomSheetState,
snackbarHostState = snackbarHostState
)
}
}
/**
* <a href="https://material.io/components/sheets-bottom#standard-bottom-sheet" class="external" target="_blank">Material Design standard bottom sheet</a>.
*
* Standard bottom sheets co-exist with the screen’s main UI region and allow for simultaneously
* viewing and interacting with both regions. They are commonly used to keep a feature or
* secondary content visible on screen when content in main UI region is frequently scrolled or
* panned.
*
* ![Standard bottom sheet image](https://developer.android.com/images/reference/androidx/compose/material/standard-bottom-sheet.png)
*
* This component provides an API to put together several material components to construct your
* screen. For a similar component which implements the basic material design layout strategy
* with app bars, floating action buttons and navigation drawers, use the standard [Scaffold].
* For similar component that uses a backdrop as the centerpiece of the screen, use
* [BackdropScaffold].
*
* A simple example of a bottom sheet scaffold looks like this:
*
* @sample androidx.compose.material.samples.BottomSheetScaffoldSample
*
* @param sheetContent The content of the bottom sheet.
* @param modifier An optional [Modifier] for the root of the scaffold.
* @param scaffoldState The state of the scaffold.
* @param topBar An optional top app bar.
* @param snackbarHost The composable hosting the snackbars shown inside the scaffold.
* @param floatingActionButton An optional floating action button.
* @param floatingActionButtonPosition The position of the floating action button.
* @param sheetGesturesEnabled Whether the bottom sheet can be interacted with by gestures.
* @param sheetShape The shape of the bottom sheet.
* @param sheetElevation The elevation of the bottom sheet.
* @param sheetBackgroundColor The background color of the bottom sheet.
* @param sheetContentColor The preferred content color provided by the bottom sheet to its
* children. Defaults to the matching content color for [sheetBackgroundColor], or if that is
* not a color from the theme, this will keep the same content color set above the bottom sheet.
* @param sheetPeekHeight The height of the bottom sheet when it is collapsed.
* @param drawerContent The content of the drawer sheet.
* @param drawerGesturesEnabled Whether the drawer sheet can be interacted with by gestures.
* @param drawerShape The shape of the drawer sheet.
* @param drawerElevation The elevation of the drawer sheet.
* @param drawerBackgroundColor The background color of the drawer sheet.
* @param drawerContentColor The preferred content color provided by the drawer sheet to its
* children. Defaults to the matching content color for [drawerBackgroundColor], or if that is
* not a color from the theme, this will keep the same content color set above the drawer sheet.
* @param drawerScrimColor The color of the scrim that is applied when the drawer is open.
* @param content The main content of the screen. You should use the provided [PaddingValues]
* to properly offset the content, so that it is not obstructed by the bottom sheet when collapsed.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BottomSheetScaffold(
sheetContent: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(),
topBar: (@Composable () -> Unit)? = null,
snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
floatingActionButton: (@Composable () -> Unit)? = null,
floatingActionButtonPosition: FabPosition = FabPosition.End,
sheetGesturesEnabled: Boolean = true,
sheetShape: Shape = MaterialTheme.shapes.large,
sheetElevation: Dp = BottomSheetScaffoldDefaults.SheetElevation,
sheetBackgroundColor: Color = MaterialTheme.colorScheme.surface,
sheetContentColor: Color = contentColorFor(sheetBackgroundColor),
sheetPeekHeight: Dp = BottomSheetScaffoldDefaults.SheetPeekHeight,
drawerContent: @Composable (ColumnScope.() -> Unit)? = null,
drawerGesturesEnabled: Boolean = true,
drawerShape: Shape = MaterialTheme.shapes.large,
drawerElevation: Dp = DrawerDefaults.Elevation,
drawerBackgroundColor: Color = MaterialTheme.colorScheme.surface,
drawerContentColor: Color = contentColorFor(drawerBackgroundColor),
drawerScrimColor: Color = DrawerDefaults.scrimColor,
backgroundColor: Color = MaterialTheme.colorScheme.background,
contentColor: Color = contentColorFor(backgroundColor),
content: @Composable (PaddingValues) -> Unit
) {
val scope = rememberCoroutineScope()
BoxWithConstraints(modifier) {
val fullHeight = constraints.maxHeight.toFloat()
val peekHeightPx = with(LocalDensity.current) { sheetPeekHeight.toPx() }
var bottomSheetHeight by remember { mutableStateOf(fullHeight) }
val swipeable = Modifier
.nestedScroll(scaffoldState.bottomSheetState.nestedScrollConnection)
.swipeable(
state = scaffoldState.bottomSheetState,
anchors = mapOf(
fullHeight - peekHeightPx to BottomSheetValue.Collapsed,
fullHeight - bottomSheetHeight to BottomSheetValue.Expanded
),
orientation = Orientation.Vertical,
enabled = sheetGesturesEnabled,
resistance = null
)
.semantics {
if (peekHeightPx != bottomSheetHeight) {
if (scaffoldState.bottomSheetState.isCollapsed) {
expand {
if (scaffoldState.bottomSheetState.confirmStateChange(BottomSheetValue.Expanded)) {
scope.launch { scaffoldState.bottomSheetState.expand() }
}
true
}
} else {
collapse {
if (scaffoldState.bottomSheetState.confirmStateChange(BottomSheetValue.Collapsed)) {
scope.launch { scaffoldState.bottomSheetState.collapse() }
}
true
}
}
}
}
val child = @Composable {
BottomSheetScaffoldStack(
body = {
Surface(
color = backgroundColor,
contentColor = contentColor
) {
Column(Modifier.fillMaxSize()) {
topBar?.invoke()
content(PaddingValues(bottom = sheetPeekHeight))
}
}
},
bottomSheet = {
Surface(
swipeable
.fillMaxWidth()
.requiredHeightIn(min = sheetPeekHeight)
.onGloballyPositioned {
bottomSheetHeight = it.size.height.toFloat()
},
shape = sheetShape,
// parameter does not exists in material3
// elevation = sheetElevation,
color = sheetBackgroundColor,
contentColor = sheetContentColor,
content = { Column(content = sheetContent) }
)
},
floatingActionButton = {
Box {
floatingActionButton?.invoke()
}
},
snackbarHost = {
Box {
snackbarHost(scaffoldState.snackbarHostState)
}
},
bottomSheetOffset = scaffoldState.bottomSheetState.offset,
floatingActionButtonPosition = floatingActionButtonPosition
)
}
if (drawerContent == null) {
child()
} else {
ModalDrawer(
drawerContent = drawerContent,
drawerState = scaffoldState.drawerState,
gesturesEnabled = drawerGesturesEnabled,
drawerShape = drawerShape,
drawerElevation = drawerElevation,
drawerBackgroundColor = drawerBackgroundColor,
drawerContentColor = drawerContentColor,
scrimColor = drawerScrimColor,
content = child
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BottomSheetScaffoldStack(
body: @Composable () -> Unit,
bottomSheet: @Composable () -> Unit,
floatingActionButton: @Composable () -> Unit,
snackbarHost: @Composable () -> Unit,
bottomSheetOffset: State<Float>,
floatingActionButtonPosition: FabPosition
) {
Layout(
content = {
body()
bottomSheet()
floatingActionButton()
snackbarHost()
}
) { measurables, constraints ->
val placeable = measurables.first().measure(constraints)
layout(placeable.width, placeable.height) {
placeable.placeRelative(0, 0)
val (sheetPlaceable, fabPlaceable, snackbarPlaceable) =
measurables.drop(1).map {
it.measure(constraints.copy(minWidth = 0, minHeight = 0))
}
val sheetOffsetY = bottomSheetOffset.value.roundToInt()
sheetPlaceable.placeRelative(0, sheetOffsetY)
val fabOffsetX = when (floatingActionButtonPosition) {
FabPosition.Center -> (placeable.width - fabPlaceable.width) / 2
else -> placeable.width - fabPlaceable.width - FabEndSpacing.roundToPx()
}
val fabOffsetY = sheetOffsetY - fabPlaceable.height / 2
fabPlaceable.placeRelative(fabOffsetX, fabOffsetY)
val snackbarOffsetX = (placeable.width - snackbarPlaceable.width) / 2
val snackbarOffsetY = placeable.height - snackbarPlaceable.height
snackbarPlaceable.placeRelative(snackbarOffsetX, snackbarOffsetY)
}
}
}
private val FabEndSpacing = 16.dp
/**
* Contains useful defaults for [BottomSheetScaffold].
*/
object BottomSheetScaffoldDefaults {
/**
* The default elevation used by [BottomSheetScaffold].
*/
val SheetElevation = 8.dp
/**
* The default peek height used by [BottomSheetScaffold].
*/
val SheetPeekHeight = 56.dp
}
/*
* Copyright 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.swarmlab.beta.ui.screens.components.material3
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
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.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.dismiss
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.paneTitle
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import kotlin.math.max
import kotlin.math.roundToInt
/**
* Possible values of [DrawerState].
*/
enum class DrawerValue {
/**
* The state of the drawer when it is closed.
*/
Closed,
/**
* The state of the drawer when it is open.
*/
Open
}
/**
* Possible values of [BottomDrawerState].
*/
enum class BottomDrawerValue {
/**
* The state of the bottom drawer when it is closed.
*/
Closed,
/**
* The state of the bottom drawer when it is open (i.e. at 50% height).
*/
Open,
/**
* The state of the bottom drawer when it is expanded (i.e. at 100% height).
*/
Expanded
}
/**
* State of the [ModalDrawer] composable.
*
* @param initialValue The initial value of the state.
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("NotCloseable")
@Stable
class DrawerState(
initialValue: DrawerValue,
confirmStateChange: (DrawerValue) -> Boolean = { true }
) {
internal val swipeableState = SwipeableState(
initialValue = initialValue,
animationSpec = AnimationSpec,
confirmStateChange = confirmStateChange
)
/**
* Whether the drawer is open.
*/
val isOpen: Boolean
get() = currentValue == DrawerValue.Open
/**
* Whether the drawer is closed.
*/
val isClosed: Boolean
get() = currentValue == DrawerValue.Closed
/**
* The current value of the state.
*
* If no swipe or animation is in progress, this corresponds to the start the drawer
* currently in. If a swipe or an animation is in progress, this corresponds the state drawer
* was in before the swipe or animation started.
*/
val currentValue: DrawerValue
get() {
return swipeableState.currentValue
}
/**
* Whether the state is currently animating.
*/
val isAnimationRunning: Boolean
get() {
return swipeableState.isAnimationRunning
}
/**
* Open the drawer with animation and suspend until it if fully opened or animation has been
* cancelled. This method will throw [CancellationException] if the animation is
* interrupted
*
* @return the reason the open animation ended
*/
suspend fun open() = animateTo(DrawerValue.Open, AnimationSpec)
/**
* Close the drawer with animation and suspend until it if fully closed or animation has been
* cancelled. This method will throw [CancellationException] if the animation is
* interrupted
*
* @return the reason the close animation ended
*/
suspend fun close() = animateTo(DrawerValue.Closed, AnimationSpec)
/**
* Set the state of the drawer with specific animation
*
* @param targetValue The new value to animate to.
* @param anim The animation that will be used to animate to the new value.
*/
suspend fun animateTo(targetValue: DrawerValue, anim: AnimationSpec<Float>) {
swipeableState.animateTo(targetValue, anim)
}
/**
* Set the state without any animation and suspend until it's set
*
* @param targetValue The new target value
*/
suspend fun snapTo(targetValue: DrawerValue) {
swipeableState.snapTo(targetValue)
}
/**
* The target value of the drawer state.
*
* If a swipe is in progress, this is the value that the Drawer would animate to if the
* swipe finishes. If an animation is running, this is the target value of that animation.
* Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
*/
@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
val targetValue: DrawerValue
get() = swipeableState.targetValue
/**
* The current position (in pixels) of the drawer sheet.
*/
@Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
val offset: State<Float>
get() = swipeableState.offset
companion object {
/**
* The default [Saver] implementation for [DrawerState].
*/
fun Saver(confirmStateChange: (DrawerValue) -> Boolean) =
Saver<DrawerState, DrawerValue>(
save = { it.currentValue },
restore = { DrawerState(it, confirmStateChange) }
)
}
}
/**
* State of the [BottomDrawer] composable.
*
* @param initialValue The initial value of the state.
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("NotCloseable")
class BottomDrawerState(
initialValue: BottomDrawerValue,
confirmStateChange: (BottomDrawerValue) -> Boolean = { true }
) : SwipeableState<BottomDrawerValue>(
initialValue = initialValue,
animationSpec = AnimationSpec,
confirmStateChange = confirmStateChange
) {
/**
* Whether the drawer is open, either in opened or expanded state.
*/
val isOpen: Boolean
get() = currentValue != BottomDrawerValue.Closed
/**
* Whether the drawer is closed.
*/
val isClosed: Boolean
get() = currentValue == BottomDrawerValue.Closed
/**
* Whether the drawer is expanded.
*/
val isExpanded: Boolean
get() = currentValue == BottomDrawerValue.Expanded
/**
* Open the drawer with animation and suspend until it if fully opened or animation has been
* cancelled. If the content height is less than [BottomDrawerOpenFraction], the drawer state
* will move to [BottomDrawerValue.Expanded] instead.
*
* @throws [CancellationException] if the animation is interrupted
*
*/
suspend fun open() {
val targetValue =
if (isOpenEnabled) BottomDrawerValue.Open else BottomDrawerValue.Expanded
animateTo(targetValue)
}
/**
* Close the drawer with animation and suspend until it if fully closed or animation has been
* cancelled.
*
* @throws [CancellationException] if the animation is interrupted
*
*/
suspend fun close() = animateTo(BottomDrawerValue.Closed)
/**
* Expand the drawer with animation and suspend until it if fully expanded or animation has
* been cancelled.
*
* @throws [CancellationException] if the animation is interrupted
*
*/
suspend fun expand() = animateTo(BottomDrawerValue.Expanded)
private val isOpenEnabled: Boolean
get() = anchors.values.contains(BottomDrawerValue.Open)
internal val nestedScrollConnection = this.PreUpPostDownNestedScrollConnection
companion object {
/**
* The default [Saver] implementation for [BottomDrawerState].
*/
fun Saver(confirmStateChange: (BottomDrawerValue) -> Boolean) =
Saver<BottomDrawerState, BottomDrawerValue>(
save = { it.currentValue },
restore = { BottomDrawerState(it, confirmStateChange) }
)
}
}
/**
* Create and [remember] a [DrawerState].
*
* @param initialValue The initial value of the state.
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
*/
@Composable
fun rememberDrawerState(
initialValue: DrawerValue,
confirmStateChange: (DrawerValue) -> Boolean = { true }
): DrawerState {
return rememberSaveable(saver = DrawerState.Saver(confirmStateChange)) {
DrawerState(initialValue, confirmStateChange)
}
}
/**
* Create and [remember] a [BottomDrawerState].
*
* @param initialValue The initial value of the state.
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
*/
@Composable
fun rememberBottomDrawerState(
initialValue: BottomDrawerValue,
confirmStateChange: (BottomDrawerValue) -> Boolean = { true }
): BottomDrawerState {
return rememberSaveable(saver = BottomDrawerState.Saver(confirmStateChange)) {
BottomDrawerState(initialValue, confirmStateChange)
}
}
/**
* <a href="https://material.io/components/navigation-drawer#modal-drawer" class="external" target="_blank">Material Design modal navigation drawer</a>.
*
* Modal navigation drawers block interaction with the rest of an app’s content with a scrim.
* They are elevated above most of the app’s UI and don’t affect the screen’s layout grid.
*
* ![Modal drawer image](https://developer.android.com/images/reference/androidx/compose/material/modal-drawer.png)
*
* See [BottomDrawer] for a layout that introduces a bottom drawer, suitable when
* using bottom navigation.
*
* @sample androidx.compose.material.samples.ModalDrawerSample
*
* @param drawerContent composable that represents content inside the drawer
* @param modifier optional modifier for the drawer
* @param drawerState state of the drawer
* @param gesturesEnabled whether or not drawer can be interacted by gestures
* @param drawerShape shape of the drawer sheet
* @param drawerElevation drawer sheet elevation. This controls the size of the shadow below the
* drawer sheet
* @param drawerBackgroundColor background color to be used for the drawer sheet
* @param drawerContentColor color of the content to use inside the drawer sheet. Defaults to
* either the matching content color for [drawerBackgroundColor], or, if it is not a color from
* the theme, this will keep the same value set above this Surface.
* @param scrimColor color of the scrim that obscures content when the drawer is open
* @param content content of the rest of the UI
*
* @throws IllegalStateException when parent has [Float.POSITIVE_INFINITY] width
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ModalDrawer(
drawerContent: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed),
gesturesEnabled: Boolean = true,
drawerShape: Shape = MaterialTheme.shapes.large,
drawerElevation: Dp = DrawerDefaults.Elevation,
drawerBackgroundColor: Color = MaterialTheme.colorScheme.surface,
drawerContentColor: Color = contentColorFor(drawerBackgroundColor),
scrimColor: Color = DrawerDefaults.scrimColor,
content: @Composable () -> Unit
) {
val scope = rememberCoroutineScope()
BoxWithConstraints(modifier.fillMaxSize()) {
val modalDrawerConstraints = constraints
// TODO : think about Infinite max bounds case
if (!modalDrawerConstraints.hasBoundedWidth) {
throw IllegalStateException("Drawer shouldn't have infinite width")
}
val minValue = -modalDrawerConstraints.maxWidth.toFloat()
val maxValue = 0f
val anchors = mapOf(minValue to DrawerValue.Closed, maxValue to DrawerValue.Open)
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
Box(
Modifier.swipeable(
state = drawerState.swipeableState,
anchors = anchors,
thresholds = { _, _ -> FractionalThreshold(0.5f) },
orientation = Orientation.Horizontal,
enabled = gesturesEnabled,
reverseDirection = isRtl,
velocityThreshold = DrawerVelocityThreshold,
resistance = null
)
) {
Box {
content()
}
Scrim(
open = drawerState.isOpen,
onClose = {
if (
gesturesEnabled &&
drawerState.swipeableState.confirmStateChange(DrawerValue.Closed)
) {
scope.launch { drawerState.close() }
}
},
fraction = {
calculateFraction(minValue, maxValue, drawerState.offset.value)
},
color = scrimColor
)
val navigationMenu = getString(Strings.NavigationMenu)
Surface(
modifier = with(LocalDensity.current) {
Modifier
.sizeIn(
minWidth = modalDrawerConstraints.minWidth.toDp(),
minHeight = modalDrawerConstraints.minHeight.toDp(),
maxWidth = modalDrawerConstraints.maxWidth.toDp(),
maxHeight = modalDrawerConstraints.maxHeight.toDp()
)
}
.offset { IntOffset(drawerState.offset.value.roundToInt(), 0) }
.padding(end = EndDrawerPadding)
.semantics {
paneTitle = navigationMenu
if (drawerState.isOpen) {
dismiss {
if (
drawerState.swipeableState
.confirmStateChange(DrawerValue.Closed)
) {
scope.launch { drawerState.close() }
}; true
}
}
},
shape = drawerShape,
color = drawerBackgroundColor,
contentColor = drawerContentColor,
// does not exist in material3
// elevation = drawerElevation
) {
Column(Modifier.fillMaxSize(), content = drawerContent)
}
}
}
}
/**
* <a href="https://material.io/components/navigation-drawer#bottom-drawer" class="external" target="_blank">Material Design bottom navigation drawer</a>.
*
* Bottom navigation drawers are modal drawers that are anchored to the bottom of the screen instead
* of the left or right edge. They are only used with bottom app bars.
*
* ![Bottom drawer image](https://developer.android.com/images/reference/androidx/compose/material/bottom-drawer.png)
*
* See [ModalDrawer] for a layout that introduces a classic from-the-side drawer.
*
* @sample androidx.compose.material.samples.BottomDrawerSample
*
* @param drawerState state of the drawer
* @param modifier optional [Modifier] for the entire component
* @param gesturesEnabled whether or not drawer can be interacted by gestures
* @param drawerShape shape of the drawer sheet
* @param drawerElevation drawer sheet elevation. This controls the size of the shadow below the
* drawer sheet
* @param drawerContent composable that represents content inside the drawer
* @param drawerBackgroundColor background color to be used for the drawer sheet
* @param drawerContentColor color of the content to use inside the drawer sheet. Defaults to
* either the matching content color for [drawerBackgroundColor], or, if it is not a color from
* the theme, this will keep the same value set above this Surface.
* @param scrimColor color of the scrim that obscures content when the drawer is open. If the
* color passed is [Color.Unspecified], then a scrim will no longer be applied and the bottom
* drawer will not block interaction with the rest of the screen when visible.
* @param content content of the rest of the UI
*
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BottomDrawer(
drawerContent: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
drawerState: BottomDrawerState = rememberBottomDrawerState(BottomDrawerValue.Closed),
gesturesEnabled: Boolean = true,
drawerShape: Shape = MaterialTheme.shapes.large,
drawerElevation: Dp = DrawerDefaults.Elevation,
drawerBackgroundColor: Color = MaterialTheme.colorScheme.surface,
drawerContentColor: Color = contentColorFor(drawerBackgroundColor),
scrimColor: Color = DrawerDefaults.scrimColor,
content: @Composable () -> Unit
) {
val scope = rememberCoroutineScope()
BoxWithConstraints(modifier.fillMaxSize()) {
val fullHeight = constraints.maxHeight.toFloat()
var drawerHeight by remember(fullHeight) { mutableStateOf(fullHeight) }
// TODO(b/178630869) Proper landscape support
val isLandscape = constraints.maxWidth > constraints.maxHeight
val minHeight = 0f
val peekHeight = fullHeight * BottomDrawerOpenFraction
val expandedHeight = max(minHeight, fullHeight - drawerHeight)
val anchors = if (drawerHeight < peekHeight || isLandscape) {
mapOf(
fullHeight to BottomDrawerValue.Closed,
expandedHeight to BottomDrawerValue.Expanded
)
} else {
mapOf(
fullHeight to BottomDrawerValue.Closed,
peekHeight to BottomDrawerValue.Open,
expandedHeight to BottomDrawerValue.Expanded
)
}
val drawerConstraints = with(LocalDensity.current) {
Modifier
.sizeIn(
maxWidth = constraints.maxWidth.toDp(),
maxHeight = constraints.maxHeight.toDp()
)
}
val nestedScroll = if (gesturesEnabled) {
Modifier.nestedScroll(drawerState.nestedScrollConnection)
} else {
Modifier
}
val swipeable = Modifier
.then(nestedScroll)
.swipeable(
state = drawerState,
anchors = anchors,
orientation = Orientation.Vertical,
enabled = gesturesEnabled,
resistance = null
)
Box(swipeable) {
content()
BottomDrawerScrim(
color = scrimColor,
onDismiss = {
if (
gesturesEnabled && drawerState.confirmStateChange(BottomDrawerValue.Closed)
) {
scope.launch { drawerState.close() }
}
},
visible = drawerState.targetValue != BottomDrawerValue.Closed
)
val navigationMenu = getString(Strings.NavigationMenu)
Surface(
drawerConstraints
.offset { IntOffset(x = 0, y = drawerState.offset.value.roundToInt()) }
.onGloballyPositioned { position ->
drawerHeight = position.size.height.toFloat()
}
.semantics {
paneTitle = navigationMenu
if (drawerState.isOpen) {
// TODO(b/180101663) The action currently doesn't return the correct results
dismiss {
if (drawerState.confirmStateChange(BottomDrawerValue.Closed)) {
scope.launch { drawerState.close() }
}; true
}
}
},
shape = drawerShape,
color = drawerBackgroundColor,
contentColor = drawerContentColor
// parameter does not exist in material3
// elevation = drawerElevation
) {
Column(content = drawerContent)
}
}
}
}
/**
* Object to hold default values for [ModalDrawer] and [BottomDrawer]
*/
object DrawerDefaults {
/**
* Default Elevation for drawer sheet as specified in material specs
*/
val Elevation = 16.dp
val scrimColor: Color
@Composable
get() = MaterialTheme.colorScheme.onSurface.copy(alpha = ScrimOpacity)
/**
* Default alpha for scrim color
*/
const val ScrimOpacity = 0.32f
}
private fun calculateFraction(a: Float, b: Float, pos: Float) =
((pos - a) / (b - a)).coerceIn(0f, 1f)
@Composable
private fun BottomDrawerScrim(
color: Color,
onDismiss: () -> Unit,
visible: Boolean
) {
if (color.isSpecified) {
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = TweenSpec()
)
val closeDrawer = getString(Strings.CloseDrawer)
val dismissModifier = if (visible) {
Modifier
.pointerInput(onDismiss) {
detectTapGestures { onDismiss() }
}
.semantics(mergeDescendants = true) {
contentDescription = closeDrawer
onClick { onDismiss(); true }
}
} else {
Modifier
}
Canvas(
Modifier
.fillMaxSize()
.then(dismissModifier)
) {
drawRect(color = color, alpha = alpha)
}
}
}
@Composable
private fun Scrim(
open: Boolean,
onClose: () -> Unit,
fraction: () -> Float,
color: Color
) {
val closeDrawer = getString(Strings.CloseDrawer)
val dismissDrawer = if (open) {
Modifier
.pointerInput(onClose) { detectTapGestures { onClose() } }
.semantics(mergeDescendants = true) {
contentDescription = closeDrawer
onClick { onClose(); true }
}
} else {
Modifier
}
Canvas(
Modifier
.fillMaxSize()
.then(dismissDrawer)
) {
drawRect(color, alpha = fraction())
}
}
private val EndDrawerPadding = 56.dp
private val DrawerVelocityThreshold = 400.dp
// TODO: b/177571613 this should be a proper decay settling
// this is taken from the DrawerLayout's DragViewHelper as a min duration.
private val AnimationSpec = TweenSpec<Float>(durationMillis = 256)
private const val BottomDrawerOpenFraction = 0.5f
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.swarmlab.beta.ui.screens.components.material3
import androidx.compose.runtime.Composable
import androidx.compose.ui.R
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.runtime.Immutable
@Immutable
@kotlin.jvm.JvmInline
value class Strings private constructor(@Suppress("unused") private val value: Int) {
companion object {
val NavigationMenu = Strings(0)
val CloseDrawer = Strings(1)
val CloseSheet = Strings(2)
val DefaultErrorMessage = Strings(3)
val ExposedDropdownMenu = Strings(4)
val SliderRangeStart = Strings(5)
val SliderRangeEnd = Strings(6)
}
}
@Composable
fun getString(string: Strings): String {
LocalConfiguration.current
val resources = LocalContext.current.resources
return when (string) {
Strings.NavigationMenu -> resources.getString(R.string.navigation_menu)
Strings.CloseDrawer -> resources.getString(R.string.close_drawer)
Strings.CloseSheet -> resources.getString(R.string.close_sheet)
Strings.DefaultErrorMessage -> resources.getString(R.string.default_error_message)
Strings.ExposedDropdownMenu -> resources.getString(R.string.dropdown_menu)
Strings.SliderRangeStart -> resources.getString(R.string.range_start)
Strings.SliderRangeEnd -> resources.getString(R.string.range_end)
else -> ""
}
}
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package fr.swarmlab.beta.ui.screens.components.material3
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.SpringSpec
import androidx.compose.foundation.gestures.DraggableState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.ExperimentalMaterial3Api
import fr.swarmlab.beta.ui.screens.components.material3.SwipeableDefaults.AnimationSpec
import fr.swarmlab.beta.ui.screens.components.material3.SwipeableDefaults.StandardResistanceFactor
import fr.swarmlab.beta.ui.screens.components.material3.SwipeableDefaults.VelocityThreshold
import fr.swarmlab.beta.ui.screens.components.material3.SwipeableDefaults.resistanceConfig
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sign
import kotlin.math.sin
/**
* State of the [swipeable] modifier.
*
* This contains necessary information about any ongoing swipe or animation and provides methods
* to change the state either immediately or by starting an animation. To create and remember a
* [SwipeableState] with the default animation clock, use [rememberSwipeableState].
*
* @param initialValue The initial value of the state.
* @param animationSpec The default animation that will be used to animate to a new state.
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
*/
@Stable
@ExperimentalMaterial3Api
open class SwipeableState<T>(
initialValue: T,
internal val animationSpec: AnimationSpec<Float> = AnimationSpec,
internal val confirmStateChange: (newValue: T) -> Boolean = { true }
) {
/**
* The current value of the state.
*
* If no swipe or animation is in progress, this corresponds to the anchor at which the
* [swipeable] is currently settled. If a swipe or animation is in progress, this corresponds
* the last anchor at which the [swipeable] was settled before the swipe or animation started.
*/
var currentValue: T by mutableStateOf(initialValue)
private set
/**
* Whether the state is currently animating.
*/
var isAnimationRunning: Boolean by mutableStateOf(false)
private set
/**
* The current position (in pixels) of the [swipeable].
*
* You should use this state to offset your content accordingly. The recommended way is to
* use `Modifier.offsetPx`. This includes the resistance by default, if resistance is enabled.
*/
val offset: State<Float> get() = offsetState
/**
* The amount by which the [swipeable] has been swiped past its bounds.
*/
val overflow: State<Float> get() = overflowState
// Use `Float.NaN` as a placeholder while the state is uninitialised.
private val offsetState = mutableStateOf(0f)
private val overflowState = mutableStateOf(0f)
// the source of truth for the "real"(non ui) position
// basically position in bounds + overflow
private val absoluteOffset = mutableStateOf(0f)
// current animation target, if animating, otherwise null
private val animationTarget = mutableStateOf<Float?>(null)
internal var anchors by mutableStateOf(emptyMap<Float, T>())
private val latestNonEmptyAnchorsFlow: Flow<Map<Float, T>> =
snapshotFlow { anchors }
.filter { it.isNotEmpty() }
.take(1)
internal var minBound = Float.NEGATIVE_INFINITY
internal var maxBound = Float.POSITIVE_INFINITY
internal fun ensureInit(newAnchors: Map<Float, T>) {
if (anchors.isEmpty()) {
// need to do initial synchronization synchronously :(
val initialOffset = newAnchors.getOffset(currentValue)
requireNotNull(initialOffset) {
"The initial value must have an associated anchor."
}
offsetState.value = initialOffset
absoluteOffset.value = initialOffset
}
}
internal suspend fun processNewAnchors(
oldAnchors: Map<Float, T>,
newAnchors: Map<Float, T>
) {
if (oldAnchors.isEmpty()) {
// If this is the first time that we receive anchors, then we need to initialise
// the state so we snap to the offset associated to the initial value.
minBound = newAnchors.keys.minOrNull()!!
maxBound = newAnchors.keys.maxOrNull()!!
val initialOffset = newAnchors.getOffset(currentValue)
requireNotNull(initialOffset) {
"The initial value must have an associated anchor."
}
snapInternalToOffset(initialOffset)
} else if (newAnchors != oldAnchors) {
// If we have received new anchors, then the offset of the current value might
// have changed, so we need to animate to the new offset. If the current value
// has been removed from the anchors then we animate to the closest anchor
// instead. Note that this stops any ongoing animation.
minBound = Float.NEGATIVE_INFINITY
maxBound = Float.POSITIVE_INFINITY
val animationTargetValue = animationTarget.value
// if we're in the animation already, let's find it a new home
val targetOffset = if (animationTargetValue != null) {
// first, try to map old state to the new state
val oldState = oldAnchors[animationTargetValue]
val newState = newAnchors.getOffset(oldState)
// return new state if exists, or find the closes one among new anchors
newState ?: newAnchors.keys.minByOrNull { abs(it - animationTargetValue) }!!
} else {
// we're not animating, proceed by finding the new anchors for an old value
val actualOldValue = oldAnchors[offset.value]
val value = if (actualOldValue == currentValue) currentValue else actualOldValue
newAnchors.getOffset(value) ?: newAnchors
.keys.minByOrNull { abs(it - offset.value) }!!
}
try {
animateInternalToOffset(targetOffset, animationSpec)
} catch (c: CancellationException) {
// If the animation was interrupted for any reason, snap as a last resort.
snapInternalToOffset(targetOffset)
} finally {
currentValue = newAnchors.getValue(targetOffset)
minBound = newAnchors.keys.minOrNull()!!
maxBound = newAnchors.keys.maxOrNull()!!
}
}
}
internal var thresholds: (Float, Float) -> Float by mutableStateOf({ _, _ -> 0f })
internal var velocityThreshold by mutableStateOf(0f)
internal var resistance: ResistanceConfig? by mutableStateOf(null)
internal val draggableState = DraggableState {
val newAbsolute = absoluteOffset.value + it
val clamped = newAbsolute.coerceIn(minBound, maxBound)
val overflow = newAbsolute - clamped
val resistanceDelta = resistance?.computeResistance(overflow) ?: 0f
offsetState.value = clamped + resistanceDelta
overflowState.value = overflow
absoluteOffset.value = newAbsolute
}
private suspend fun snapInternalToOffset(target: Float) {
draggableState.drag {
dragBy(target - absoluteOffset.value)
}
}
private suspend fun animateInternalToOffset(target: Float, spec: AnimationSpec<Float>) {
draggableState.drag {
var prevValue = absoluteOffset.value
animationTarget.value = target
isAnimationRunning = true
try {
Animatable(prevValue).animateTo(target, spec) {
dragBy(this.value - prevValue)
prevValue = this.value
}
} finally {
animationTarget.value = null
isAnimationRunning = false
}
}
}
/**
* The target value of the state.
*
* If a swipe is in progress, this is the value that the [swipeable] would animate to if the
* swipe finished. If an animation is running, this is the target value of that animation.
* Finally, if no swipe or animation is in progress, this is the same as the [currentValue].
*/
@ExperimentalMaterial3Api
internal val targetValue: T
get() {
// TODO(calintat): Track current velocity (b/149549482) and use that here.
val target = animationTarget.value ?: computeTarget(
offset = offset.value,
lastValue = anchors.getOffset(currentValue) ?: offset.value,
anchors = anchors.keys,
thresholds = thresholds,
velocity = 0f,
velocityThreshold = Float.POSITIVE_INFINITY
)
return anchors[target] ?: currentValue
}
/**
* Information about the ongoing swipe or animation, if any. See [SwipeProgress] for details.
*
* If no swipe or animation is in progress, this returns `SwipeProgress(value, value, 1f)`.
*/
@ExperimentalMaterial3Api
internal val progress: SwipeProgress<T>
get() {
val bounds = findBounds(offset.value, anchors.keys)
val from: T
val to: T
val fraction: Float
when (bounds.size) {
0 -> {
from = currentValue
to = currentValue
fraction = 1f
}
1 -> {
from = anchors.getValue(bounds[0])
to = anchors.getValue(bounds[0])
fraction = 1f
}
else -> {
val (a, b) =
if (direction > 0f) {
bounds[0] to bounds[1]
} else {
bounds[1] to bounds[0]
}
from = anchors.getValue(a)
to = anchors.getValue(b)
fraction = (offset.value - a) / (b - a)
}
}
return SwipeProgress(from, to, fraction)
}
/**
* The direction in which the [swipeable] is moving, relative to the current [currentValue].
*
* This will be either 1f if it is is moving from left to right or top to bottom, -1f if it is
* moving from right to left or bottom to top, or 0f if no swipe or animation is in progress.
*/
@ExperimentalMaterial3Api
internal val direction: Float
get() = anchors.getOffset(currentValue)?.let { sign(offset.value - it) } ?: 0f
/**
* Set the state without any animation and suspend until it's set
*
* @param targetValue The new target value to set [currentValue] to.
*/
@ExperimentalMaterial3Api
internal suspend fun snapTo(targetValue: T) {
latestNonEmptyAnchorsFlow.collect { anchors ->
val targetOffset = anchors.getOffset(targetValue)
requireNotNull(targetOffset) {
"The target value must have an associated anchor."
}
snapInternalToOffset(targetOffset)
currentValue = targetValue
}
}
/**
* Set the state to the target value by starting an animation.
*
* @param targetValue The new value to animate to.
* @param anim The animation that will be used to animate to the new value.
*/
@ExperimentalMaterial3Api
internal suspend fun animateTo(targetValue: T, anim: AnimationSpec<Float> = animationSpec) {
latestNonEmptyAnchorsFlow.collect { anchors ->
try {
val targetOffset = anchors.getOffset(targetValue)
requireNotNull(targetOffset) {
"The target value must have an associated anchor."
}
animateInternalToOffset(targetOffset, anim)
} finally {
val endOffset = absoluteOffset.value
val endValue = anchors
// fighting rounding error once again, anchor should be as close as 0.5 pixels
.filterKeys { anchorOffset -> abs(anchorOffset - endOffset) < 0.5f }
.values.firstOrNull() ?: currentValue
currentValue = endValue
}
}
}
/**
* Perform fling with settling to one of the anchors which is determined by the given
* [velocity]. Fling with settling [swipeable] will always consume all the velocity provided
* since it will settle at the anchor.
*
* In general cases, [swipeable] flings by itself when being swiped. This method is to be
* used for nested scroll logic that wraps the [swipeable]. In nested scroll developer may
* want to trigger settling fling when the child scroll container reaches the bound.
*
* @param velocity velocity to fling and settle with
*
* @return the reason fling ended
*/
internal suspend fun performFling(velocity: Float) {
latestNonEmptyAnchorsFlow.collect { anchors ->
val lastAnchor = anchors.getOffset(currentValue)!!
val targetValue = computeTarget(
offset = offset.value,
lastValue = lastAnchor,
anchors = anchors.keys,
thresholds = thresholds,
velocity = velocity,
velocityThreshold = velocityThreshold
)
val targetState = anchors[targetValue]
if (targetState != null && confirmStateChange(targetState)) animateTo(targetState)
// If the user vetoed the state change, rollback to the previous state.
else animateInternalToOffset(lastAnchor, animationSpec)
}
}
/**
* Force [swipeable] to consume drag delta provided from outside of the regular [swipeable]
* gesture flow.
*
* Note: This method performs generic drag and it won't settle to any particular anchor, *
* leaving swipeable in between anchors. When done dragging, [performFling] must be
* called as well to ensure swipeable will settle at the anchor.
*
* In general cases, [swipeable] drags by itself when being swiped. This method is to be
* used for nested scroll logic that wraps the [swipeable]. In nested scroll developer may
* want to force drag when the child scroll container reaches the bound.
*
* @param delta delta in pixels to drag by
*
* @return the amount of [delta] consumed
*/
internal fun performDrag(delta: Float): Float {
val potentiallyConsumed = absoluteOffset.value + delta
val clamped = potentiallyConsumed.coerceIn(minBound, maxBound)
val deltaToConsume = clamped - absoluteOffset.value
if (abs(deltaToConsume) > 0) {
draggableState.dispatchRawDelta(deltaToConsume)
}
return deltaToConsume
}
companion object {
/**
* The default [Saver] implementation for [SwipeableState].
*/
fun <T : Any> Saver(
animationSpec: AnimationSpec<Float>,
confirmStateChange: (T) -> Boolean
) = Saver<SwipeableState<T>, T>(
save = { it.currentValue },
restore = { SwipeableState(it, animationSpec, confirmStateChange) }
)
}
}
/**
* Collects information about the ongoing swipe or animation in [swipeable].
*
* To access this information, use [SwipeableState.progress].
*
* @param from The state corresponding to the anchor we are moving away from.
* @param to The state corresponding to the anchor we are moving towards.
* @param fraction The fraction that the current position represents between [from] and [to].
* Must be between `0` and `1`.
*/
@Immutable
@ExperimentalMaterial3Api
internal class SwipeProgress<T>(
val from: T,
val to: T,
/*@FloatRange(from = 0.0, to = 1.0)*/
val fraction: Float
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is SwipeProgress<*>) return false
if (from != other.from) return false
if (to != other.to) return false
if (fraction != other.fraction) return false
return true
}
override fun hashCode(): Int {
var result = from?.hashCode() ?: 0
result = 31 * result + (to?.hashCode() ?: 0)
result = 31 * result + fraction.hashCode()
return result
}
override fun toString(): String {
return "SwipeProgress(from=$from, to=$to, fraction=$fraction)"
}
}
/**
* Create and [remember] a [SwipeableState] with the default animation clock.
*
* @param initialValue The initial value of the state.
* @param animationSpec The default animation that will be used to animate to a new state.
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
*/
@Composable
@ExperimentalMaterial3Api
internal fun <T : Any> rememberSwipeableState(
initialValue: T,
animationSpec: AnimationSpec<Float> = AnimationSpec,
confirmStateChange: (newValue: T) -> Boolean = { true }
): SwipeableState<T> {
return rememberSaveable(
saver = SwipeableState.Saver(
animationSpec = animationSpec,
confirmStateChange = confirmStateChange
)
) {
SwipeableState(
initialValue = initialValue,
animationSpec = animationSpec,
confirmStateChange = confirmStateChange
)
}
}
/**
* Create and [remember] a [SwipeableState] which is kept in sync with another state, i.e.:
* 1. Whenever the [value] changes, the [SwipeableState] will be animated to that new value.
* 2. Whenever the value of the [SwipeableState] changes (e.g. after a swipe), the owner of the
* [value] will be notified to update their state to the new value of the [SwipeableState] by
* invoking [onValueChange]. If the owner does not update their state to the provided value for
* some reason, then the [SwipeableState] will perform a rollback to the previous, correct value.
*/
@Composable
@ExperimentalMaterial3Api
internal fun <T : Any> rememberSwipeableStateFor(
value: T,
onValueChange: (T) -> Unit,
animationSpec: AnimationSpec<Float> = AnimationSpec
): SwipeableState<T> {
val swipeableState = remember {
SwipeableState(
initialValue = value,
animationSpec = animationSpec,
confirmStateChange = { true }
)
}
val forceAnimationCheck = remember { mutableStateOf(false) }
LaunchedEffect(value, forceAnimationCheck.value) {
if (value != swipeableState.currentValue) {
swipeableState.animateTo(value)
}
}
DisposableEffect(swipeableState.currentValue) {
if (value != swipeableState.currentValue) {
onValueChange(swipeableState.currentValue)
forceAnimationCheck.value = !forceAnimationCheck.value
}
onDispose { }
}
return swipeableState
}
/**
* Enable swipe gestures between a set of predefined states.
*
* To use this, you must provide a map of anchors (in pixels) to states (of type [T]).
* Note that this map cannot be empty and cannot have two anchors mapped to the same state.
*
* When a swipe is detected, the offset of the [SwipeableState] will be updated with the swipe
* delta. You should use this offset to move your content accordingly (see `Modifier.offsetPx`).
* When the swipe ends, the offset will be animated to one of the anchors and when that anchor is
* reached, the value of the [SwipeableState] will also be updated to the state corresponding to
* the new anchor. The target anchor is calculated based on the provided positional [thresholds].
*
* Swiping is constrained between the minimum and maximum anchors. If the user attempts to swipe
* past these bounds, a resistance effect will be applied by default. The amount of resistance at
* each edge is specified by the [resistance] config. To disable all resistance, set it to `null`.
*
* @param T The type of the state.
* @param state The state of the [swipeable].
* @param anchors Pairs of anchors and states, used to map anchors to states and vice versa.
* @param thresholds Specifies where the thresholds between the states are. The thresholds will be
* used to determine which state to animate to when swiping stops. This is represented as a lambda
* that takes two states and returns the threshold between them in the form of a [ThresholdConfig].
* Note that the order of the states corresponds to the swipe direction.
* @param orientation The orientation in which the [swipeable] can be swiped.
* @param enabled Whether this [swipeable] is enabled and should react to the user's input.
* @param reverseDirection Whether to reverse the direction of the swipe, so a top to bottom
* swipe will behave like bottom to top, and a left to right swipe will behave like right to left.
* @param interactionSource Optional [MutableInteractionSource] that will passed on to
* the internal [Modifier.draggable].
* @param resistance Controls how much resistance will be applied when swiping past the bounds.
* @param velocityThreshold The threshold (in dp per second) that the end velocity has to exceed
* in order to animate to the next state, even if the positional [thresholds] have not been reached.
*/
@ExperimentalMaterial3Api
internal fun <T> Modifier.swipeable(
state: SwipeableState<T>,
anchors: Map<Float, T>,
orientation: Orientation,
enabled: Boolean = true,
reverseDirection: Boolean = false,
interactionSource: MutableInteractionSource? = null,
thresholds: (from: T, to: T) -> ThresholdConfig = { _, _ -> FixedThreshold(56.dp) },
resistance: ResistanceConfig? = resistanceConfig(anchors.keys),
velocityThreshold: Dp = VelocityThreshold
) = composed(
inspectorInfo = debugInspectorInfo {
name = "swipeable"
properties["state"] = state
properties["anchors"] = anchors
properties["orientation"] = orientation
properties["enabled"] = enabled
properties["reverseDirection"] = reverseDirection
properties["interactionSource"] = interactionSource
properties["thresholds"] = thresholds
properties["resistance"] = resistance
properties["velocityThreshold"] = velocityThreshold
}
) {
require(anchors.isNotEmpty()) {
"You must have at least one anchor."
}
require(anchors.values.distinct().count() == anchors.size) {
"You cannot have two anchors mapped to the same state."
}
val density = LocalDensity.current
state.ensureInit(anchors)
LaunchedEffect(anchors, state) {
val oldAnchors = state.anchors
state.anchors = anchors
state.resistance = resistance
state.thresholds = { a, b ->
val from = anchors.getValue(a)
val to = anchors.getValue(b)
with(thresholds(from, to)) { density.computeThreshold(a, b) }
}
with(density) {
state.velocityThreshold = velocityThreshold.toPx()
}
state.processNewAnchors(oldAnchors, anchors)
}
Modifier.draggable(
orientation = orientation,
enabled = enabled,
reverseDirection = reverseDirection,
interactionSource = interactionSource,
startDragImmediately = state.isAnimationRunning,
onDragStopped = { velocity -> launch { state.performFling(velocity) } },
state = state.draggableState
)
}
/**
* Interface to compute a threshold between two anchors/states in a [swipeable].
*
* To define a [ThresholdConfig], consider using [FixedThreshold] and [FractionalThreshold].
*/
@Stable
@ExperimentalMaterial3Api
internal interface ThresholdConfig {
/**
* Compute the value of the threshold (in pixels), once the values of the anchors are known.
*/
fun Density.computeThreshold(fromValue: Float, toValue: Float): Float
}
/**
* A fixed threshold will be at an [offset] away from the first anchor.
*
* @param offset The offset (in dp) that the threshold will be at.
*/
@Immutable
@ExperimentalMaterial3Api
internal data class FixedThreshold(private val offset: Dp) : ThresholdConfig {
override fun Density.computeThreshold(fromValue: Float, toValue: Float): Float {
return fromValue + offset.toPx() * sign(toValue - fromValue)
}
}
/**
* A fractional threshold will be at a [fraction] of the way between the two anchors.
*
* @param fraction The fraction (between 0 and 1) that the threshold will be at.
*/
@Immutable
@ExperimentalMaterial3Api
internal data class FractionalThreshold(
/*@FloatRange(from = 0.0, to = 1.0)*/
private val fraction: Float
) : ThresholdConfig {
override fun Density.computeThreshold(fromValue: Float, toValue: Float): Float {
return lerp(fromValue, toValue, fraction)
}
}
/**
* Specifies how resistance is calculated in [swipeable].
*
* There are two things needed to calculate resistance: the resistance basis determines how much
* overflow will be consumed to achieve maximum resistance, and the resistance factor determines
* the amount of resistance (the larger the resistance factor, the stronger the resistance).
*
* The resistance basis is usually either the size of the component which [swipeable] is applied
* to, or the distance between the minimum and maximum anchors. For a constructor in which the
* resistance basis defaults to the latter, consider using [resistanceConfig].
*
* You may specify different resistance factors for each bound. Consider using one of the default
* resistance factors in [SwipeableDefaults]: `StandardResistanceFactor` to convey that the user
* has run out of things to see, and `StiffResistanceFactor` to convey that the user cannot swipe
* this right now. Also, you can set either factor to 0 to disable resistance at that bound.
*
* @param basis Specifies the maximum amount of overflow that will be consumed. Must be positive.
* @param factorAtMin The factor by which to scale the resistance at the minimum bound.
* Must not be negative.
* @param factorAtMax The factor by which to scale the resistance at the maximum bound.
* Must not be negative.
*/
@Immutable
internal class ResistanceConfig(
/*@FloatRange(from = 0.0, fromInclusive = false)*/
val basis: Float,
/*@FloatRange(from = 0.0)*/
val factorAtMin: Float = StandardResistanceFactor,
/*@FloatRange(from = 0.0)*/
val factorAtMax: Float = StandardResistanceFactor
) {
fun computeResistance(overflow: Float): Float {
val factor = if (overflow < 0) factorAtMin else factorAtMax
if (factor == 0f) return 0f
val progress = (overflow / basis).coerceIn(-1f, 1f)
return basis / factor * sin(progress * PI.toFloat() / 2)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ResistanceConfig) return false
if (basis != other.basis) return false
if (factorAtMin != other.factorAtMin) return false
if (factorAtMax != other.factorAtMax) return false
return true
}
override fun hashCode(): Int {
var result = basis.hashCode()
result = 31 * result + factorAtMin.hashCode()
result = 31 * result + factorAtMax.hashCode()
return result
}
override fun toString(): String {
return "ResistanceConfig(basis=$basis, factorAtMin=$factorAtMin, factorAtMax=$factorAtMax)"
}
}
/**
* Given an offset x and a set of anchors, return a list of anchors:
* 1. [ ] if the set of anchors is empty,
* 2. [ x' ] if x is equal to one of the anchors, accounting for a small rounding error, where x'
* is x rounded to the exact value of the matching anchor,
* 3. [ min ] if min is the minimum anchor and x < min,
* 4. [ max ] if max is the maximum anchor and x > max, or
* 5. [ a , b ] if a and b are anchors such that a < x < b and b - a is minimal.
*/
private fun findBounds(
offset: Float,
anchors: Set<Float>
): List<Float> {
// Find the anchors the target lies between with a little bit of rounding error.
val a = anchors.filter { it <= offset + 0.001 }.maxOrNull()
val b = anchors.filter { it >= offset - 0.001 }.minOrNull()
return when {
a == null ->
// case 1 or 3
listOfNotNull(b)
b == null ->
// case 4
listOf(a)
a == b ->
// case 2
// Can't return offset itself here since it might not be exactly equal
// to the anchor, despite being considered an exact match.
listOf(a)
else ->
// case 5
listOf(a, b)
}
}
private fun computeTarget(
offset: Float,
lastValue: Float,
anchors: Set<Float>,
thresholds: (Float, Float) -> Float,
velocity: Float,
velocityThreshold: Float
): Float {
val bounds = findBounds(offset, anchors)
return when (bounds.size) {
0 -> lastValue
1 -> bounds[0]
else -> {
val lower = bounds[0]
val upper = bounds[1]
if (lastValue <= offset) {
// Swiping from lower to upper (positive).
if (velocity >= velocityThreshold) {
return upper
} else {
val threshold = thresholds(lower, upper)
if (offset < threshold) lower else upper
}
} else {
// Swiping from upper to lower (negative).
if (velocity <= -velocityThreshold) {
return lower
} else {
val threshold = thresholds(upper, lower)
if (offset > threshold) upper else lower
}
}
}
}
}
private fun <T> Map<Float, T>.getOffset(state: T): Float? {
return entries.firstOrNull { it.value == state }?.key
}
/**
* Contains useful defaults for [swipeable] and [SwipeableState].
*/
internal object SwipeableDefaults {
/**
* The default animation used by [SwipeableState].
*/
internal val AnimationSpec = SpringSpec<Float>()
/**
* The default velocity threshold (1.8 dp per millisecond) used by [swipeable].
*/
internal val VelocityThreshold = 125.dp
/**
* A stiff resistance factor which indicates that swiping isn't available right now.
*/
const val StiffResistanceFactor = 20f
/**
* A standard resistance factor which indicates that the user has run out of things to see.
*/
const val StandardResistanceFactor = 10f
/**
* The default resistance config used by [swipeable].
*
* This returns `null` if there is one anchor. If there are at least two anchors, it returns
* a [ResistanceConfig] with the resistance basis equal to the distance between the two bounds.
*/
internal fun resistanceConfig(
anchors: Set<Float>,
factorAtMin: Float = StandardResistanceFactor,
factorAtMax: Float = StandardResistanceFactor
): ResistanceConfig? {
return if (anchors.size <= 1) {
null
} else {
val basis = anchors.maxOrNull()!! - anchors.minOrNull()!!
ResistanceConfig(basis, factorAtMin, factorAtMax)
}
}
}
// temp default nested scroll connection for swipeables which desire as an opt in
// revisit in b/174756744 as all types will have their own specific connection probably
@ExperimentalMaterial3Api
internal val <T> SwipeableState<T>.PreUpPostDownNestedScrollConnection: NestedScrollConnection
get() = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.toFloat()
return if (delta < 0 && source == NestedScrollSource.Drag) {
performDrag(delta).toOffset()
} else {
Offset.Zero
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return if (source == NestedScrollSource.Drag) {
performDrag(available.toFloat()).toOffset()
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
val toFling = Offset(available.x, available.y).toFloat()
return if (toFling < 0 && offset.value > minBound) {
performFling(velocity = toFling)
// since we go to the anchor with tween settling, consume all for the best UX
available
} else {
Velocity.Zero
}
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
performFling(velocity = Offset(available.x, available.y).toFloat())
return available
}
private fun Float.toOffset(): Offset = Offset(0f, this)
private fun Offset.toFloat(): Float = this.y
}
@Tonnie-Dev
Copy link

That's a very long code, thanks for going the the extra mile.

@Marlinski
Copy link
Author

Thanks for the comment! I did not had to write any custom logic though, I copy pasted it and replaced the material dependencies with those from material3 when they were available. Otherwise I would pull those, copy paste it and repeat the process recursively.

@gersomonline
Copy link

+1

@hoangnguyen2021
Copy link

Thanks a lot. You will need to add the dependency androidx.compose.ui:ui-util for it to work.

@L0LB0Y
Copy link

L0LB0Y commented Jan 8, 2023

what about the Modal Bottom sheet? any solution

@marchingon12
Copy link

how exactly do we use this? do we just import it into our components folder? sorry im rather new to android develpment and was looking for a solution to BottomSheetScaffold for md3 when i found out they still havent released it yet.

@GoldenSoju
Copy link

@marchingon12
It was released just yesterday,
Please see: androidx.compose.material3:material3:1.1.0

@marchingon12
Copy link

@GoldenSoju thank you very much for the update! I'll make good use of the info :D

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment