/*
* 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 androidx.compose.material
import androidx.compose.animation.animate
import androidx.compose.animation.asDisposableClock
import androidx.compose.animation.core.AnimationClockObservable
import androidx.compose.animation.core.AnimationEndReason
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.TweenSpec
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offsetPx
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.savedinstancestate.Saver
import androidx.compose.runtime.savedinstancestate.rememberSavedInstanceState
import androidx.compose.ui.Modifier
import androidx.compose.ui.gesture.scrollorientationlocking.Orientation
import androidx.compose.ui.gesture.tapGestureFilter
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.ExperimentalSubcomposeLayoutApi
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.AnimationClockAmbient
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.max
/**
* Possible values of [ModalBottomSheetState].
*/
@ExperimentalMaterialApi
enum class ModalBottomSheetValue {
/**
* The bottom sheet is not visible.
*/
Hidden,
/**
* The bottom sheet is visible at full height.
*/
Expanded,
/**
* The bottom sheet is partially visible at 50% of the screen height. This state is only
* enabled if the height of the bottom sheet is more than 50% of the screen height.
*/
HalfExpanded
}
/**
* State of the [ModalBottomSheetLayout] composable.
*
* @param initialValue The initial value of the state.
* @param clock The animation clock that will be used to drive the animations.
* @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.
*/
@ExperimentalMaterialApi
class ModalBottomSheetState(
initialValue: ModalBottomSheetValue,
clock: AnimationClockObservable,
animationSpec: AnimationSpec<Float> = SwipeableConstants.DefaultAnimationSpec,
confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true }
) : SwipeableState<ModalBottomSheetValue>(
initialValue = initialValue,
clock = clock,
animationSpec = animationSpec,
confirmStateChange = confirmStateChange
) {
/**
* Whether the bottom sheet is visible.
*/
val isVisible: Boolean
get() = value != ModalBottomSheetValue.Hidden
private val isHalfExpandedEnabled: Boolean
get() = anchors.values.contains(ModalBottomSheetValue.HalfExpanded)
/**
* Show the bottom sheet, with an animation.
*
* @param onShown Optional callback invoked when the bottom sheet has been shown.
*/
fun show(onShown: (() -> Unit)? = null) {
val targetValue =
if (isHalfExpandedEnabled) ModalBottomSheetValue.HalfExpanded
else ModalBottomSheetValue.Expanded
animateTo(targetValue = targetValue, onEnd = { endReason, _ ->
if (endReason == AnimationEndReason.TargetReached) {
onShown?.invoke()
}
})
}
/**
* Hide the bottom sheet, with an animation.
*
* @param onHidden Optional callback invoked when the bottom sheet has been hidden.
*/
fun hide(onHidden: (() -> Unit)? = null) {
animateTo(targetValue = ModalBottomSheetValue.Hidden, onEnd = { endReason, _ ->
if (endReason == AnimationEndReason.TargetReached) {
onHidden?.invoke()
}
})
}
companion object {
/**
* The default [Saver] implementation for [ModalBottomSheetState].
*/
fun Saver(
clock: AnimationClockObservable,
animationSpec: AnimationSpec<Float>,
confirmStateChange: (ModalBottomSheetValue) -> Boolean
): Saver<ModalBottomSheetState, *> = Saver(
save = { it.value },
restore = {
ModalBottomSheetState(
initialValue = it,
clock = clock,
animationSpec = animationSpec,
confirmStateChange = confirmStateChange
)
}
)
}
}
/**
* Create a [ModalBottomSheetState] and [remember] it against the [clock]. If a clock is not
* specified, the default animation clock will be used, as provided by [AnimationClockAmbient].
*
* @param initialValue The initial value of the state.
* @param clock The animation clock that will be used to drive the animations.
* @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
@ExperimentalMaterialApi
fun rememberModalBottomSheetState(
initialValue: ModalBottomSheetValue,
clock: AnimationClockObservable = AnimationClockAmbient.current,
animationSpec: AnimationSpec<Float> = SwipeableConstants.DefaultAnimationSpec,
confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true }
): ModalBottomSheetState {
val disposableClock = clock.asDisposableClock()
return rememberSavedInstanceState(
disposableClock,
saver = ModalBottomSheetState.Saver(
clock = disposableClock,
animationSpec = animationSpec,
confirmStateChange = confirmStateChange
)
) {
ModalBottomSheetState(
initialValue = initialValue,
clock = disposableClock,
animationSpec = animationSpec,
confirmStateChange = confirmStateChange
)
}
}
/**
* Modal bottom sheets present a set of choices while blocking interaction with the rest of the
* screen. They are an alternative to inline menus and simple dialogs on mobile, providing
* additional room for content, iconography, and actions.
*
* A simple example of a modal bottom sheet looks like this:
*
* @sample androidx.compose.material.samples.ModalBottomSheetSample
*
* @param sheetContent The content of the bottom sheet.
* @param modifier Optional [Modifier] for the entire component.
* @param sheetState The state of the bottom sheet.
* @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 `onFoo` 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 scrimColor The color of the scrim that is applied to the rest of the screen when the
* bottom sheet is visible. If you set this to `Color.Transparent`, then a scrim will no longer be
* applied and the bottom sheet will not block interaction with the rest of the screen when visible.
* @param content The content of rest of the screen.
*/
@Composable
@ExperimentalMaterialApi
fun ModalBottomSheetLayout(
sheetContent: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
sheetState: ModalBottomSheetState =
rememberModalBottomSheetState(ModalBottomSheetValue.Hidden),
sheetShape: Shape = MaterialTheme.shapes.large,
sheetElevation: Dp = ModalBottomSheetConstants.DefaultElevation,
sheetBackgroundColor: Color = MaterialTheme.colors.surface,
sheetContentColor: Color = contentColorFor(sheetBackgroundColor),
scrimColor: Color = ModalBottomSheetConstants.DefaultScrimColor,
content: @Composable () -> Unit
) = BottomSheetStack(
modifier = modifier,
sheetContent = {
Surface(
Modifier
.fillMaxWidth()
.offsetPx(y = sheetState.offset),
shape = sheetShape,
elevation = sheetElevation,
color = sheetBackgroundColor,
contentColor = sheetContentColor
) {
Column(children = sheetContent)
}
},
content = { constraints, sheetHeight ->
val fullHeight = constraints.maxHeight.toFloat()
val anchors = if (sheetHeight < fullHeight / 2) {
mapOf(
fullHeight to ModalBottomSheetValue.Hidden,
fullHeight - sheetHeight to ModalBottomSheetValue.Expanded
)
} else {
mapOf(
fullHeight to ModalBottomSheetValue.Hidden,
fullHeight / 2 to ModalBottomSheetValue.HalfExpanded,
max(0f, fullHeight - sheetHeight) to ModalBottomSheetValue.Expanded
)
}
val swipeable = Modifier.swipeable(
state = sheetState,
anchors = anchors,
thresholds = { _, _ -> FixedThreshold(56.dp) },
orientation = Orientation.Vertical,
enabled = sheetState.value != ModalBottomSheetValue.Hidden,
resistance = null
)
Box(Modifier.fillMaxSize().then(swipeable)) {
content()
Scrim(
color = scrimColor,
onDismiss = { sheetState.hide() },
visible = sheetState.targetValue != ModalBottomSheetValue.Hidden
)
}
}
)
@Composable
private fun Scrim(
color: Color,
onDismiss: () -> Unit,
visible: Boolean
) {
if (color != Color.Transparent) {
val alpha = animate(target = if (visible) 1f else 0f, animSpec = TweenSpec())
val dismissModifier = if (visible) Modifier.tapGestureFilter { onDismiss() } else Modifier
Canvas(
Modifier
.fillMaxSize()
.then(dismissModifier)
) {
drawRect(color = color, alpha = alpha)
}
}
}
@Composable
@OptIn(ExperimentalSubcomposeLayoutApi::class)
private fun BottomSheetStack(
modifier: Modifier,
sheetContent: @Composable () -> Unit,
content: @Composable (constraints: Constraints, sheetHeight: Float) -> Unit
) {
SubcomposeLayout<BottomSheetStackSlot>(modifier) { constraints ->
val sheetPlaceable =
subcompose(BottomSheetStackSlot.SheetContent, sheetContent)
.first().measure(constraints.copy(minWidth = 0, minHeight = 0))
val sheetHeight = sheetPlaceable.height.toFloat()
val placeable =
subcompose(BottomSheetStackSlot.Content) { content(constraints, sheetHeight) }
.first().measure(constraints)
layout(placeable.width, placeable.height) {
placeable.placeRelative(0, 0)
sheetPlaceable.placeRelative(0, 0)
}
}
}
private enum class BottomSheetStackSlot { SheetContent, Content }
/**
* Contains useful constants for [ModalBottomSheetLayout].
*/
object ModalBottomSheetConstants {
/**
* The default elevation used by [ModalBottomSheetLayout].
*/
val DefaultElevation = 16.dp
/**
* The default scrim color used by [ModalBottomSheetLayout].
*/
@Composable
val DefaultScrimColor: Color
get() = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
}