Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 60c2853e authored by Omar Miatello's avatar Omar Miatello Committed by Android (Google) Code Review
Browse files

Merge changes from topic "cherrypicker-L85800000962821528:N65300001400612059" into main

* changes:
  Overscroll animation for SceneTransitionLayout
  Handle gesture started on nested child in SceneTransitionLayout
  SceneTransitionLayout now supports the nestedScroll API
  Added PriorityPostNestedScrollConnection
  Added LargeTopAppBarNestedScrollConnection
parents b5e861ee fcb6f437
Loading
Loading
Loading
Loading
+312 −73
Original line number Diff line number Diff line
@@ -19,18 +19,25 @@ package com.android.compose.animation.scene
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.gestures.DraggableState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import com.android.compose.nestedscroll.PriorityPostNestedScrollConnection
import kotlin.math.absoluteValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
@@ -70,20 +77,35 @@ internal fun Modifier.swipeToScene(
    // the same as SwipeableV2Defaults.PositionalThreshold.
    val positionalThreshold = with(LocalDensity.current) { 56.dp.toPx() }

    return draggable(
    val draggableState = rememberDraggableState { delta ->
        onDrag(layoutImpl, transition, orientation, delta)
    }

    return nestedScroll(
            connection =
                rememberSwipeToSceneNestedScrollConnection(
                    orientation = orientation,
                    coroutineScope = rememberCoroutineScope(),
                    draggableState = draggableState,
                    transition = transition,
                    layoutImpl = layoutImpl,
                    velocityThreshold = velocityThreshold,
                    positionalThreshold = positionalThreshold
                ),
        )
        .draggable(
            state = draggableState,
            orientation = orientation,
            enabled = enabled,
            startDragImmediately = startDragImmediately,
            onDragStarted = { onDragStarted(layoutImpl, transition, orientation) },
        state =
            rememberDraggableState { delta -> onDrag(layoutImpl, transition, orientation, delta) },
            onDragStopped = { velocity ->
                onDragStopped(
                layoutImpl,
                transition,
                velocity,
                velocityThreshold,
                positionalThreshold,
                    layoutImpl = layoutImpl,
                    transition = transition,
                    velocity = velocity,
                    velocityThreshold = velocityThreshold,
                    positionalThreshold = positionalThreshold,
                )
            },
        )
@@ -235,35 +257,18 @@ private fun onDrag(
    // twice in a row to accelerate the transition and go from A => B then B => C really fast.
    maybeHandleAcceleratedSwipe(transition, orientation)

    val fromScene = transition._fromScene
    val upOrLeft = fromScene.upOrLeft(orientation)
    val downOrRight = fromScene.downOrRight(orientation)
    val offset = transition.dragOffset
    val fromScene = transition._fromScene

    // Compute the target scene depending on the current offset.
    val targetSceneKey: SceneKey
    val signedDistance: Float
    when {
        offset < 0f && upOrLeft != null -> {
            targetSceneKey = upOrLeft
            signedDistance = -transition.absoluteDistance
        }
        offset > 0f && downOrRight != null -> {
            targetSceneKey = downOrRight
            signedDistance = transition.absoluteDistance
        }
        else -> {
            targetSceneKey = fromScene.key
            signedDistance = 0f
        }
    }
    val target = fromScene.findTargetSceneAndDistance(orientation, offset, layoutImpl)

    if (transition._toScene.key != targetSceneKey) {
        transition._toScene = layoutImpl.scenes.getValue(targetSceneKey)
    if (transition._toScene.key != target.sceneKey) {
        transition._toScene = layoutImpl.scenes.getValue(target.sceneKey)
    }

    if (transition._distance != signedDistance) {
        transition._distance = signedDistance
    if (transition._distance != target.distance) {
        transition._distance = target.distance
    }
}

@@ -299,12 +304,55 @@ private fun maybeHandleAcceleratedSwipe(
    // using fromScene and dragOffset.
}

private data class TargetScene(
    val sceneKey: SceneKey,
    val distance: Float,
)

private fun Scene.findTargetSceneAndDistance(
    orientation: Orientation,
    directionOffset: Float,
    layoutImpl: SceneTransitionLayoutImpl,
): TargetScene {
    val maxDistance =
        when (orientation) {
            Orientation.Horizontal -> layoutImpl.size.width
            Orientation.Vertical -> layoutImpl.size.height
        }.toFloat()

    val upOrLeft = upOrLeft(orientation)
    val downOrRight = downOrRight(orientation)

    // Compute the target scene depending on the current offset.
    return when {
        directionOffset < 0f && upOrLeft != null -> {
            TargetScene(
                sceneKey = upOrLeft,
                distance = -maxDistance,
            )
        }
        directionOffset > 0f && downOrRight != null -> {
            TargetScene(
                sceneKey = downOrRight,
                distance = maxDistance,
            )
        }
        else -> {
            TargetScene(
                sceneKey = key,
                distance = 0f,
            )
        }
    }
}

private fun CoroutineScope.onDragStopped(
    layoutImpl: SceneTransitionLayoutImpl,
    transition: SwipeTransition,
    velocity: Float,
    velocityThreshold: Float,
    positionalThreshold: Float,
    canChangeScene: Boolean = true,
) {
    // The state was changed since the drag started; don't do anything.
    if (layoutImpl.state.transitionState != transition) {
@@ -323,6 +371,7 @@ private fun CoroutineScope.onDragStopped(
    val offset = transition.dragOffset
    val distance = transition.distance
    if (
        canChangeScene &&
            shouldCommitSwipe(
                offset,
                distance,
@@ -348,31 +397,13 @@ private fun CoroutineScope.onDragStopped(
        layoutImpl.onChangeScene(targetScene.key)
    }

    // Animate the offset.
    transition.offsetAnimationJob = launch {
        transition.offsetAnimatable.snapTo(offset)
        transition.isAnimatingOffset = true

        transition.offsetAnimatable.animateTo(
            targetOffset,
            // TODO(b/290184746): Make this spring spec configurable.
            spring(
                stiffness = Spring.StiffnessMediumLow,
                visibilityThreshold = OffsetVisibilityThreshold
            ),
    animateOffset(
        transition = transition,
        layoutImpl = layoutImpl,
        initialVelocity = velocity,
        targetOffset = targetOffset,
        targetScene = targetScene.key
    )

        // Now that the animation is done, the state should be idle. Note that if the state was
        // changed since this animation started, some external code changed it and we shouldn't do
        // anything here. Note also that this job will be cancelled in the case where the user
        // intercepts this swipe.
        if (layoutImpl.state.transitionState == transition) {
            layoutImpl.state.transitionState = TransitionState.Idle(targetScene.key)
        }

        transition.offsetAnimationJob = null
    }
}

/**
@@ -412,8 +443,216 @@ private fun shouldCommitSwipe(
    }
}

private fun CoroutineScope.animateOffset(
    transition: SwipeTransition,
    layoutImpl: SceneTransitionLayoutImpl,
    initialVelocity: Float,
    targetOffset: Float,
    targetScene: SceneKey,
) {
    transition.offsetAnimationJob = launch {
        if (!transition.isAnimatingOffset) {
            transition.offsetAnimatable.snapTo(transition.dragOffset)
        }
        transition.isAnimatingOffset = true

        transition.offsetAnimatable.animateTo(
            targetOffset,
            // TODO(b/290184746): Make this spring spec configurable.
            spring(
                stiffness = Spring.StiffnessMediumLow,
                visibilityThreshold = OffsetVisibilityThreshold
            ),
            initialVelocity = initialVelocity,
        )

        // Now that the animation is done, the state should be idle. Note that if the state was
        // changed since this animation started, some external code changed it and we shouldn't do
        // anything here. Note also that this job will be cancelled in the case where the user
        // intercepts this swipe.
        if (layoutImpl.state.transitionState == transition) {
            layoutImpl.state.transitionState = TransitionState.Idle(targetScene)
        }

        transition.offsetAnimationJob = null
    }
}

private fun CoroutineScope.animateOverscroll(
    layoutImpl: SceneTransitionLayoutImpl,
    transition: SwipeTransition,
    velocity: Velocity,
    orientation: Orientation,
): Velocity {
    val velocityAmount =
        when (orientation) {
            Orientation.Vertical -> velocity.y
            Orientation.Horizontal -> velocity.x
        }

    if (velocityAmount == 0f) {
        // There is no remaining velocity
        return Velocity.Zero
    }

    val fromScene = layoutImpl.scene(layoutImpl.state.transitionState.currentScene)
    val target = fromScene.findTargetSceneAndDistance(orientation, velocityAmount, layoutImpl)
    val isValidTarget = target.distance != 0f && target.sceneKey != fromScene.key

    if (!isValidTarget || layoutImpl.state.transitionState == transition) {
        // We have not found a valid target or we are already in a transition
        return Velocity.Zero
    }

    transition._currentScene = fromScene
    transition._fromScene = fromScene
    transition._toScene = layoutImpl.scene(target.sceneKey)
    transition._distance = target.distance
    transition.absoluteDistance = target.distance.absoluteValue
    transition.dragOffset = 0f
    transition.isAnimatingOffset = false
    transition.offsetAnimationJob = null

    layoutImpl.state.transitionState = transition

    animateOffset(
        transition = transition,
        layoutImpl = layoutImpl,
        initialVelocity = velocityAmount,
        targetOffset = 0f,
        targetScene = fromScene.key
    )

    // The animateOffset animation consumes any remaining velocity.
    return velocity
}

/**
 * The number of pixels below which there won't be a visible difference in the transition and from
 * which the animation can stop.
 */
private const val OffsetVisibilityThreshold = 0.5f

@Composable
private fun rememberSwipeToSceneNestedScrollConnection(
    orientation: Orientation,
    coroutineScope: CoroutineScope,
    draggableState: DraggableState,
    transition: SwipeTransition,
    layoutImpl: SceneTransitionLayoutImpl,
    velocityThreshold: Float,
    positionalThreshold: Float,
): PriorityPostNestedScrollConnection {
    val density = LocalDensity.current
    val scrollConnection =
        remember(
            orientation,
            coroutineScope,
            draggableState,
            transition,
            layoutImpl,
            velocityThreshold,
            positionalThreshold,
            density,
        ) {
            fun Offset.toAmount() =
                when (orientation) {
                    Orientation.Horizontal -> x
                    Orientation.Vertical -> y
                }

            fun Velocity.toAmount() =
                when (orientation) {
                    Orientation.Horizontal -> x
                    Orientation.Vertical -> y
                }

            fun Float.toOffset() =
                when (orientation) {
                    Orientation.Horizontal -> Offset(x = this, y = 0f)
                    Orientation.Vertical -> Offset(x = 0f, y = this)
                }

            // The next potential scene is calculated during the canStart
            var nextScene: SceneKey? = null

            // This is the scene on which we will have priority during the scroll gesture.
            var priorityScene: SceneKey? = null

            // If we performed a long gesture before entering priority mode, we would have to avoid
            // moving on to the next scene.
            var gestureStartedOnNestedChild = false

            PriorityPostNestedScrollConnection(
                canStart = { offsetAvailable, offsetBeforeStart ->
                    val amount = offsetAvailable.toAmount()
                    if (amount == 0f) return@PriorityPostNestedScrollConnection false

                    gestureStartedOnNestedChild = offsetBeforeStart != Offset.Zero

                    val fromScene = layoutImpl.scene(layoutImpl.state.transitionState.currentScene)
                    nextScene =
                        when {
                            amount < 0f -> fromScene.upOrLeft(orientation)
                            amount > 0f -> fromScene.downOrRight(orientation)
                            else -> null
                        }

                    nextScene != null
                },
                canContinueScroll = { priorityScene == transition._toScene.key },
                onStart = {
                    priorityScene = nextScene
                    onDragStarted(layoutImpl, transition, orientation)
                },
                onScroll = { offsetAvailable ->
                    val amount = offsetAvailable.toAmount()

                    // TODO(b/297842071) We should handle the overscroll or slow drag if the gesture
                    // is initiated in a nested child.

                    // Appends a new coroutine to attempt to drag by [amount] px. In this case we
                    // are assuming that the [coroutineScope] is tied to the main thread and that
                    // calls to [launch] are therefore queued.
                    coroutineScope.launch { draggableState.drag { dragBy(amount) } }

                    amount.toOffset()
                },
                onStop = { velocityAvailable ->
                    priorityScene = null

                    coroutineScope.onDragStopped(
                        layoutImpl = layoutImpl,
                        transition = transition,
                        velocity = velocityAvailable.toAmount(),
                        velocityThreshold = velocityThreshold,
                        positionalThreshold = positionalThreshold,
                        canChangeScene = !gestureStartedOnNestedChild
                    )

                    // The onDragStopped animation consumes any remaining velocity.
                    velocityAvailable
                },
                onPostFling = { velocityAvailable ->
                    // If there is any velocity left, we can try running an overscroll animation
                    // between scenes.
                    coroutineScope.animateOverscroll(
                        layoutImpl = layoutImpl,
                        transition = transition,
                        velocity = velocityAvailable,
                        orientation = orientation
                    )
                },
            )
        }
    DisposableEffect(scrollConnection) {
        onDispose {
            coroutineScope.launch {
                // This should ensure that the draggableState is in a consistent state and that it
                // does not cause any unexpected behavior.
                scrollConnection.reset()
            }
        }
    }
    return scrollConnection
}
+92 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 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.android.compose.nestedscroll

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource

/**
 * A [NestedScrollConnection] that listens for all vertical scroll events and responds in the
 * following way:
 * - If you **scroll up**, it **first brings the [height]** back to the [minHeight] and then allows
 *   scrolling of the children (usually the content).
 * - If you **scroll down**, it **first allows scrolling of the children** (usually the content) and
 *   then resets the [height] to [maxHeight].
 *
 * This behavior is useful for implementing a
 * [Large top app bar](https://m3.material.io/components/top-app-bar/specs) effect or something
 * similar.
 *
 * @sample com.android.compose.animation.scene.demo.Shade
 */
class LargeTopAppBarNestedScrollConnection(
    private val height: () -> Float,
    private val onChangeHeight: (Float) -> Unit,
    private val minHeight: Float,
    private val maxHeight: Float,
) : NestedScrollConnection {

    constructor(
        height: () -> Float,
        onHeightChanged: (Float) -> Unit,
        heightRange: ClosedFloatingPointRange<Float>,
    ) : this(
        height = height,
        onChangeHeight = onHeightChanged,
        minHeight = heightRange.start,
        maxHeight = heightRange.endInclusive,
    )

    /**
     * When swiping up, the LargeTopAppBar will shrink (to [minHeight]) and the content will expand.
     * Then, you can then scroll down the content.
     */
    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        val y = available.y
        val currentHeight = height()
        if (y >= 0 || currentHeight <= minHeight) {
            return Offset.Zero
        }

        val amountLeft = minHeight - currentHeight
        val amountConsumed = y.coerceAtLeast(amountLeft)
        onChangeHeight(currentHeight + amountConsumed)
        return Offset(0f, amountConsumed)
    }

    /**
     * When swiping down, the content will scroll up until it reaches the top. Then, the
     * LargeTopAppBar will expand until it reaches its [maxHeight].
     */
    override fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset {
        val y = available.y
        val currentHeight = height()
        if (y <= 0 || currentHeight >= maxHeight) {
            return Offset.Zero
        }

        val amountLeft = maxHeight - currentHeight
        val amountConsumed = y.coerceAtMost(amountLeft)
        onChangeHeight(currentHeight + amountConsumed)
        return Offset(0f, amountConsumed)
    }
}
+126 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2023 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.android.compose.nestedscroll

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.unit.Velocity

/**
 * This [NestedScrollConnection] waits for a child to scroll ([onPostScroll]), and then decides (via
 * [canStart]) if it should take over scrolling. If it does, it will scroll before its children,
 * until [canContinueScroll] allows it.
 *
 * Note: Call [reset] before destroying this object to make sure you always get a call to [onStop]
 * after [onStart].
 *
 * @sample com.android.compose.animation.scene.rememberSwipeToSceneNestedScrollConnection
 */
class PriorityPostNestedScrollConnection(
    private val canStart: (offsetAvailable: Offset, offsetBeforeStart: Offset) -> Boolean,
    private val canContinueScroll: () -> Boolean,
    private val onStart: () -> Unit,
    private val onScroll: (offsetAvailable: Offset) -> Offset,
    private val onStop: (velocityAvailable: Velocity) -> Velocity,
    private val onPostFling: suspend (velocityAvailable: Velocity) -> Velocity,
) : NestedScrollConnection {

    /** In priority mode [onPreScroll] events are first consumed by the parent, via [onScroll]. */
    private var isPriorityMode = false

    private var offsetScrolledBeforePriorityMode = Offset.Zero

    override fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource,
    ): Offset {
        // The offset before the start takes into account the up and down movements, starting from
        // the beginning or from the last fling gesture.
        val offsetBeforeStart = offsetScrolledBeforePriorityMode - available

        if (
            isPriorityMode ||
                source == NestedScrollSource.Fling ||
                !canStart(available, offsetBeforeStart)
        ) {
            // The priority mode cannot start so we won't consume the available offset.
            return Offset.Zero
        }

        // Step 1: It's our turn! We start capturing scroll events when one of our children has an
        // available offset following a scroll event.
        isPriorityMode = true

        // Note: onStop will be called if we cannot continue to scroll (step 3a), or the finger is
        // lifted (step 3b), or this object has been destroyed (step 3c).
        onStart()

        return onScroll(available)
    }

    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        if (!isPriorityMode) {
            if (source != NestedScrollSource.Fling) {
                // We want to track the amount of offset consumed before entering priority mode
                offsetScrolledBeforePriorityMode += available
            }

            return Offset.Zero
        }

        if (!canContinueScroll()) {
            // Step 3a: We have lost priority and we no longer need to intercept scroll events.
            onPriorityStop(velocity = Velocity.Zero)
            return Offset.Zero
        }

        // Step 2: We have the priority and can consume the scroll events.
        return onScroll(available)
    }

    override suspend fun onPreFling(available: Velocity): Velocity {
        // Step 3b: The finger is lifted, we can stop intercepting scroll events and use the speed
        // of the fling gesture.
        return onPriorityStop(velocity = available)
    }

    override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
        return onPostFling(available)
    }

    /** Method to call before destroying the object or to reset the initial state. */
    fun reset() {
        // Step 3c: To ensure that an onStop is always called for every onStart.
        onPriorityStop(velocity = Velocity.Zero)
    }

    private fun onPriorityStop(velocity: Velocity): Velocity {

        // We can restart tracking the consumed offsets from scratch.
        offsetScrolledBeforePriorityMode = Offset.Zero

        if (!isPriorityMode) {
            return Velocity.Zero
        }

        isPriorityMode = false

        return onStop(velocity)
    }
}
+145 −0

File added.

Preview size limit exceeded, changes collapsed.

+172 −0

File added.

Preview size limit exceeded, changes collapsed.