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 }