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

Commit fdca966c authored by omarmt's avatar omarmt
Browse files

Add SceneScope.nestedScrollToScene() modifier

Defines the behavior to use when the scrollable element can no longer
handle scroll events, and the conditions under which these events can be
 used to transition to the next scene.

 Also in this CL:
- GestureHandler has been removed, it is no longer needed.
- Overscroll can change the scene based on four configurations:
DuringTransitionBetweenScenes, EdgeNoOverscroll, EdgeWithOverscroll, or
Always.
- Simplified SceneNestedScrollHandler (it is no longer necessary to
define the priorityScene since the connection is reset when the scene
changes).

Test: atest SceneGestureHandlerTest
Bug: 291053278
Flag: NA
Change-Id: Ia4874bff4bdca0a9628d77a3a04c58ee86de47e0
parent 006fd9e0
Loading
Loading
Loading
Loading
+0 −5
Original line number Diff line number Diff line
@@ -3,11 +3,6 @@ package com.android.compose.animation.scene
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection

interface GestureHandler {
    val draggable: DraggableHandler
    val nestedScroll: NestedScrollHandler
}

interface DraggableHandler {
    fun onDragStarted(startedPosition: Offset, pointersDown: Int = 1)
    fun onDelta(pixels: Float)
+103 −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.animation.scene

import androidx.compose.foundation.gestures.Orientation
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.input.nestedscroll.nestedScroll

/**
 * Defines the behavior of the [SceneTransitionLayout] when a scrollable component is scrolled.
 *
 * By default, scrollable elements within the scene have priority during the user's gesture and are
 * not consumed by the [SceneTransitionLayout] unless specifically requested via
 * [nestedScrollToScene].
 */
enum class NestedScrollBehavior(val canStartOnPostFling: Boolean) {
    /**
     * During scene transitions, scroll events are consumed by the [SceneTransitionLayout] instead
     * of the scrollable component.
     */
    DuringTransitionBetweenScenes(canStartOnPostFling = false),

    /**
     * Overscroll will only be used by the [SceneTransitionLayout] to move to the next scene if the
     * gesture begins at the edge of the scrollable component (so that a scroll in that direction
     * can no longer be consumed). If the gesture is partially consumed by the scrollable component,
     * there will be NO overscroll effect between scenes.
     *
     * In addition, during scene transitions, scroll events are consumed by the
     * [SceneTransitionLayout] instead of the scrollable component.
     */
    EdgeNoOverscroll(canStartOnPostFling = false),

    /**
     * Overscroll will only be used by the [SceneTransitionLayout] to move to the next scene if the
     * gesture begins at the edge of the scrollable component. If the gesture is partially consumed
     * by the scrollable component, there will be an overscroll effect between scenes.
     *
     * In addition, during scene transitions, scroll events are consumed by the
     * [SceneTransitionLayout] instead of the scrollable component.
     */
    EdgeWithOverscroll(canStartOnPostFling = true),

    /**
     * Any overscroll will be used by the [SceneTransitionLayout] to move to the next scene.
     *
     * In addition, during scene transitions, scroll events are consumed by the
     * [SceneTransitionLayout] instead of the scrollable component.
     */
    Always(canStartOnPostFling = true),
}

internal fun Modifier.nestedScrollToScene(
    layoutImpl: SceneTransitionLayoutImpl,
    orientation: Orientation,
    startBehavior: NestedScrollBehavior,
    endBehavior: NestedScrollBehavior,
): Modifier = composed {
    val connection =
        remember(layoutImpl, orientation, startBehavior, endBehavior) {
            scenePriorityNestedScrollConnection(
                layoutImpl = layoutImpl,
                orientation = orientation,
                startBehavior = startBehavior,
                endBehavior = endBehavior
            )
        }

    // Make sure we reset the scroll connection when this modifier is removed from composition
    DisposableEffect(connection) { onDispose { connection.reset() } }

    nestedScroll(connection = connection)
}

private fun scenePriorityNestedScrollConnection(
    layoutImpl: SceneTransitionLayoutImpl,
    orientation: Orientation,
    startBehavior: NestedScrollBehavior,
    endBehavior: NestedScrollBehavior,
) =
    SceneNestedScrollHandler(
            gestureHandler = layoutImpl.gestureHandler(orientation = orientation),
            startBehavior = startBehavior,
            endBehavior = endBehavior,
        )
        .connection
+13 −0
Original line number Diff line number Diff line
@@ -16,6 +16,7 @@

package com.android.compose.animation.scene

import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
@@ -68,6 +69,18 @@ private class SceneScopeImpl(
        return element(layoutImpl, scene, key)
    }

    override fun Modifier.nestedScrollToScene(
        orientation: Orientation,
        startBehavior: NestedScrollBehavior,
        endBehavior: NestedScrollBehavior,
    ): Modifier =
        nestedScrollToScene(
            layoutImpl = layoutImpl,
            orientation = orientation,
            startBehavior = startBehavior,
            endBehavior = endBehavior,
        )

    @Composable
    override fun <T> animateSharedValueAsState(
        value: T,
+70 −43
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.animation.scene

import android.util.Log
@@ -25,10 +41,8 @@ class SceneGestureHandler(
    private val layoutImpl: SceneTransitionLayoutImpl,
    internal val orientation: Orientation,
    private val coroutineScope: CoroutineScope,
) : GestureHandler {
    override val draggable: DraggableHandler = SceneDraggableHandler(this)

    override val nestedScroll: SceneNestedScrollHandler = SceneNestedScrollHandler(this)
) {
    val draggable: DraggableHandler = SceneDraggableHandler(this)

    private var transitionState
        get() = layoutImpl.state.transitionState
@@ -521,6 +535,8 @@ private class SceneDraggableHandler(
@VisibleForTesting
class SceneNestedScrollHandler(
    private val gestureHandler: SceneGestureHandler,
    private val startBehavior: NestedScrollBehavior,
    private val endBehavior: NestedScrollBehavior,
) : NestedScrollHandler {
    override val connection: PriorityNestedScrollConnection = nestedScrollConnection()

@@ -543,15 +559,9 @@ class SceneNestedScrollHandler(
        }

    private fun nestedScrollConnection(): PriorityNestedScrollConnection {
        // 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
        var canChangeScene = false

        val actionUpOrLeft =
            Swipe(
@@ -573,51 +583,70 @@ class SceneNestedScrollHandler(
                pointerCount = 1,
            )

        fun findNextScene(amount: Float): SceneKey? {
        fun hasNextScene(amount: Float): Boolean {
            val fromScene = gestureHandler.currentScene
            return when {
            val nextScene =
                when {
                    amount < 0f -> fromScene.userActions[actionUpOrLeft]
                    amount > 0f -> fromScene.userActions[actionDownOrRight]
                    else -> null
                }
            return nextScene != null
        }

        return PriorityNestedScrollConnection(
            canStartPreScroll = { offsetAvailable, offsetBeforeStart ->
                gestureStartedOnNestedChild = offsetBeforeStart != Offset.Zero

                val canInterceptPreScroll =
                canChangeScene = offsetBeforeStart == Offset.Zero
                gestureHandler.isDrivingTransition &&
                        !gestureStartedOnNestedChild &&
                    canChangeScene &&
                    offsetAvailable.toAmount() != 0f

                if (!canInterceptPreScroll) return@PriorityNestedScrollConnection false

                nextScene = gestureHandler.swipeTransitionToScene.key

                true
            },
            canStartPostScroll = { offsetAvailable, offsetBeforeStart ->
                val amount = offsetAvailable.toAmount()
                if (amount == 0f) return@PriorityNestedScrollConnection false
                val behavior: NestedScrollBehavior =
                    when {
                        amount > 0 -> startBehavior
                        amount < 0 -> endBehavior
                        else -> return@PriorityNestedScrollConnection false
                    }

                gestureStartedOnNestedChild = offsetBeforeStart != Offset.Zero
                nextScene = findNextScene(amount)
                nextScene != null
                val isZeroOffset = offsetBeforeStart == Offset.Zero

                when (behavior) {
                    NestedScrollBehavior.DuringTransitionBetweenScenes -> {
                        canChangeScene = false // unused: added for consistency
                        false
                    }
                    NestedScrollBehavior.EdgeNoOverscroll -> {
                        canChangeScene = isZeroOffset
                        isZeroOffset && hasNextScene(amount)
                    }
                    NestedScrollBehavior.EdgeWithOverscroll -> {
                        canChangeScene = isZeroOffset
                        hasNextScene(amount)
                    }
                    NestedScrollBehavior.Always -> {
                        canChangeScene = true
                        hasNextScene(amount)
                    }
                }
            },
            canStartPostFling = { velocityAvailable ->
                val amount = velocityAvailable.toAmount()
                if (amount == 0f) return@PriorityNestedScrollConnection false
                val behavior: NestedScrollBehavior =
                    when {
                        amount > 0 -> startBehavior
                        amount < 0 -> endBehavior
                        else -> return@PriorityNestedScrollConnection false
                    }

                // We could start an overscroll animation
                gestureStartedOnNestedChild = true
                nextScene = findNextScene(amount)
                nextScene != null
                canChangeScene = false
                behavior.canStartOnPostFling && hasNextScene(amount)
            },
            canContinueScroll = { priorityScene == gestureHandler.swipeTransitionToScene.key },
            canContinueScroll = { true },
            onStart = {
                gestureHandler.gestureWithPriority = this
                priorityScene = nextScene
                gestureHandler.onDragStarted(pointersDown = 1, startedPosition = null)
            },
            onScroll = { offsetAvailable ->
@@ -638,11 +667,9 @@ class SceneNestedScrollHandler(
                    return@PriorityNestedScrollConnection Velocity.Zero
                }

                priorityScene = null

                gestureHandler.onDragStopped(
                    velocity = velocityAvailable.toAmount(),
                    canChangeScene = !gestureStartedOnNestedChild
                    canChangeScene = canChangeScene
                )

                // The onDragStopped animation consumes any remaining velocity.
+24 −6
Original line number Diff line number Diff line
@@ -20,7 +20,9 @@ import androidx.compose.foundation.gestures.Orientation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.platform.LocalDensity

/**
@@ -52,14 +54,16 @@ fun SceneTransitionLayout(
    scenes: SceneTransitionLayoutScope.() -> Unit,
) {
    val density = LocalDensity.current
    val coroutineScope = rememberCoroutineScope()
    val layoutImpl = remember {
        SceneTransitionLayoutImpl(
            onChangeScene,
            scenes,
            transitions,
            state,
            density,
            edgeDetector,
            onChangeScene = onChangeScene,
            builder = scenes,
            transitions = transitions,
            state = state,
            density = density,
            edgeDetector = edgeDetector,
            coroutineScope = coroutineScope,
        )
    }

@@ -119,6 +123,20 @@ interface SceneScope {
     */
    fun Modifier.element(key: ElementKey): Modifier

    /**
     * Adds a [NestedScrollConnection] to intercept scroll events not handled by the scrollable
     * component.
     *
     * @param orientation is used to determine if we handle top/bottom or left/right events.
     * @param startBehavior when we should perform the overscroll animation at the top/left.
     * @param endBehavior when we should perform the overscroll animation at the bottom/right.
     */
    fun Modifier.nestedScrollToScene(
        orientation: Orientation,
        startBehavior: NestedScrollBehavior = NestedScrollBehavior.EdgeNoOverscroll,
        endBehavior: NestedScrollBehavior = NestedScrollBehavior.EdgeNoOverscroll,
    ): Modifier

    /**
     * Create a *movable* element identified by [key].
     *
Loading