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, 111 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, 129 confirmStateChange: (ModalBottomSheetValue) -> Boolean = { true } 130 ) : this(initialValue, animationSpec, isSkipHalfExpanded = false, confirmStateChange) 131 132 init { 133 if (isSkipHalfExpanded) { 134 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 */ 148 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 */ 162 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 */ 175 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 245 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 ) 258 ) { 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 276 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 *  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 312 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") 415 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 449 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 }