SwipeToDismissBox.kt

/*
 * Copyright 2021 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 androidx.wear.compose.material

import androidx.compose.animation.core.AnimationSpec
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.IntOffset
import kotlin.math.roundToInt

/**
 * Wear Material [SwipeToDismissBox] that handles the swipe-to-dismiss gesture. Takes two slots,
 * the background (only displayed during the swipe gesture) and a content slot.
 *
 * Example usage:
 * @sample androidx.wear.compose.material.samples.SimpleSwipeToDismissBox
 *
 * @param state State containing information about ongoing swipe or animation.
 * @param modifier Optional [Modifier] for this component.
 * @param background Optional slot for content to be displayed behind the foreground content -
 * the background is normally hidden, is shown behind a scrim during the swipe gesture,
 * and is shown without scrim once the finger passes the swipe-to-dismiss threshold.
 * @param scrimColor Optional [Color] used for the scrim over the background and
 * content composables during the swipe gesture. The alpha on the color is ignored,
 * instead being set individually for each of the background and content layers in order to
 * indicate to the user which state will result if the gesture is released.
 */
@Composable
@ExperimentalWearMaterialApi
fun SwipeToDismissBox(
    state: SwipeToDismissBoxState,
    modifier: Modifier = Modifier,
    background: (@Composable BoxScope.() -> Unit)? = null,
    scrimColor: Color = MaterialTheme.colors.surface,
    content: @Composable BoxScope.() -> Unit
) = BoxWithConstraints(modifier) {
    val maxWidth = constraints.maxWidth.toFloat()
    // Map pixel position to states - initially, don't know the width in pixels so omit upper bound.
    val anchors =
        mapOf(
            0f to SwipeDismissTarget.Original,
            maxWidth to SwipeDismissTarget.Dismissal
        )

    Box(
        modifier = Modifier
            .fillMaxSize()
            .swipeable(
                state = state,
                anchors = anchors,
                thresholds = { _, _ -> FractionalThreshold(SwipeThreshold) },
                orientation = Orientation.Horizontal
            )
    ) {
        val offsetPx = state.offset.value.roundToInt()
        if (background != null && offsetPx > 0) {
            Box(
                modifier = Modifier.fillMaxSize()
            ) {
                background()
                // TODO(b/193606660): Add animations that follow after swipe confirmation.
                val backgroundScrimAlpha =
                    if (state.targetValue == SwipeDismissTarget.Original) {
                        SwipeStartedBackgroundAlpha
                    } else {
                        SwipeConfirmedBackgroundAlpha
                    }
                Box(
                    modifier =
                        Modifier
                            .matchParentSize()
                            .background(
                                scrimColor.copy(alpha = backgroundScrimAlpha)
                            )
                )
            }
        }
        Box(
            Modifier
                .offset { IntOffset(offsetPx, 0) }
                .fillMaxSize()
        ) {
            content()
            val contentScrimAlpha =
                if (state.targetValue == SwipeDismissTarget.Original) {
                    SwipeStartedContentAlpha
                } else {
                    SwipeConfirmedContentAlpha
                }
            Box(
                modifier = Modifier
                    .matchParentSize()
                    .background(
                        scrimColor.copy(alpha = contentScrimAlpha)
                    )
            )
        }
    }
}

@Stable
/**
 * State for [SwipeToDismissBox].
 *
 * TODO(b/194492134): extend API to include shortcuts for status and actions like dismissing
 * the screen.
 */
@ExperimentalWearMaterialApi
class SwipeToDismissBoxState(
    animationSpec: AnimationSpec<Float> = SwipeToDismissBoxDefaults.AnimationSpec,
    confirmStateChange: (SwipeDismissTarget) -> Boolean = { true },
) : SwipeableState<SwipeDismissTarget>(
    initialValue = SwipeDismissTarget.Original,
    animationSpec = animationSpec,
    confirmStateChange = confirmStateChange,
) {
    companion object {
        /**
         * The default [Saver] implementation for [SwipeToDismissBox].
         */
        fun Saver(
            animationSpec: AnimationSpec<Float>,
            confirmStateChange: (SwipeDismissTarget) -> Boolean
        ): Saver<SwipeToDismissBoxState, *> = Saver(
            save = { it.currentValue },
            restore = {
                SwipeToDismissBoxState(
                    animationSpec = animationSpec,
                    confirmStateChange = confirmStateChange
                )
            }
        )
    }
}

/**
 * Create a [SwipeToDismissBoxState] and remember it.
 *
 * @param animationSpec The default animation used to animate to a new state.
 * @param confirmStateChange Optional callback to confirm or veto a pending state change.
 */
@Composable
@ExperimentalWearMaterialApi
fun rememberSwipeToDismissBoxState(
    animationSpec: AnimationSpec<Float> = SwipeToDismissBoxDefaults.AnimationSpec,
    confirmStateChange: (SwipeDismissTarget) -> Boolean = { true },
): SwipeToDismissBoxState {
    return rememberSaveable(
        saver = SwipeToDismissBoxState.Saver(
            animationSpec = animationSpec,
            confirmStateChange = confirmStateChange,
        )
    ) {
        SwipeToDismissBoxState(
            animationSpec = animationSpec,
            confirmStateChange = confirmStateChange,
        )
    }
}

/**
 * Contains defaults for [SwipeToDismissBox].
 */
@ExperimentalWearMaterialApi
public object SwipeToDismissBoxDefaults {
    public val AnimationSpec = SwipeableDefaults.AnimationSpec
}

/**
 * States used as targets for the anchor points for swipe-to-dismiss.
 */
@ExperimentalWearMaterialApi
public enum class SwipeDismissTarget {
    /**
     * The state of the SwipeToDismissBox before the swipe started.
     */
    Original,

    /**
     * The state of the SwipeToDismissBox after the swipe passes the swipe-to-dismiss threshold.
     */
    Dismissal
}

private val SwipeStartedBackgroundAlpha = 0.5f
private val SwipeConfirmedBackgroundAlpha = 0.0f
private val SwipeStartedContentAlpha = 0.0f
private val SwipeConfirmedContentAlpha = 0.5f
private val SwipeThreshold = 0.5f