1 /*
<lambda>null2  * Copyright 2020 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 androidx.compose.material
18 
19 import androidx.annotation.FloatRange
20 import androidx.compose.animation.core.AnimationSpec
21 import androidx.compose.animation.core.FastOutSlowInEasing
22 import androidx.compose.animation.core.TweenSpec
23 import androidx.compose.animation.core.animateFloatAsState
24 import androidx.compose.animation.core.tween
25 import androidx.compose.foundation.Canvas
26 import androidx.compose.foundation.gestures.Orientation
27 import androidx.compose.foundation.gestures.detectTapGestures
28 import androidx.compose.foundation.layout.Box
29 import androidx.compose.foundation.layout.Column
30 import androidx.compose.foundation.layout.ColumnScope
31 import androidx.compose.foundation.layout.fillMaxSize
32 import androidx.compose.foundation.layout.fillMaxWidth
33 import androidx.compose.foundation.layout.widthIn
34 import androidx.compose.material.ModalBottomSheetState.Companion.Saver
35 import androidx.compose.material.ModalBottomSheetValue.Expanded
36 import androidx.compose.material.ModalBottomSheetValue.HalfExpanded
37 import androidx.compose.material.ModalBottomSheetValue.Hidden
38 import androidx.compose.runtime.Composable
39 import androidx.compose.runtime.getValue
40 import androidx.compose.runtime.key
41 import androidx.compose.runtime.remember
42 import androidx.compose.runtime.rememberCoroutineScope
43 import androidx.compose.runtime.saveable.Saver
44 import androidx.compose.runtime.saveable.rememberSaveable
45 import androidx.compose.ui.Alignment
46 import androidx.compose.ui.Modifier
47 import androidx.compose.ui.geometry.Offset
48 import androidx.compose.ui.graphics.Color
49 import androidx.compose.ui.graphics.Shape
50 import androidx.compose.ui.graphics.isSpecified
51 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
52 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
53 import androidx.compose.ui.input.nestedscroll.nestedScroll
54 import androidx.compose.ui.input.pointer.pointerInput
55 import androidx.compose.ui.platform.LocalDensity
56 import androidx.compose.ui.semantics.collapse
57 import androidx.compose.ui.semantics.contentDescription
58 import androidx.compose.ui.semantics.dismiss
59 import androidx.compose.ui.semantics.expand
60 import androidx.compose.ui.semantics.onClick
61 import androidx.compose.ui.semantics.semantics
62 import androidx.compose.ui.unit.Density
63 import androidx.compose.ui.unit.Dp
64 import androidx.compose.ui.unit.Velocity
65 import androidx.compose.ui.unit.dp
66 import kotlin.jvm.JvmName
67 import kotlin.math.abs
68 import kotlin.math.max
69 import kotlin.math.min
70 import kotlinx.coroutines.launch
71 
72 /** Possible values of [ModalBottomSheetState]. */
73 enum class ModalBottomSheetValue {
74     /** The bottom sheet is not visible. */
75     Hidden,
76 
77     /** The bottom sheet is visible at full height. */
78     Expanded,
79 
80     /**
81      * The bottom sheet is partially visible at 50% of the screen height. This state is only enabled
82      * if the height of the bottom sheet is more than 50% of the screen height.
83      */
84     HalfExpanded
85 }
86 
87 /**
88  * State of the [ModalBottomSheetLayout] composable.
89  *
90  * @param initialValue The initial value of the state. <b>Must not be set to
91  *   [ModalBottomSheetValue.HalfExpanded] if [isSkipHalfExpanded] is set to true.</b>
92  * @param density The density that this state can use to convert values to and from dp.
93  * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
94  * @param animationSpec The default animation that will be used to animate to a new state.
95  * @param isSkipHalfExpanded Whether the half expanded state, if the sheet is tall enough, should be
96  *   skipped. If true, the sheet will always expand to the [Expanded] state and move to the [Hidden]
97  *   state when hiding the sheet, either programmatically or by user interaction. <b>Must not be set
98  *   to true if the initialValue is [ModalBottomSheetValue.HalfExpanded].</b> If supplied with
99  *   [ModalBottomSheetValue.HalfExpanded] for the initialValue, an [IllegalArgumentException] will
100  *   be thrown.
101  */
102 @OptIn(ExperimentalMaterialApi::class)
103 class ModalBottomSheetState(
104     initialValue: ModalBottomSheetValue,
105     density: Density,
<lambda>null106     confirmValueChange: (ModalBottomSheetValue) -> Boolean = { true },
107     internal val animationSpec: AnimationSpec<Float> = ModalBottomSheetDefaults.AnimationSpec,
108     internal val isSkipHalfExpanded: Boolean = false,
109 ) {
110 
111     internal val anchoredDraggableState =
112         AnchoredDraggableState(
113             initialValue = initialValue,
114             animationSpec = animationSpec,
115             confirmValueChange = confirmValueChange,
<lambda>null116             positionalThreshold = { with(density) { ModalBottomSheetPositionalThreshold.toPx() } },
<lambda>null117             velocityThreshold = { with(density) { ModalBottomSheetVelocityThreshold.toPx() } }
118         )
119 
120     /** The current value of the [ModalBottomSheetState]. */
121     val currentValue: ModalBottomSheetValue
122         get() = anchoredDraggableState.currentValue
123 
124     /**
125      * The target value the state will settle at once the current interaction ends, or the
126      * [currentValue] if there is no interaction in progress.
127      */
128     val targetValue: ModalBottomSheetValue
129         get() = anchoredDraggableState.targetValue
130 
131     /**
132      * The fraction of the progress, within [0f..1f] bounds, or 1f if the [AnchoredDraggableState]
133      * is in a settled state.
134      */
135     @Deprecated(
136         message = "Please use the progress function to query progress explicitly between targets.",
137         replaceWith = ReplaceWith("progress(from = , to = )")
138     )
139     @get:FloatRange(from = 0.0, to = 1.0)
140     @ExperimentalMaterialApi
141     val progress: Float
142         get() = anchoredDraggableState.progress
143 
144     /**
145      * The fraction of the offset between [from] and [to], as a fraction between [0f..1f], or 1f if
146      * [from] is equal to [to].
147      *
148      * @param from The starting value used to calculate the distance
149      * @param to The end value used to calculate the distance
150      */
151     @FloatRange(from = 0.0, to = 1.0)
progressnull152     fun progress(from: ModalBottomSheetValue, to: ModalBottomSheetValue): Float {
153         val fromOffset = anchoredDraggableState.anchors.positionOf(from)
154         val toOffset = anchoredDraggableState.anchors.positionOf(to)
155         val currentOffset =
156             anchoredDraggableState.offset.coerceIn(
157                 min(fromOffset, toOffset), // fromOffset might be > toOffset
158                 max(fromOffset, toOffset)
159             )
160         val fraction = (currentOffset - fromOffset) / (toOffset - fromOffset)
161         return if (fraction.isNaN()) 1f else abs(fraction)
162     }
163 
164     /** Whether the bottom sheet is visible. */
165     val isVisible: Boolean
166         get() = anchoredDraggableState.currentValue != Hidden
167 
168     internal val hasHalfExpandedState: Boolean
169         get() = anchoredDraggableState.anchors.hasAnchorFor(HalfExpanded)
170 
171     init {
172         if (isSkipHalfExpanded) {
<lambda>null173             require(initialValue != HalfExpanded) {
174                 "The initial value must not be set to HalfExpanded if skipHalfExpanded is set to" +
175                     " true."
176             }
177         }
178     }
179 
180     /**
181      * Show the bottom sheet with animation and suspend until it's shown. If the sheet is taller
182      * than 50% of the parent's height, the bottom sheet will be half expanded. Otherwise it will be
183      * fully expanded.
184      */
shownull185     suspend fun show() {
186         val hasExpandedState = anchoredDraggableState.anchors.hasAnchorFor(Expanded)
187         val targetValue =
188             when (currentValue) {
189                 Hidden -> if (hasHalfExpandedState) HalfExpanded else Expanded
190                 else -> if (hasExpandedState) Expanded else Hidden
191             }
192         animateTo(targetValue)
193     }
194 
195     /**
196      * Half expand the bottom sheet if half expand is enabled with animation and suspend until it
197      * animation is complete or cancelled.
198      */
halfExpandnull199     internal suspend fun halfExpand() {
200         if (!hasHalfExpandedState) {
201             return
202         }
203         animateTo(HalfExpanded)
204     }
205 
206     /**
207      * Hide the bottom sheet with animation and suspend until it if fully hidden or animation has
208      * been cancelled.
209      */
hidenull210     suspend fun hide() = animateTo(Hidden)
211 
212     /**
213      * Fully expand the bottom sheet with animation and suspend until it if fully expanded or
214      * animation has been cancelled.
215      */
216     internal suspend fun expand() {
217         if (!anchoredDraggableState.anchors.hasAnchorFor(Expanded)) {
218             return
219         }
220         animateTo(Expanded)
221     }
222 
animateTonull223     internal suspend fun animateTo(
224         target: ModalBottomSheetValue,
225         velocity: Float = anchoredDraggableState.lastVelocity
226     ) = anchoredDraggableState.animateTo(target, velocity)
227 
228     internal suspend fun snapTo(target: ModalBottomSheetValue) =
229         anchoredDraggableState.snapTo(target)
230 
231     internal fun requireOffset() = anchoredDraggableState.requireOffset()
232 
233     companion object {
234         /**
235          * The default [Saver] implementation for [ModalBottomSheetState]. Saves the [currentValue]
236          * and recreates a [ModalBottomSheetState] with the saved value as initial value.
237          */
238         fun Saver(
239             animationSpec: AnimationSpec<Float>,
240             confirmValueChange: (ModalBottomSheetValue) -> Boolean,
241             skipHalfExpanded: Boolean,
242             density: Density
243         ): Saver<ModalBottomSheetState, *> =
244             Saver(
245                 save = { it.currentValue },
246                 restore = {
247                     ModalBottomSheetState(
248                         initialValue = it,
249                         density = density,
250                         animationSpec = animationSpec,
251                         isSkipHalfExpanded = skipHalfExpanded,
252                         confirmValueChange = confirmValueChange
253                     )
254                 }
255             )
256     }
257 }
258 
259 /**
260  * Create a [ModalBottomSheetState] and [remember] it.
261  *
262  * @param initialValue The initial value of the state.
263  * @param animationSpec The default animation that will be used to animate to a new state.
264  * @param confirmValueChange Optional callback invoked to confirm or veto a pending state change.
265  * @param skipHalfExpanded Whether the half expanded state, if the sheet is tall enough, should be
266  *   skipped. If true, the sheet will always expand to the [Expanded] state and move to the [Hidden]
267  *   state when hiding the sheet, either programmatically or by user interaction. <b>Must not be set
268  *   to true if the [initialValue] is [ModalBottomSheetValue.HalfExpanded].</b> If supplied with
269  *   [ModalBottomSheetValue.HalfExpanded] for the [initialValue], an [IllegalArgumentException] will
270  *   be thrown.
271  */
272 @Composable
rememberModalBottomSheetStatenull273 fun rememberModalBottomSheetState(
274     initialValue: ModalBottomSheetValue,
275     animationSpec: AnimationSpec<Float> = ModalBottomSheetDefaults.AnimationSpec,
276     confirmValueChange: (ModalBottomSheetValue) -> Boolean = { true },
277     skipHalfExpanded: Boolean = false,
278 ): ModalBottomSheetState {
279     val density = LocalDensity.current
280     // Key the rememberSaveable against the initial value. If it changed we don't want to attempt
281     // to restore as the restored value could have been saved with a now invalid set of anchors.
282     // b/152014032
<lambda>null283     return key(initialValue) {
284         rememberSaveable(
285             initialValue,
286             animationSpec,
287             skipHalfExpanded,
288             confirmValueChange,
289             density,
290             saver =
291                 Saver(
292                     density = density,
293                     animationSpec = animationSpec,
294                     skipHalfExpanded = skipHalfExpanded,
295                     confirmValueChange = confirmValueChange
296                 )
297         ) {
298             ModalBottomSheetState(
299                 density = density,
300                 initialValue = initialValue,
301                 animationSpec = animationSpec,
302                 isSkipHalfExpanded = skipHalfExpanded,
303                 confirmValueChange = confirmValueChange
304             )
305         }
306     }
307 }
308 
309 /**
310  * [Material Design modal bottom
311  * sheet](https://material.io/components/sheets-bottom#modal-bottom-sheet)
312  *
313  * Modal bottom sheets present a set of choices while blocking interaction with the rest of the
314  * screen. They are an alternative to inline menus and simple dialogs, providing additional room for
315  * content, iconography, and actions.
316  *
317  * ![Modal bottom sheet
318  * image](https://developer.android.com/images/reference/androidx/compose/material/modal-bottom-sheet.png)
319  *
320  * A simple example of a modal bottom sheet looks like this:
321  *
322  * @sample androidx.compose.material.samples.ModalBottomSheetSample
323  * @param sheetContent The content of the bottom sheet.
324  * @param modifier Optional [Modifier] for the entire component.
325  * @param sheetState The state of the bottom sheet.
326  * @param sheetGesturesEnabled Whether the bottom sheet can be interacted with by gestures.
327  * @param sheetShape The shape of the bottom sheet.
328  * @param sheetElevation The elevation of the bottom sheet.
329  * @param sheetBackgroundColor The background color of the bottom sheet.
330  * @param sheetContentColor The preferred content color provided by the bottom sheet to its
331  *   children. Defaults to the matching content color for [sheetBackgroundColor], or if that is not
332  *   a color from the theme, this will keep the same content color set above the bottom sheet.
333  * @param scrimColor The color of the scrim that is applied to the rest of the screen when the
334  *   bottom sheet is visible. If the color passed is [Color.Unspecified], then a scrim will no
335  *   longer be applied and the bottom sheet will not block interaction with the rest of the screen
336  *   when visible.
337  * @param content The content of rest of the screen.
338  */
339 @OptIn(ExperimentalMaterialApi::class)
340 @Composable
341 // Keep defaults in sync with androidx.compose.material.navigation.ModalBottomSheetLayout
ModalBottomSheetLayoutnull342 fun ModalBottomSheetLayout(
343     sheetContent: @Composable ColumnScope.() -> Unit,
344     modifier: Modifier = Modifier,
345     sheetState: ModalBottomSheetState = rememberModalBottomSheetState(Hidden),
346     sheetGesturesEnabled: Boolean = true,
347     sheetShape: Shape = MaterialTheme.shapes.large,
348     sheetElevation: Dp = ModalBottomSheetDefaults.Elevation,
349     sheetBackgroundColor: Color = MaterialTheme.colors.surface,
350     sheetContentColor: Color = contentColorFor(sheetBackgroundColor),
351     scrimColor: Color = ModalBottomSheetDefaults.scrimColor,
352     content: @Composable () -> Unit
353 ) {
354     val scope = rememberCoroutineScope()
355     val orientation = Orientation.Vertical
356     Box(modifier) {
357         Box(Modifier.fillMaxSize()) {
358             content()
359             Scrim(
360                 color = scrimColor,
361                 onDismiss = {
362                     if (sheetState.anchoredDraggableState.confirmValueChange(Hidden)) {
363                         scope.launch { sheetState.hide() }
364                     }
365                 },
366                 visible = sheetState.anchoredDraggableState.targetValue != Hidden
367             )
368         }
369         Surface(
370             Modifier.align(Alignment.TopCenter) // We offset from the top so we'll center from there
371                 .widthIn(max = MaxModalBottomSheetWidth)
372                 .fillMaxWidth()
373                 .then(
374                     if (sheetGesturesEnabled) {
375                         Modifier.nestedScroll(
376                             remember(sheetState.anchoredDraggableState, orientation) {
377                                 ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
378                                     state = sheetState.anchoredDraggableState,
379                                     orientation = orientation
380                                 )
381                             }
382                         )
383                     } else Modifier
384                 )
385                 .modalBottomSheetAnchors(sheetState)
386                 .anchoredDraggable(
387                     state = sheetState.anchoredDraggableState,
388                     orientation = orientation,
389                     enabled =
390                         sheetGesturesEnabled &&
391                             sheetState.anchoredDraggableState.currentValue != Hidden,
392                 )
393                 .then(
394                     if (sheetGesturesEnabled) {
395                         Modifier.semantics {
396                             if (sheetState.isVisible) {
397                                 dismiss {
398                                     if (
399                                         sheetState.anchoredDraggableState.confirmValueChange(Hidden)
400                                     ) {
401                                         scope.launch { sheetState.hide() }
402                                     }
403                                     true
404                                 }
405                                 if (
406                                     sheetState.anchoredDraggableState.currentValue == HalfExpanded
407                                 ) {
408                                     expand {
409                                         if (
410                                             sheetState.anchoredDraggableState.confirmValueChange(
411                                                 Expanded
412                                             )
413                                         ) {
414                                             scope.launch { sheetState.expand() }
415                                         }
416                                         true
417                                     }
418                                 } else if (sheetState.hasHalfExpandedState) {
419                                     collapse {
420                                         if (
421                                             sheetState.anchoredDraggableState.confirmValueChange(
422                                                 HalfExpanded
423                                             )
424                                         ) {
425                                             scope.launch { sheetState.halfExpand() }
426                                         }
427                                         true
428                                     }
429                                 }
430                             }
431                         }
432                     } else Modifier
433                 ),
434             shape = sheetShape,
435             elevation = sheetElevation,
436             color = sheetBackgroundColor,
437             contentColor = sheetContentColor
438         ) {
439             Column(content = sheetContent)
440         }
441     }
442 }
443 
444 @OptIn(ExperimentalMaterialApi::class)
modalBottomSheetAnchorsnull445 private fun Modifier.modalBottomSheetAnchors(sheetState: ModalBottomSheetState) =
446     draggableAnchors(
447         state = sheetState.anchoredDraggableState,
448         orientation = Orientation.Vertical
449     ) { sheetSize, constraints ->
450         val fullHeight = constraints.maxHeight.toFloat()
451         val newAnchors = DraggableAnchors {
452             Hidden at fullHeight
453             val halfHeight = fullHeight / 2f
454             if (!sheetState.isSkipHalfExpanded && sheetSize.height > halfHeight) {
455                 HalfExpanded at halfHeight
456             }
457             if (sheetSize.height != 0) {
458                 Expanded at max(0f, fullHeight - sheetSize.height)
459             }
460         }
461         // If we are setting the anchors for the first time and have an anchor for
462         // the current (initial) value, prefer that
463         val isInitialized = sheetState.anchoredDraggableState.anchors.size > 0
464         val previousValue = sheetState.currentValue
465         val newTarget =
466             if (!isInitialized && newAnchors.hasAnchorFor(previousValue)) {
467                 previousValue
468             } else {
469                 when (sheetState.targetValue) {
470                     Hidden -> Hidden
471                     HalfExpanded,
472                     Expanded -> {
473                         val hasHalfExpandedState = newAnchors.hasAnchorFor(HalfExpanded)
474                         val newTarget =
475                             if (hasHalfExpandedState) {
476                                 HalfExpanded
477                             } else if (newAnchors.hasAnchorFor(Expanded)) {
478                                 Expanded
479                             } else {
480                                 Hidden
481                             }
482                         newTarget
483                     }
484                 }
485             }
486         return@draggableAnchors newAnchors to newTarget
487     }
488 
489 @Composable
Scrimnull490 private fun Scrim(color: Color, onDismiss: () -> Unit, visible: Boolean) {
491     if (color.isSpecified) {
492         val alpha by
493             animateFloatAsState(targetValue = if (visible) 1f else 0f, animationSpec = TweenSpec())
494         val closeSheet = getString(Strings.CloseSheet)
495         val dismissModifier =
496             if (visible) {
497                 Modifier.pointerInput(onDismiss) { detectTapGestures { onDismiss() } }
498                     .semantics(mergeDescendants = true) {
499                         contentDescription = closeSheet
500                         onClick {
501                             onDismiss()
502                             true
503                         }
504                     }
505             } else {
506                 Modifier
507             }
508 
509         Canvas(Modifier.fillMaxSize().then(dismissModifier)) {
510             drawRect(color = color, alpha = alpha.coerceIn(0f, 1f))
511         }
512     }
513 }
514 
515 /** Contains useful Defaults for [ModalBottomSheetLayout]. */
516 object ModalBottomSheetDefaults {
517 
518     /** The default elevation used by [ModalBottomSheetLayout]. */
519     val Elevation = 16.dp
520 
521     /** The default scrim color used by [ModalBottomSheetLayout]. */
522     val scrimColor: Color
523         @Composable get() = MaterialTheme.colors.onSurface.copy(alpha = 0.32f)
524 
525     /** The default animation spec used by [ModalBottomSheetState]. */
526     val AnimationSpec: AnimationSpec<Float> =
527         tween(durationMillis = 300, easing = FastOutSlowInEasing)
528 }
529 
530 @OptIn(ExperimentalMaterialApi::class)
ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnectionnull531 private fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
532     state: AnchoredDraggableState<*>,
533     orientation: Orientation
534 ): NestedScrollConnection =
535     object : NestedScrollConnection {
536         override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
537             val delta = available.toFloat()
538             return if (delta < 0 && source == NestedScrollSource.UserInput) {
539                 state.dispatchRawDelta(delta).toOffset()
540             } else {
541                 Offset.Zero
542             }
543         }
544 
545         override fun onPostScroll(
546             consumed: Offset,
547             available: Offset,
548             source: NestedScrollSource
549         ): Offset {
550             return if (source == NestedScrollSource.UserInput) {
551                 state.dispatchRawDelta(available.toFloat()).toOffset()
552             } else {
553                 Offset.Zero
554             }
555         }
556 
557         override suspend fun onPreFling(available: Velocity): Velocity {
558             val toFling = available.toFloat()
559             val currentOffset = state.requireOffset()
560             return if (toFling < 0 && currentOffset > state.anchors.minAnchor()) {
561                 state.settle(velocity = toFling)
562                 // since we go to the anchor with tween settling, consume all for the best UX
563                 available
564             } else {
565                 Velocity.Zero
566             }
567         }
568 
569         override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
570             state.settle(velocity = available.toFloat())
571             return available
572         }
573 
574         private fun Float.toOffset(): Offset =
575             Offset(
576                 x = if (orientation == Orientation.Horizontal) this else 0f,
577                 y = if (orientation == Orientation.Vertical) this else 0f
578             )
579 
580         @JvmName("velocityToFloat")
581         private fun Velocity.toFloat() = if (orientation == Orientation.Horizontal) x else y
582 
583         @JvmName("offsetToFloat")
584         private fun Offset.toFloat(): Float = if (orientation == Orientation.Horizontal) x else y
585     }
586 
587 private val ModalBottomSheetPositionalThreshold = 56.dp
588 private val ModalBottomSheetVelocityThreshold = 125.dp
589 private val MaxModalBottomSheetWidth = 640.dp
590