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 * 
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