Skip to content

Instantly share code, notes, and snippets.

@dkaera
Last active April 29, 2023 12:14
Show Gist options
  • Save dkaera/052230301740b0bd1f4c88493e1186de to your computer and use it in GitHub Desktop.
Save dkaera/052230301740b0bd1f4c88493e1186de to your computer and use it in GitHub Desktop.
Extends the list of configuration options by the frontLayerOffset.
/*
* Copyright 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.example.app.ui.widgets
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.UiComposable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.collapse
import androidx.compose.ui.semantics.expand
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.*
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.util.fastMap
import androidx.compose.ui.zIndex
import com.example.app.ui.widgets.BackdropValue.Concealed
import com.example.app.ui.widgets.BackdropValue.Revealed
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
/**
* Possible values of [BackdropScaffoldState].
*/
@ExperimentalMaterialApi
enum class BackdropValue {
/**
* Indicates the back layer is concealed and the front layer is active.
*/
Concealed,
/**
* Indicates the back layer is revealed and the front layer is inactive.
*/
Revealed
}
/**
* State of the [AppBackdropScaffold] composable.
*
* @param initialValue The initial value of the state.
* @param animationSpec The default animation that will be used to animate to a new state.
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
* @param snackbarHostState The [SnackbarHostState] used to show snackbars inside the scaffold.
*/
@ExperimentalMaterialApi
@Stable
class AppBackdropScaffoldState(
initialValue: BackdropValue,
animationSpec: AnimationSpec<Float> = SwipeableDefaults.AnimationSpec,
val confirmStateChange: (BackdropValue) -> Boolean = { true },
val snackbarHostState: SnackbarHostState = SnackbarHostState()
) : SwipeableState<BackdropValue>(
initialValue = initialValue,
animationSpec = animationSpec,
confirmStateChange = confirmStateChange
) {
/**
* Whether the back layer is revealed.
*/
val isRevealed: Boolean
get() = currentValue == Revealed
/**
* Whether the back layer is concealed.
*/
val isConcealed: Boolean
get() = currentValue == Concealed
/**
* Reveal the back layer with animation and suspend until it if fully revealed or animation
* has been cancelled. This method will throw [CancellationException] if the animation is
* interrupted
*
* @return the reason the reveal animation ended
*/
suspend fun reveal() = animateTo(targetValue = Revealed)
/**
* Conceal the back layer with animation and suspend until it if fully concealed or animation
* has been cancelled. This method will throw [CancellationException] if the animation is
* interrupted
*
* @return the reason the conceal animation ended
*/
suspend fun conceal() = animateTo(targetValue = Concealed)
internal val nestedScrollConnection = this.PreUpPostDownNestedScrollConnection
companion object {
/**
* The default [Saver] implementation for [AppBackdropScaffoldState].
*/
fun Saver(
animationSpec: AnimationSpec<Float>,
confirmStateChange: (BackdropValue) -> Boolean,
snackbarHostState: SnackbarHostState
): Saver<AppBackdropScaffoldState, *> = Saver(
save = { it.currentValue },
restore = {
AppBackdropScaffoldState(
initialValue = it,
animationSpec = animationSpec,
confirmStateChange = confirmStateChange,
snackbarHostState = snackbarHostState
)
}
)
}
}
/**
* Create and [remember] a [AppBackdropScaffoldState].
*
* @param initialValue The initial value of the state.
* @param animationSpec The default animation that will be used to animate to a new state.
* @param confirmStateChange Optional callback invoked to confirm or veto a pending state change.
* @param snackbarHostState The [SnackbarHostState] used to show snackbars inside the scaffold.
*/
@Composable
@ExperimentalMaterialApi
fun rememberBackdropScaffoldState(
initialValue: BackdropValue,
animationSpec: AnimationSpec<Float> = SwipeableDefaults.AnimationSpec,
confirmStateChange: (BackdropValue) -> Boolean = { true },
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
): AppBackdropScaffoldState {
return rememberSaveable(
animationSpec,
confirmStateChange,
snackbarHostState,
saver = AppBackdropScaffoldState.Saver(
animationSpec = animationSpec,
confirmStateChange = confirmStateChange,
snackbarHostState = snackbarHostState
)
) {
AppBackdropScaffoldState(
initialValue = initialValue,
animationSpec = animationSpec,
confirmStateChange = confirmStateChange,
snackbarHostState = snackbarHostState
)
}
}
/**
* <a href="https://material.io/components/backdrop" class="external" target="_blank">Material Design backdrop</a>.
*
* A backdrop appears behind all other surfaces in an app, displaying contextual and actionable
* content.
*
* ![Backdrop image](https://developer.android.com/images/reference/androidx/compose/material/backdrop.png)
*
* This component provides an API to put together several material components to construct your
* screen. For a similar component which implements the basic material design layout strategy
* with app bars, floating action buttons and navigation drawers, use the standard [Scaffold].
* For similar component that uses a bottom sheet as the centerpiece of the screen, use
* [BottomSheetScaffold].
*
* Either the back layer or front layer can be active at a time. When the front layer is active,
* it sits at an offset below the top of the screen. This is the [peekHeight] and defaults to
* 56dp which is the default app bar height. When the front layer is inactive, it sticks to the
* height of the back layer's content if [stickyFrontLayer] is set to `true` and the height of
* the front layer exceeds the [headerHeight], and otherwise it minimizes to the [headerHeight].
* To switch between the back layer and front layer, you can either swipe on the front layer if
* [gesturesEnabled] is set to `true` or use any of the methods in [BackdropScaffoldState].
*
* The scaffold also contains an app bar, which by default is placed above the back layer's
* content. If [persistentAppBar] is set to `false`, then the backdrop will not show the app bar
* when the back layer is revealed; instead it will switch between the app bar and the provided
* content with an animation. For best results, the [peekHeight] should match the app bar height.
* To show a snackbar, use the method `showSnackbar` of [BackdropScaffoldState.snackbarHostState].
*
* A simple example of a backdrop scaffold looks like this:
*
* @sample androidx.compose.material.samples.BackdropScaffoldSample
*
* @param appBar App bar for the back layer. Make sure that the [peekHeight] is equal to the
* height of the app bar, so that the app bar is fully visible. Consider using [TopAppBar] but
* set the elevation to 0dp and background color to transparent as a surface is already provided.
* @param backLayerContent The content of the back layer.
* @param frontLayerContent The content of the front layer.
* @param modifier Optional [Modifier] for the root of the scaffold.
* @param scaffoldState The state of the scaffold.
* @param gesturesEnabled Whether or not the backdrop can be interacted with by gestures.
* @param peekHeight The height of the visible part of the back layer when it is concealed.
* @param headerHeight The minimum height of the front layer when it is inactive.
* @param persistentAppBar Whether the app bar should be shown when the back layer is revealed.
* By default, it will always be shown above the back layer's content. If this is set to `false`,
* the back layer will automatically switch between the app bar and its content with an animation.
* @param stickyFrontLayer Whether the front layer should stick to the height of the back layer.
* @param backLayerBackgroundColor The background color of the back layer.
* @param backLayerContentColor The preferred content color provided by the back layer to its
* children. Defaults to the matching content color for [backLayerBackgroundColor], or if that
* is not a color from the theme, this will keep the same content color set above the back layer.
* @param frontLayerShape The shape of the front layer.
* @param frontLayerElevation The elevation of the front layer.
* @param frontLayerBackgroundColor The background color of the front layer.
* @param frontLayerContentColor The preferred content color provided by the back front to its
* children. Defaults to the matching content color for [frontLayerBackgroundColor], or if that
* is not a color from the theme, this will keep the same content color set above the front layer.
* @param frontLayerScrimColor The color of the scrim applied to the front layer when the back
* layer is revealed. If the color passed is [Color.Unspecified], then a scrim will not be
* applied and interaction with the front layer will not be blocked when the back layer is revealed.
* @param snackbarHost The component hosting the snackbars shown inside the scaffold.
*/
@Composable
@ExperimentalMaterialApi
fun AppBackdropScaffold(
appBar: @Composable () -> Unit,
backLayerContent: @Composable () -> Unit,
frontLayerContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
scaffoldState: AppBackdropScaffoldState = rememberBackdropScaffoldState(Concealed),
gesturesEnabled: Boolean = true,
peekHeight: Dp = BackdropScaffoldDefaults.PeekHeight,
headerHeight: Dp = BackdropScaffoldDefaults.HeaderHeight,
persistentAppBar: Boolean = true,
stickyFrontLayer: Boolean = true,
backLayerBackgroundColor: Color = MaterialTheme.colors.primary,
backLayerContentColor: Color = contentColorFor(backLayerBackgroundColor),
stickyFrontLayerOffset: Dp = 0.dp,
frontLayerShape: Shape = BackdropScaffoldDefaults.frontLayerShape,
frontLayerElevation: Dp = BackdropScaffoldDefaults.FrontLayerElevation,
frontLayerBackgroundColor: Color = MaterialTheme.colors.surface,
frontLayerContentColor: Color = contentColorFor(frontLayerBackgroundColor),
frontLayerScrimColor: Color = BackdropScaffoldDefaults.frontLayerScrimColor,
snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) }
) {
val peekHeightPx = with(LocalDensity.current) { peekHeight.toPx() }
val headerHeightPx = with(LocalDensity.current) { headerHeight.toPx() }
val stickyFrontLayerOffsetPx = with(LocalDensity.current) { stickyFrontLayerOffset.toPx() }
val backLayer = @Composable {
if (persistentAppBar) {
Column {
appBar()
backLayerContent()
}
} else {
BackLayerTransition(
scaffoldState.targetValue,
appBar,
backLayerContent
)
}
}
val calculateBackLayerConstraints: (Constraints) -> Constraints = {
it.copy(minWidth = 0, minHeight = 0).offset(vertical = -headerHeightPx.roundToInt())
}
// Back layer
Surface(
color = backLayerBackgroundColor,
contentColor = backLayerContentColor
) {
val scope = rememberCoroutineScope()
BackdropStack(
modifier.fillMaxSize(),
backLayer,
calculateBackLayerConstraints
) { constraints, backLayerHeight ->
val fullHeight = constraints.maxHeight.toFloat()
var revealedHeight = fullHeight - headerHeightPx
if (stickyFrontLayer) {
revealedHeight = min(revealedHeight, backLayerHeight) - stickyFrontLayerOffsetPx
}
val nestedScroll = if (gesturesEnabled) {
Modifier.nestedScroll(scaffoldState.nestedScrollConnection)
} else {
Modifier
}
val swipeable = Modifier
.then(nestedScroll)
.swipeable(
state = scaffoldState,
anchors = mapOf(
peekHeightPx to Concealed,
revealedHeight to Revealed
),
orientation = Orientation.Vertical,
enabled = gesturesEnabled
)
.semantics {
if (scaffoldState.isConcealed) {
collapse {
if (scaffoldState.confirmStateChange(Revealed)) {
scope.launch { scaffoldState.reveal() }
}; true
}
} else {
expand {
if (scaffoldState.confirmStateChange(Concealed)) {
scope.launch { scaffoldState.conceal() }
}; true
}
}
}
// Front layer
Surface(
Modifier
.offset {
IntOffset(
0,
scaffoldState.offset.value.roundToInt()
)
}
.then(swipeable),
shape = frontLayerShape,
elevation = frontLayerElevation,
color = frontLayerBackgroundColor,
contentColor = frontLayerContentColor
) {
Box(Modifier.padding(bottom = peekHeight)) {
frontLayerContent()
Scrim(
color = frontLayerScrimColor,
onDismiss = {
if (gesturesEnabled && scaffoldState.confirmStateChange(Concealed)) {
scope.launch { scaffoldState.conceal() }
}
},
visible = scaffoldState.targetValue == Revealed
)
}
}
// Snackbar host
Box(
Modifier
.padding(
bottom = if (scaffoldState.isRevealed &&
revealedHeight == fullHeight - headerHeightPx
) headerHeight else 0.dp
),
contentAlignment = Alignment.BottomCenter
) {
snackbarHost(scaffoldState.snackbarHostState)
}
}
}
}
@Composable
private fun Scrim(
color: Color,
onDismiss: () -> Unit,
visible: Boolean
) {
if (color.isSpecified) {
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = TweenSpec()
)
val dismissModifier = if (visible) {
Modifier.pointerInput(Unit) { detectTapGestures { onDismiss() } }
} else {
Modifier
}
Canvas(
Modifier
.fillMaxSize()
.then(dismissModifier)
) {
drawRect(color = color, alpha = alpha)
}
}
}
/**
* A shared axis transition, used in the back layer. Both the [appBar] and the [content] shift
* vertically, while they crossfade. It is very important that both are composed and measured,
* even if invisible, and that this component is as large as both of them.
*/
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun BackLayerTransition(
target: BackdropValue,
appBar: @Composable () -> Unit,
content: @Composable () -> Unit
) {
// The progress of the animation between Revealed (0) and Concealed (2).
// The midpoint (1) is the point where the appBar and backContent are switched.
val animationProgress by animateFloatAsState(
targetValue = if (target == Revealed) 0f else 2f, animationSpec = TweenSpec()
)
val animationSlideOffset = with(LocalDensity.current) { AnimationSlideOffset.toPx() }
val appBarFloat = (animationProgress - 1).coerceIn(0f, 1f)
val contentFloat = (1 - animationProgress).coerceIn(0f, 1f)
Box {
Box(
Modifier
.zIndex(appBarFloat)
.graphicsLayer(
alpha = appBarFloat,
translationY = (1 - appBarFloat) * animationSlideOffset
)
) {
appBar()
}
Box(
Modifier
.zIndex(contentFloat)
.graphicsLayer(
alpha = contentFloat,
translationY = (1 - contentFloat) * -animationSlideOffset
)
) {
content()
}
}
}
@Composable
@UiComposable
private fun BackdropStack(
modifier: Modifier,
backLayer: @Composable @UiComposable () -> Unit,
calculateBackLayerConstraints: (Constraints) -> Constraints,
frontLayer: @Composable @UiComposable (Constraints, Float) -> Unit
) {
SubcomposeLayout(modifier) { constraints ->
val backLayerPlaceable =
subcompose(BackdropLayers.Back, backLayer).first()
.measure(calculateBackLayerConstraints(constraints))
val backLayerHeight = backLayerPlaceable.height.toFloat()
val placeables =
subcompose(BackdropLayers.Front) {
frontLayer(constraints, backLayerHeight)
}.fastMap { it.measure(constraints) }
var maxWidth = max(constraints.minWidth, backLayerPlaceable.width)
var maxHeight = max(constraints.minHeight, backLayerPlaceable.height)
placeables.fastForEach {
maxWidth = max(maxWidth, it.width)
maxHeight = max(maxHeight, it.height)
}
layout(maxWidth, maxHeight) {
backLayerPlaceable.placeRelative(0, 0)
placeables.fastForEach { it.placeRelative(0, 0) }
}
}
}
private enum class BackdropLayers { Back, Front }
/**
* Contains useful defaults for [AppBackdropScaffold].
*/
object BackdropScaffoldDefaults {
/**
* The default peek height of the back layer.
*/
val PeekHeight = 56.dp
/**
* The default header height of the front layer.
*/
val HeaderHeight = 48.dp
/**
* The default shape of the front layer.
*/
val frontLayerShape: Shape
@Composable
get() = MaterialTheme.shapes.large
.copy(topStart = CornerSize(16.dp), topEnd = CornerSize(16.dp))
/**
* The default elevation of the front layer.
*/
val FrontLayerElevation = 1.dp
/**
* The default color of the scrim applied to the front layer.
*/
val frontLayerScrimColor: Color
@Composable get() = MaterialTheme.colors.surface.copy(alpha = 0.60f)
}
private val AnimationSlideOffset = 20.dp
internal var minBound = Float.NEGATIVE_INFINITY
// temp default nested scroll connection for swipeables which desire as an opt in
// revisit in b/174756744 as all types will have their own specific connection probably
@ExperimentalMaterialApi
internal val <T> SwipeableState<T>.PreUpPostDownNestedScrollConnection: NestedScrollConnection
get() = object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.toFloat()
return if (delta < 0 && source == NestedScrollSource.Drag) {
performDrag(delta).toOffset()
} else {
Offset.Zero
}
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
return if (source == NestedScrollSource.Drag) {
performDrag(available.toFloat()).toOffset()
} else {
Offset.Zero
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
val toFling = Offset(available.x, available.y).toFloat()
return if (toFling < 0 && offset.value > minBound) {
performFling(velocity = toFling)
// since we go to the anchor with tween settling, consume all for the best UX
available
} else {
Velocity.Zero
}
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
performFling(velocity = Offset(available.x, available.y).toFloat())
return available
}
private fun Float.toOffset(): Offset = Offset(0f, this)
private fun Offset.toFloat(): Float = this.y
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment