1 /*
2 * Copyright (C) 2022 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package com.android.credentialmanager.common.material
18
19 import androidx.compose.animation.core.AnimationSpec
20 import androidx.compose.animation.core.TweenSpec
21 import androidx.compose.animation.core.animateFloatAsState
22 import androidx.compose.foundation.Canvas
23 import androidx.compose.foundation.gestures.Orientation
24 import androidx.compose.foundation.gestures.detectTapGestures
25 import androidx.compose.foundation.layout.Box
26 import androidx.compose.foundation.layout.BoxWithConstraints
27 import androidx.compose.foundation.layout.Column
28 import androidx.compose.foundation.layout.ColumnScope
29 import androidx.compose.foundation.layout.fillMaxSize
30 import androidx.compose.foundation.layout.fillMaxWidth
31 import androidx.compose.foundation.layout.sizeIn
32 import androidx.compose.foundation.layout.offset
33 import androidx.compose.material3.MaterialTheme
34 import androidx.compose.material3.Surface
35 import androidx.compose.material3.contentColorFor
36 import androidx.compose.runtime.Composable
37 import androidx.compose.runtime.State
38 import androidx.compose.runtime.getValue
39 import androidx.compose.runtime.mutableStateOf
40 import androidx.compose.runtime.remember
41 import androidx.compose.runtime.rememberCoroutineScope
42 import androidx.compose.runtime.saveable.Saver
43 import androidx.compose.runtime.saveable.rememberSaveable
44 import androidx.compose.ui.Alignment
45 import androidx.compose.ui.Modifier
46 import androidx.compose.ui.graphics.Color
47 import androidx.compose.ui.graphics.Shape
48 import androidx.compose.ui.graphics.isSpecified
49 import androidx.compose.ui.input.nestedscroll.nestedScroll
50 import androidx.compose.ui.input.pointer.pointerInput
51 import androidx.compose.ui.layout.onGloballyPositioned
52 import androidx.compose.ui.platform.LocalConfiguration
53 import androidx.compose.ui.platform.LocalContext
54 import androidx.compose.ui.semantics.collapse
55 import androidx.compose.ui.semantics.contentDescription
56 import androidx.compose.ui.semantics.dismiss
57 import androidx.compose.ui.semantics.expand
58 import androidx.compose.ui.semantics.onClick
59 import androidx.compose.ui.semantics.semantics
60 import androidx.compose.ui.unit.Dp
61 import androidx.compose.ui.unit.IntOffset
62 import androidx.compose.ui.unit.dp
63 import com.android.credentialmanager.R
64 import com.android.credentialmanager.common.material.ModalBottomSheetValue.Expanded
65 import com.android.credentialmanager.common.material.ModalBottomSheetValue.HalfExpanded
66 import com.android.credentialmanager.common.material.ModalBottomSheetValue.Hidden
67 import kotlinx.coroutines.CancellationException
68 import kotlinx.coroutines.launch
69 import kotlin.math.max
70 import kotlin.math.roundToInt
71
72 /**
73 * Possible values of [ModalBottomSheetState].
74 */
75 enum class ModalBottomSheetValue {
76 /**
77 * The bottom sheet is not visible.
78 */
79 Hidden,
80
81 /**
82 * The bottom sheet is visible at full height.
83 */
84 Expanded,
85
86 /**
87 * The bottom sheet is partially visible at 50% of the screen height. This state is only
88 * enabled if the height of the bottom sheet is more than 50% of the screen height.
89 */
90 HalfExpanded
91 }
92
93 /**
94 * State of the [ModalBottomSheetLayout] composable.
95 *
96 * @param initialValue The initial value of the state. <b>Must not be set to
97 * [ModalBottomSheetValue.HalfExpanded] if [isSkipHalfExpanded] is set to true.</b>
98 * @param animationSpec The default animation that will be used to animate to a new state.
99 * @param isSkipHalfExpanded Whether the half expanded state, if the sheet is tall enough, should
100 * be skipped. If true, the sheet will always expand to the [Expanded] state and move to the
101 * [Hidden] state when hiding the sheet, either programmatically or by user interaction.
102 * <b>Must not be set to true if the [initialValue] is [ModalBottomSheetValue.HalfExpanded].</b>
103 * If supplied with [ModalBottomSheetValue.HalfExpanded] for the [initialValue], an
104 * [IllegalArgumentException] will be thrown.
105 * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
106 */
107 class ModalBottomSheetState(
108 initialValue: ModalBottomSheetValue,
109 animationSpec: AnimationSpec<Float> = SwipeableDefaults.AnimationSpec,
110 internal val isSkipHalfExpanded: Boolean,
<lambda>null111 confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true }
112 ) : SwipeableState<ModalBottomSheetValue>(
113 initialValue = initialValue,
114 animationSpec = animationSpec,
115 confirmStateChange = confirmStateChange
116 ) {
117 /**
118 * Whether the bottom sheet is visible.
119 */
120 val isVisible: Boolean
121 get() = currentValue != Hidden
122
123 internal val hasHalfExpandedState: Boolean
124 get() = anchors.values.contains(HalfExpanded)
125
126 constructor(
127 initialValue: ModalBottomSheetValue,
128 animationSpec: AnimationSpec<Float> = SwipeableDefaults.AnimationSpec,
<lambda>null129 confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true }
130 ) : this(initialValue, animationSpec, isSkipHalfExpanded = false, confirmStateChange)
131
132 init {
133 if (isSkipHalfExpanded) {
<lambda>null134 require(initialValue != HalfExpanded) {
135 "The initial value must not be set to HalfExpanded if skipHalfExpanded is set to" +
136 " true."
137 }
138 }
139 }
140
141 /**
142 * Show the bottom sheet with animation and suspend until it's shown. If the sheet is taller
143 * than 50% of the parent's height, the bottom sheet will be half expanded. Otherwise it will be
144 * fully expanded.
145 *
146 * @throws [CancellationException] if the animation is interrupted
147 */
shownull148 suspend fun show() {
149 val targetValue = when {
150 hasHalfExpandedState -> HalfExpanded
151 else -> Expanded
152 }
153 animateTo(targetValue = targetValue)
154 }
155
156 /**
157 * Half expand the bottom sheet if half expand is enabled with animation and suspend until it
158 * animation is complete or cancelled
159 *
160 * @throws [CancellationException] if the animation is interrupted
161 */
halfExpandnull162 internal suspend fun halfExpand() {
163 if (!hasHalfExpandedState) {
164 return
165 }
166 animateTo(HalfExpanded)
167 }
168
169 /**
170 * Fully expand the bottom sheet with animation and suspend until it if fully expanded or
171 * animation has been cancelled.
172 * *
173 * @throws [CancellationException] if the animation is interrupted
174 */
expandnull175 internal suspend fun expand() = animateTo(Expanded)
176
177 /**
178 * Hide the bottom sheet with animation and suspend until it if fully hidden or animation has
179 * been cancelled.
180 *
181 * @throws [CancellationException] if the animation is interrupted
182 */
183 suspend fun hide() = animateTo(Hidden)
184
185 internal val nestedScrollConnection = this.PreUpPostDownNestedScrollConnection
186
187 companion object {
188 /**
189 * The default [Saver] implementation for [ModalBottomSheetState].
190 */
191 fun Saver(
192 animationSpec: AnimationSpec<Float>,
193 skipHalfExpanded: Boolean,
194 confirmStateChange: (ModalBottomSheetValue) -> Boolean
195 ): Saver<ModalBottomSheetState, *> = Saver(
196 save = { it.currentValue },
197 restore = {
198 ModalBottomSheetState(
199 initialValue = it,
200 animationSpec = animationSpec,
201 isSkipHalfExpanded = skipHalfExpanded,
202 confirmStateChange = confirmStateChange
203 )
204 }
205 )
206
207 /**
208 * The default [Saver] implementation for [ModalBottomSheetState].
209 */
210 @Deprecated(
211 message = "Please specify the skipHalfExpanded parameter",
212 replaceWith = ReplaceWith(
213 "ModalBottomSheetState.Saver(" +
214 "animationSpec = animationSpec," +
215 "skipHalfExpanded = ," +
216 "confirmStateChange = confirmStateChange" +
217 ")"
218 )
219 )
220 fun Saver(
221 animationSpec: AnimationSpec<Float>,
222 confirmStateChange: (ModalBottomSheetValue) -> Boolean
223 ): Saver<ModalBottomSheetState, *> = Saver(
224 animationSpec = animationSpec,
225 skipHalfExpanded = false,
226 confirmStateChange = confirmStateChange
227 )
228 }
229 }
230
231 /**
232 * Create a [ModalBottomSheetState] and [remember] it.
233 *
234 * @param initialValue The initial value of the state.
235 * @param animationSpec The default animation that will be used to animate to a new state.
236 * @param skipHalfExpanded Whether the half expanded state, if the sheet is tall enough, should
237 * be skipped. If true, the sheet will always expand to the [Expanded] state and move to the
238 * [Hidden] state when hiding the sheet, either programmatically or by user interaction.
239 * <b>Must not be set to true if the [initialValue] is [ModalBottomSheetValue.HalfExpanded].</b>
240 * If supplied with [ModalBottomSheetValue.HalfExpanded] for the [initialValue], an
241 * [IllegalArgumentException] will be thrown.
242 * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
243 */
244 @Composable
rememberModalBottomSheetStatenull245 fun rememberModalBottomSheetState(
246 initialValue: ModalBottomSheetValue,
247 animationSpec: AnimationSpec<Float> = SwipeableDefaults.AnimationSpec,
248 skipHalfExpanded: Boolean,
249 confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true }
250 ): ModalBottomSheetState {
251 return rememberSaveable(
252 initialValue, animationSpec, skipHalfExpanded, confirmStateChange,
253 saver = ModalBottomSheetState.Saver(
254 animationSpec = animationSpec,
255 skipHalfExpanded = skipHalfExpanded,
256 confirmStateChange = confirmStateChange
257 )
<lambda>null258 ) {
259 ModalBottomSheetState(
260 initialValue = initialValue,
261 animationSpec = animationSpec,
262 isSkipHalfExpanded = skipHalfExpanded,
263 confirmStateChange = confirmStateChange
264 )
265 }
266 }
267
268 /**
269 * Create a [ModalBottomSheetState] and [remember] it.
270 *
271 * @param initialValue The initial value of the state.
272 * @param animationSpec The default animation that will be used to animate to a new state.
273 * @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
274 */
275 @Composable
rememberModalBottomSheetStatenull276 fun rememberModalBottomSheetState(
277 initialValue: ModalBottomSheetValue,
278 animationSpec: AnimationSpec<Float> = SwipeableDefaults.AnimationSpec,
279 confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true }
280 ): ModalBottomSheetState = rememberModalBottomSheetState(
281 initialValue = initialValue,
282 animationSpec = animationSpec,
283 skipHalfExpanded = false,
284 confirmStateChange = confirmStateChange
285 )
286
287 /**
288 * <a href="https://material.io/components/sheets-bottom#modal-bottom-sheet" class="external" target="_blank">Material Design modal bottom sheet</a>.
289 *
290 * Modal bottom sheets present a set of choices while blocking interaction with the rest of the
291 * screen. They are an alternative to inline menus and simple dialogs, providing
292 * additional room for content, iconography, and actions.
293 *
294 * ![Modal bottom sheet image](https://developer.android.com/images/reference/androidx/compose/material/modal-bottom-sheet.png)
295 *
296 * A simple example of a modal bottom sheet looks like this:
297 *
298 * @sample androidx.compose.material.samples.ModalBottomSheetSample
299 *
300 * @param sheetContent The content of the bottom sheet.
301 * @param modifier Optional [Modifier] for the entire component.
302 * @param sheetState The state of the bottom sheet.
303 * @param sheetShape The shape of the bottom sheet.
304 * @param sheetElevation The elevation of the bottom sheet.
305 * @param sheetBackgroundColor The background color of the bottom sheet.
306 * @param sheetContentColor The preferred content color provided by the bottom sheet to its
307 * children. Defaults to the matching content color for [sheetBackgroundColor], or if that is not
308 * a color from the theme, this will keep the same content color set above the bottom sheet.
309 * @param content The content of rest of the screen.
310 */
311 @Composable
ModalBottomSheetLayoutnull312 fun ModalBottomSheetLayout(
313 sheetContent: @Composable ColumnScope.() -> Unit,
314 modifier: Modifier = Modifier,
315 sheetState: ModalBottomSheetState =
316 rememberModalBottomSheetState(Hidden),
317 sheetShape: Shape = MaterialTheme.shapes.large,
318 sheetElevation: Dp = ModalBottomSheetDefaults.Elevation,
319 sheetBackgroundColor: Color,
320 sheetContentColor: Color = contentColorFor(sheetBackgroundColor),
321 content: @Composable () -> Unit
322 ) {
323 val scope = rememberCoroutineScope()
324 BoxWithConstraints(modifier) {
325 val fullHeight = constraints.maxHeight.toFloat()
326 val sheetHeightState = remember { mutableStateOf<Float?>(null) }
327 Box(Modifier.fillMaxSize()) {
328 content()
329 Scrim(
330 color = ModalBottomSheetDefaults.scrimColor,
331 onDismiss = {
332 if (sheetState.confirmStateChange(Hidden)) {
333 scope.launch { sheetState.hide() }
334 }
335 },
336 visible = sheetState.targetValue != Hidden
337 )
338 }
339
340 // For large screen, allow enough horizontal scrim space.
341 // Manually calculate the > compact width due to lack of corresponding jetpack dependency.
342 val maxSheetContentWidth: Dp =
343 if (maxWidth >= ModalBottomSheetDefaults.MaxCompactWidth &&
344 maxWidth <= ModalBottomSheetDefaults.MaxCompactWidth +
345 ModalBottomSheetDefaults.StartPadding + ModalBottomSheetDefaults.EndPadding
346 )
347 (maxWidth - ModalBottomSheetDefaults.StartPadding -
348 ModalBottomSheetDefaults.EndPadding)
349 else ModalBottomSheetDefaults.MaxSheetWidth
350 val maxSheetContentHeight = maxHeight - ModalBottomSheetDefaults.MinScrimHeight
351 Box(
352 Modifier.sizeIn(
353 maxWidth = maxSheetContentWidth,
354 // Allow enough vertical scrim space.
355 maxHeight = maxSheetContentHeight
356 ).align(Alignment.TopCenter)
357 ) {
358 Surface(
359 Modifier
360 .fillMaxWidth()
361 .nestedScroll(sheetState.nestedScrollConnection)
362 .offset {
363 val y = if (sheetState.anchors.isEmpty()) {
364 // if we don't know our anchors yet, render the sheet as hidden
365 fullHeight.roundToInt()
366 } else {
367 // if we do know our anchors, respect them
368 sheetState.offset.value.roundToInt()
369 }
370 IntOffset(0, y)
371 }
372 .bottomSheetSwipeable(sheetState, fullHeight, sheetHeightState)
373 .onGloballyPositioned {
374 sheetHeightState.value = it.size.height.toFloat()
375 }
376 .semantics {
377 if (sheetState.isVisible) {
378 dismiss {
379 if (sheetState.confirmStateChange(Hidden)) {
380 scope.launch { sheetState.hide() }
381 }
382 true
383 }
384 if (sheetState.currentValue == HalfExpanded) {
385 expand {
386 if (sheetState.confirmStateChange(Expanded)) {
387 scope.launch { sheetState.expand() }
388 }
389 true
390 }
391 } else if (sheetState.hasHalfExpandedState) {
392 collapse {
393 if (sheetState.confirmStateChange(HalfExpanded)) {
394 scope.launch { sheetState.halfExpand() }
395 }
396 true
397 }
398 }
399 }
400 },
401 shape = sheetShape,
402 shadowElevation = sheetElevation,
403 color = sheetBackgroundColor,
404 contentColor = sheetContentColor
405 ) {
406 Column(
407 content = sheetContent
408 )
409 }
410 }
411 }
412 }
413
414 @Suppress("ModifierInspectorInfo")
bottomSheetSwipeablenull415 private fun Modifier.bottomSheetSwipeable(
416 sheetState: ModalBottomSheetState,
417 fullHeight: Float,
418 sheetHeightState: State<Float?>
419 ): Modifier {
420 val sheetHeight = sheetHeightState.value
421 val modifier = if (sheetHeight != null) {
422 val anchors = if (sheetHeight < fullHeight / 2 || sheetState.isSkipHalfExpanded) {
423 mapOf(
424 fullHeight to Hidden,
425 fullHeight - sheetHeight to Expanded
426 )
427 } else {
428 mapOf(
429 fullHeight to Hidden,
430 fullHeight / 2 to HalfExpanded,
431 max(0f, fullHeight - sheetHeight) to Expanded
432 )
433 }
434 Modifier.swipeable(
435 state = sheetState,
436 anchors = anchors,
437 orientation = Orientation.Vertical,
438 enabled = sheetState.currentValue != Hidden,
439 resistance = null
440 )
441 } else {
442 Modifier
443 }
444
445 return this.then(modifier)
446 }
447
448 @Composable
Scrimnull449 internal fun Scrim(
450 color: Color,
451 onDismiss: () -> Unit,
452 visible: Boolean
453 ) {
454 if (color.isSpecified) {
455 val alpha by animateFloatAsState(
456 targetValue = if (visible) 1f else 0f,
457 animationSpec = TweenSpec(durationMillis = SwipeableDefaults.DefaultDurationMillis)
458 )
459 LocalConfiguration.current
460 val resources = LocalContext.current.resources
461 val closeSheet = resources.getString(R.string.close_sheet)
462 val dismissModifier = if (visible) {
463 Modifier
464 .pointerInput(onDismiss) { detectTapGestures { onDismiss() } }
465 .semantics(mergeDescendants = true) {
466 contentDescription = closeSheet
467 onClick { onDismiss(); true }
468 }
469 } else {
470 Modifier
471 }
472
473 Canvas(
474 Modifier
475 .fillMaxSize()
476 .then(dismissModifier)
477 ) {
478 drawRect(color = color, alpha = alpha)
479 }
480 }
481 }
482
483 /**
484 * Contains useful Defaults for [ModalBottomSheetLayout].
485 */
486 object ModalBottomSheetDefaults {
487 val MaxCompactWidth = 600.dp
488 val MaxSheetWidth = 640.dp
489 val MinScrimHeight = 56.dp
490 val StartPadding = 56.dp
491 val EndPadding = 56.dp
492
493 /**
494 * The default elevation used by [ModalBottomSheetLayout].
495 */
496 val Elevation = 16.dp
497
498 /**
499 * The default scrim color used by [ModalBottomSheetLayout].
500 */
501 val scrimColor: Color
502 @Composable
503 get() = MaterialTheme.colorScheme.scrim.copy(alpha = .32f)
504 }