/* * Copyright (C) 2022 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 com.android.credentialmanager.common.material 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.fillMaxWidth import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.offset import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable 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.ui.Alignment 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.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.collapse import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.dismiss import androidx.compose.ui.semantics.expand import androidx.compose.ui.semantics.onClick import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import com.android.credentialmanager.R import com.android.credentialmanager.common.material.ModalBottomSheetValue.Expanded import com.android.credentialmanager.common.material.ModalBottomSheetValue.HalfExpanded import com.android.credentialmanager.common.material.ModalBottomSheetValue.Hidden import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlin.math.max import kotlin.math.roundToInt /** * Possible values of [ModalBottomSheetState]. */ 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. Must not be set to * [ModalBottomSheetValue.HalfExpanded] if [isSkipHalfExpanded] is set to true. * @param animationSpec The default animation that will be used to animate to a new state. * @param isSkipHalfExpanded Whether the half expanded state, if the sheet is tall enough, should * be skipped. If true, the sheet will always expand to the [Expanded] state and move to the * [Hidden] state when hiding the sheet, either programmatically or by user interaction. * Must not be set to true if the [initialValue] is [ModalBottomSheetValue.HalfExpanded]. * If supplied with [ModalBottomSheetValue.HalfExpanded] for the [initialValue], an * [IllegalArgumentException] will be thrown. * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. */ class ModalBottomSheetState( initialValue: ModalBottomSheetValue, animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, internal val isSkipHalfExpanded: Boolean, confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true } ) : SwipeableState( initialValue = initialValue, animationSpec = animationSpec, confirmStateChange = confirmStateChange ) { /** * Whether the bottom sheet is visible. */ val isVisible: Boolean get() = currentValue != Hidden internal val hasHalfExpandedState: Boolean get() = anchors.values.contains(HalfExpanded) constructor( initialValue: ModalBottomSheetValue, animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true } ) : this(initialValue, animationSpec, isSkipHalfExpanded = false, confirmStateChange) init { if (isSkipHalfExpanded) { require(initialValue != HalfExpanded) { "The initial value must not be set to HalfExpanded if skipHalfExpanded is set to" + " true." } } } /** * Show the bottom sheet with animation and suspend until it's shown. If the sheet is taller * than 50% of the parent's height, the bottom sheet will be half expanded. Otherwise it will be * fully expanded. * * @throws [CancellationException] if the animation is interrupted */ suspend fun show() { val targetValue = when { hasHalfExpandedState -> HalfExpanded else -> Expanded } animateTo(targetValue = targetValue) } /** * Half expand the bottom sheet if half expand is enabled with animation and suspend until it * animation is complete or cancelled * * @throws [CancellationException] if the animation is interrupted */ internal suspend fun halfExpand() { if (!hasHalfExpandedState) { return } animateTo(HalfExpanded) } /** * Fully expand the bottom sheet with animation and suspend until it if fully expanded or * animation has been cancelled. * * * @throws [CancellationException] if the animation is interrupted */ internal suspend fun expand() = animateTo(Expanded) /** * Hide the bottom sheet with animation and suspend until it if fully hidden or animation has * been cancelled. * * @throws [CancellationException] if the animation is interrupted */ suspend fun hide() = animateTo(Hidden) internal val nestedScrollConnection = this.PreUpPostDownNestedScrollConnection companion object { /** * The default [Saver] implementation for [ModalBottomSheetState]. */ fun Saver( animationSpec: AnimationSpec, skipHalfExpanded: Boolean, confirmStateChange: (ModalBottomSheetValue) -> Boolean ): Saver = Saver( save = { it.currentValue }, restore = { ModalBottomSheetState( initialValue = it, animationSpec = animationSpec, isSkipHalfExpanded = skipHalfExpanded, confirmStateChange = confirmStateChange ) } ) /** * The default [Saver] implementation for [ModalBottomSheetState]. */ @Deprecated( message = "Please specify the skipHalfExpanded parameter", replaceWith = ReplaceWith( "ModalBottomSheetState.Saver(" + "animationSpec = animationSpec," + "skipHalfExpanded = ," + "confirmStateChange = confirmStateChange" + ")" ) ) fun Saver( animationSpec: AnimationSpec, confirmStateChange: (ModalBottomSheetValue) -> Boolean ): Saver = Saver( animationSpec = animationSpec, skipHalfExpanded = false, confirmStateChange = confirmStateChange ) } } /** * Create a [ModalBottomSheetState] 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 skipHalfExpanded Whether the half expanded state, if the sheet is tall enough, should * be skipped. If true, the sheet will always expand to the [Expanded] state and move to the * [Hidden] state when hiding the sheet, either programmatically or by user interaction. * Must not be set to true if the [initialValue] is [ModalBottomSheetValue.HalfExpanded]. * If supplied with [ModalBottomSheetValue.HalfExpanded] for the [initialValue], an * [IllegalArgumentException] will be thrown. * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change. */ @Composable fun rememberModalBottomSheetState( initialValue: ModalBottomSheetValue, animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, skipHalfExpanded: Boolean, confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true } ): ModalBottomSheetState { return rememberSaveable( initialValue, animationSpec, skipHalfExpanded, confirmStateChange, saver = ModalBottomSheetState.Saver( animationSpec = animationSpec, skipHalfExpanded = skipHalfExpanded, confirmStateChange = confirmStateChange ) ) { ModalBottomSheetState( initialValue = initialValue, animationSpec = animationSpec, isSkipHalfExpanded = skipHalfExpanded, confirmStateChange = confirmStateChange ) } } /** * Create a [ModalBottomSheetState] 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 rememberModalBottomSheetState( initialValue: ModalBottomSheetValue, animationSpec: AnimationSpec = SwipeableDefaults.AnimationSpec, confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true } ): ModalBottomSheetState = rememberModalBottomSheetState( initialValue = initialValue, animationSpec = animationSpec, skipHalfExpanded = false, confirmStateChange = confirmStateChange ) /** * Material Design modal bottom sheet. * * 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, providing * additional room for content, iconography, and actions. * * ![Modal bottom sheet image](https://developer.android.com/images/reference/androidx/compose/material/modal-bottom-sheet.png) * * 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 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 content The content of rest of the screen. */ @Composable fun ModalBottomSheetLayout( sheetContent: @Composable ColumnScope.() -> Unit, modifier: Modifier = Modifier, sheetState: ModalBottomSheetState = rememberModalBottomSheetState(Hidden), sheetShape: Shape = MaterialTheme.shapes.large, sheetElevation: Dp = ModalBottomSheetDefaults.Elevation, sheetBackgroundColor: Color, sheetContentColor: Color = contentColorFor(sheetBackgroundColor), content: @Composable () -> Unit ) { val scope = rememberCoroutineScope() BoxWithConstraints(modifier) { val fullHeight = constraints.maxHeight.toFloat() val sheetHeightState = remember { mutableStateOf(null) } Box(Modifier.fillMaxSize()) { content() Scrim( color = ModalBottomSheetDefaults.scrimColor, onDismiss = { if (sheetState.confirmStateChange(Hidden)) { scope.launch { sheetState.hide() } } }, visible = sheetState.targetValue != Hidden ) } // For large screen, allow enough horizontal scrim space. // Manually calculate the > compact width due to lack of corresponding jetpack dependency. val maxSheetContentWidth: Dp = if (maxWidth >= ModalBottomSheetDefaults.MaxCompactWidth && maxWidth <= ModalBottomSheetDefaults.MaxCompactWidth + ModalBottomSheetDefaults.StartPadding + ModalBottomSheetDefaults.EndPadding ) (maxWidth - ModalBottomSheetDefaults.StartPadding - ModalBottomSheetDefaults.EndPadding) else ModalBottomSheetDefaults.MaxSheetWidth val maxSheetContentHeight = maxHeight - ModalBottomSheetDefaults.MinScrimHeight Box( Modifier.sizeIn( maxWidth = maxSheetContentWidth, // Allow enough vertical scrim space. maxHeight = maxSheetContentHeight ).align(Alignment.TopCenter) ) { Surface( Modifier .fillMaxWidth() .nestedScroll(sheetState.nestedScrollConnection) .offset { val y = if (sheetState.anchors.isEmpty()) { // if we don't know our anchors yet, render the sheet as hidden fullHeight.roundToInt() } else { // if we do know our anchors, respect them sheetState.offset.value.roundToInt() } IntOffset(0, y) } .bottomSheetSwipeable(sheetState, fullHeight, sheetHeightState) .onGloballyPositioned { sheetHeightState.value = it.size.height.toFloat() } .semantics { if (sheetState.isVisible) { dismiss { if (sheetState.confirmStateChange(Hidden)) { scope.launch { sheetState.hide() } } true } if (sheetState.currentValue == HalfExpanded) { expand { if (sheetState.confirmStateChange(Expanded)) { scope.launch { sheetState.expand() } } true } } else if (sheetState.hasHalfExpandedState) { collapse { if (sheetState.confirmStateChange(HalfExpanded)) { scope.launch { sheetState.halfExpand() } } true } } } }, shape = sheetShape, shadowElevation = sheetElevation, color = sheetBackgroundColor, contentColor = sheetContentColor ) { Column( content = sheetContent ) } } } } @Suppress("ModifierInspectorInfo") private fun Modifier.bottomSheetSwipeable( sheetState: ModalBottomSheetState, fullHeight: Float, sheetHeightState: State ): Modifier { val sheetHeight = sheetHeightState.value val modifier = if (sheetHeight != null) { val anchors = if (sheetHeight < fullHeight / 2 || sheetState.isSkipHalfExpanded) { mapOf( fullHeight to Hidden, fullHeight - sheetHeight to Expanded ) } else { mapOf( fullHeight to Hidden, fullHeight / 2 to HalfExpanded, max(0f, fullHeight - sheetHeight) to Expanded ) } Modifier.swipeable( state = sheetState, anchors = anchors, orientation = Orientation.Vertical, enabled = sheetState.currentValue != Hidden, resistance = null ) } else { Modifier } return this.then(modifier) } @Composable internal fun Scrim( color: Color, onDismiss: () -> Unit, visible: Boolean ) { if (color.isSpecified) { val alpha by animateFloatAsState( targetValue = if (visible) 1f else 0f, animationSpec = TweenSpec(durationMillis = SwipeableDefaults.DefaultDurationMillis) ) LocalConfiguration.current val resources = LocalContext.current.resources val closeSheet = resources.getString(R.string.close_sheet) val dismissModifier = if (visible) { Modifier .pointerInput(onDismiss) { detectTapGestures { onDismiss() } } .semantics(mergeDescendants = true) { contentDescription = closeSheet onClick { onDismiss(); true } } } else { Modifier } Canvas( Modifier .fillMaxSize() .then(dismissModifier) ) { drawRect(color = color, alpha = alpha) } } } /** * Contains useful Defaults for [ModalBottomSheetLayout]. */ object ModalBottomSheetDefaults { val MaxCompactWidth = 600.dp val MaxSheetWidth = 640.dp val MinScrimHeight = 56.dp val StartPadding = 56.dp val EndPadding = 56.dp /** * The default elevation used by [ModalBottomSheetLayout]. */ val Elevation = 16.dp /** * The default scrim color used by [ModalBottomSheetLayout]. */ val scrimColor: Color @Composable get() = MaterialTheme.colorScheme.scrim.copy(alpha = .32f) }