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

Commit a972b5f5 authored by omarmt's avatar omarmt
Browse files

The animation between scenes can only be intercepted in a defined range

A transition between scenes can only be intercepted by a scrollable
component if the animation progress is within a certain range. In this
CL, the progress must be between 5% and 95% to be intercepted during
preScroll.

Test: atest SceneGestureHandlerTest
Bug: 291053278
Flag: NA
Change-Id: Id998095286e67da7bfdecf729e59d9afafcd3700
parent fdca966c
Loading
Loading
Loading
Loading
+26 −6
Original line number Diff line number Diff line
@@ -38,13 +38,13 @@ import kotlinx.coroutines.launch

@VisibleForTesting
class SceneGestureHandler(
    private val layoutImpl: SceneTransitionLayoutImpl,
    internal val layoutImpl: SceneTransitionLayoutImpl,
    internal val orientation: Orientation,
    private val coroutineScope: CoroutineScope,
) {
    val draggable: DraggableHandler = SceneDraggableHandler(this)

    private var transitionState
    internal var transitionState
        get() = layoutImpl.state.transitionState
        set(value) {
            layoutImpl.state.transitionState = value
@@ -57,7 +57,7 @@ class SceneGestureHandler(
     * Note: the initialScene here does not matter, it's only used for initializing the transition
     * and will be replaced when a drag event starts.
     */
    private val swipeTransition = SwipeTransition(initialScene = currentScene)
    internal val swipeTransition = SwipeTransition(initialScene = currentScene)

    internal val currentScene: Scene
        get() = layoutImpl.scene(transitionState.currentScene)
@@ -414,7 +414,7 @@ class SceneGestureHandler(
        }
    }

    private class SwipeTransition(initialScene: Scene) : TransitionState.Transition {
    internal class SwipeTransition(initialScene: Scene) : TransitionState.Transition {
        var _currentScene by mutableStateOf(initialScene)
        override val currentScene: SceneKey
            get() = _currentScene.key
@@ -597,9 +597,29 @@ class SceneNestedScrollHandler(
        return PriorityNestedScrollConnection(
            canStartPreScroll = { offsetAvailable, offsetBeforeStart ->
                canChangeScene = offsetBeforeStart == Offset.Zero
                gestureHandler.isDrivingTransition &&

                val canInterceptSwipeTransition =
                    canChangeScene &&
                        gestureHandler.isDrivingTransition &&
                        offsetAvailable.toAmount() != 0f
                if (!canInterceptSwipeTransition) return@PriorityNestedScrollConnection false

                val progress = gestureHandler.swipeTransition.progress
                val threshold = gestureHandler.layoutImpl.transitionInterceptionThreshold
                fun isProgressCloseTo(value: Float) = (progress - value).absoluteValue <= threshold

                // The transition is always between 0 and 1. If it is close to either of these
                // intervals, we want to go directly to the TransitionState.Idle.
                // The progress value can go beyond this range in the case of overscroll.
                val shouldSnapToIdle = isProgressCloseTo(0f) || isProgressCloseTo(1f)
                if (shouldSnapToIdle) {
                    gestureHandler.swipeTransition.stopOffsetAnimation()
                    gestureHandler.transitionState =
                        TransitionState.Idle(gestureHandler.swipeTransition.currentScene)
                }

                // Start only if we cannot consume this event
                !shouldSnapToIdle
            },
            canStartPostScroll = { offsetAvailable, offsetBeforeStart ->
                val amount = offsetAvailable.toAmount()
+6 −0
Original line number Diff line number Diff line
@@ -16,6 +16,7 @@

package com.android.compose.animation.scene

import androidx.annotation.FloatRange
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
@@ -41,6 +42,8 @@ import androidx.compose.ui.platform.LocalDensity
 * @param transitions the definition of the transitions used to animate a change of scene.
 * @param state the observable state of this layout.
 * @param edgeDetector the edge detector used to detect which edge a swipe is started from, if any.
 * @param transitionInterceptionThreshold used during a scene transition. For the scene to be
 *   intercepted, the progress value must be above the threshold, and below (1 - threshold).
 * @param scenes the configuration of the different scenes of this layout.
 */
@Composable
@@ -51,6 +54,7 @@ fun SceneTransitionLayout(
    modifier: Modifier = Modifier,
    state: SceneTransitionLayoutState = remember { SceneTransitionLayoutState(currentScene) },
    edgeDetector: EdgeDetector = DefaultEdgeDetector,
    @FloatRange(from = 0.0, to = 0.5) transitionInterceptionThreshold: Float = 0f,
    scenes: SceneTransitionLayoutScope.() -> Unit,
) {
    val density = LocalDensity.current
@@ -63,6 +67,7 @@ fun SceneTransitionLayout(
            state = state,
            density = density,
            edgeDetector = edgeDetector,
            transitionInterceptionThreshold = transitionInterceptionThreshold,
            coroutineScope = coroutineScope,
        )
    }
@@ -71,6 +76,7 @@ fun SceneTransitionLayout(
    layoutImpl.transitions = transitions
    layoutImpl.density = density
    layoutImpl.edgeDetector = edgeDetector
    layoutImpl.transitionInterceptionThreshold = transitionInterceptionThreshold

    layoutImpl.setScenes(scenes)
    layoutImpl.setCurrentScene(currentScene)
+2 −0
Original line number Diff line number Diff line
@@ -48,6 +48,7 @@ class SceneTransitionLayoutImpl(
    internal val state: SceneTransitionLayoutState,
    density: Density,
    edgeDetector: EdgeDetector,
    transitionInterceptionThreshold: Float,
    coroutineScope: CoroutineScope,
) {
    internal val scenes = SnapshotStateMap<SceneKey, Scene>()
@@ -60,6 +61,7 @@ class SceneTransitionLayoutImpl(
    internal var transitions by mutableStateOf(transitions)
    internal var density: Density by mutableStateOf(density)
    internal var edgeDetector by mutableStateOf(edgeDetector)
    internal var transitionInterceptionThreshold by mutableStateOf(transitionInterceptionThreshold)

    private val horizontalGestureHandler: SceneGestureHandler
    private val verticalGestureHandler: SceneGestureHandler
+62 −0
Original line number Diff line number Diff line
@@ -68,6 +68,8 @@ class SceneGestureHandlerTest {
            scene(SceneC) { Text("SceneC") }
        }

        val transitionInterceptionThreshold = 0.05f

        val sceneGestureHandler =
            SceneGestureHandler(
                layoutImpl =
@@ -78,6 +80,7 @@ class SceneGestureHandlerTest {
                            state = layoutState,
                            density = Density(1f),
                            edgeDetector = DefaultEdgeDetector,
                            transitionInterceptionThreshold = transitionInterceptionThreshold,
                            coroutineScope = coroutineScope,
                        )
                        .also { it.size = IntSize(SCREEN_SIZE.toInt(), SCREEN_SIZE.toInt()) },
@@ -299,6 +302,64 @@ class SceneGestureHandlerTest {
        assertScene(currentScene = SceneA, isIdle = false)
    }

    private suspend fun TestGestureScope.preScrollAfterSceneTransition(
        firstScroll: Float,
        secondScroll: Float
    ) {
        val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeWithOverscroll)
        // start scene transition
        nestedScroll.scroll(available = Offset(0f, SCREEN_SIZE * firstScroll))

        // stop scene transition (start the "stop animation")
        nestedScroll.onPreFling(available = Velocity.Zero)

        // a pre scroll event, that could be intercepted by SceneGestureHandler
        nestedScroll.onPreScroll(Offset(0f, SCREEN_SIZE * secondScroll), NestedScrollSource.Drag)
    }

    // Float tolerance for comparisons
    private val tolerance = 0.00001f

    @Test
    fun scrollAndFling_scrollLessThanInterceptable_goToIdleOnCurrentScene() = runGestureTest {
        val first = transitionInterceptionThreshold - tolerance
        val second = 0.01f

        preScrollAfterSceneTransition(firstScroll = first, secondScroll = second)

        assertScene(SceneA, isIdle = true)
    }

    @Test
    fun scrollAndFling_scrollMinInterceptable_interceptPreScrollEvents() = runGestureTest {
        val first = transitionInterceptionThreshold + tolerance
        val second = 0.01f

        preScrollAfterSceneTransition(firstScroll = first, secondScroll = second)

        assertThat((transitionState as Transition).progress).isWithin(tolerance).of(first + second)
    }

    @Test
    fun scrollAndFling_scrollMaxInterceptable_interceptPreScrollEvents() = runGestureTest {
        val first = 1f - transitionInterceptionThreshold - tolerance
        val second = 0.01f

        preScrollAfterSceneTransition(firstScroll = first, secondScroll = second)

        assertThat((transitionState as Transition).progress).isWithin(tolerance).of(first + second)
    }

    @Test
    fun scrollAndFling_scrollMoreThanInterceptable_goToIdleOnNextScene() = runGestureTest {
        val first = 1f - transitionInterceptionThreshold + tolerance
        val second = 0.01f

        preScrollAfterSceneTransition(firstScroll = first, secondScroll = second)

        assertScene(SceneC, isIdle = true)
    }

    @Test
    fun onPreFling_velocityLowerThanThreshold_remainSameScene() = runGestureTest {
        val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeWithOverscroll)
@@ -421,6 +482,7 @@ class SceneGestureHandlerTest {
        draggable.onDelta(deltaInPixels10)
        assertScene(currentScene = SceneA, isIdle = true)
    }

    @Test
    fun beforeDraggableStart_stop_shouldBeIgnored() = runGestureTest {
        draggable.onDragStopped(velocityThreshold)