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

Commit de94ac9d authored by Jordan Demeulenaere's avatar Jordan Demeulenaere
Browse files

Add support for swipe with multiple fingers

This CL replaces the usage of Modifier.draggable by a custom
Modifier.pointerInput that returns the number of pointers down to
DraggableHandler.onDragStarted when a drag starts.

The current logic is pretty simple and mostly forked from Draggable.kt
and DragGestureDetector.kt: we still wait for the *first* pointer down
to reach the touch slop, then we count the number of distinct pointers
that are down/pressed. In the future, we might want to make the
implementation more complex and wait for any pointer down to reach the
touch slop, instead of just the first one.

Bug: 291055080
Test: SwipeToSceneTest
Change-Id: Ifdb3f118b6765a8b6d07b3b4fe0edd78273b33c2
parent 247efa91
Loading
Loading
Loading
Loading
+2 −3
Original line number Original line Diff line number Diff line
@@ -2,7 +2,6 @@ package com.android.compose.animation.scene


import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import kotlinx.coroutines.CoroutineScope


interface GestureHandler {
interface GestureHandler {
    val draggable: DraggableHandler
    val draggable: DraggableHandler
@@ -10,9 +9,9 @@ interface GestureHandler {
}
}


interface DraggableHandler {
interface DraggableHandler {
    suspend fun onDragStarted(coroutineScope: CoroutineScope, startedPosition: Offset)
    fun onDragStarted(startedPosition: Offset, pointersDown: Int = 1)
    fun onDelta(pixels: Float)
    fun onDelta(pixels: Float)
    suspend fun onDragStopped(coroutineScope: CoroutineScope, velocity: Float)
    fun onDragStopped(velocity: Float)
}
}


interface NestedScrollHandler {
interface NestedScrollHandler {
+191 −0
Original line number Original line 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.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation
import androidx.compose.foundation.gestures.awaitVerticalTouchSlopOrCancellation
import androidx.compose.foundation.gestures.horizontalDrag
import androidx.compose.foundation.gestures.verticalDrag
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerId
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.addPointerInputChange
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.util.fastForEach

/**
 * Make an element draggable in the given [orientation].
 *
 * The main difference with [multiPointerDraggable] and
 * [androidx.compose.foundation.gestures.draggable] is that [onDragStarted] also receives the number
 * of pointers that are down when the drag is started. If you don't need this information, you
 * should use `draggable` instead.
 *
 * Note that the current implementation is trivial: we wait for the touch slope on the *first* down
 * pointer, then we count the number of distinct pointers that are down right before calling
 * [onDragStarted]. This means that the drag won't start when a first pointer is down (but not
 * dragged) and a second pointer is down and dragged. This is an implementation detail that might
 * change in the future.
 */
// TODO(b/291055080): Migrate to the Modifier.Node API.
@Composable
internal fun Modifier.multiPointerDraggable(
    orientation: Orientation,
    enabled: Boolean,
    startDragImmediately: Boolean,
    onDragStarted: (startedPosition: Offset, pointersDown: Int) -> Unit,
    onDragDelta: (Float) -> Unit,
    onDragStopped: (velocity: Float) -> Unit,
): Modifier {
    val onDragStarted by rememberUpdatedState(onDragStarted)
    val onDragStopped by rememberUpdatedState(onDragStopped)
    val onDragDelta by rememberUpdatedState(onDragDelta)
    val startDragImmediately by rememberUpdatedState(startDragImmediately)

    val velocityTracker = remember { VelocityTracker() }
    val maxFlingVelocity =
        LocalViewConfiguration.current.maximumFlingVelocity.let { max ->
            val maxF = max.toFloat()
            Velocity(maxF, maxF)
        }

    return this.pointerInput(enabled, orientation, maxFlingVelocity) {
        if (!enabled) {
            return@pointerInput
        }

        val onDragStart: (Offset, Int) -> Unit = { startedPosition, pointersDown ->
            velocityTracker.resetTracking()
            onDragStarted(startedPosition, pointersDown)
        }

        val onDragCancel: () -> Unit = { onDragStopped(/* velocity= */ 0f) }

        val onDragEnd: () -> Unit = {
            val velocity = velocityTracker.calculateVelocity(maxFlingVelocity)
            onDragStopped(
                when (orientation) {
                    Orientation.Horizontal -> velocity.x
                    Orientation.Vertical -> velocity.y
                }
            )
        }

        val onDrag: (change: PointerInputChange, dragAmount: Float) -> Unit = { change, amount ->
            velocityTracker.addPointerInputChange(change)
            onDragDelta(amount)
        }

        detectDragGestures(
            orientation = orientation,
            startDragImmediately = { startDragImmediately },
            onDragStart = onDragStart,
            onDragEnd = onDragEnd,
            onDragCancel = onDragCancel,
            onDrag = onDrag,
        )
    }
}

/**
 * Detect drag gestures in the given [orientation].
 *
 * This function is a mix of [androidx.compose.foundation.gestures.awaitDownAndSlop] and
 * [androidx.compose.foundation.gestures.detectVerticalDragGestures] to add support for:
 * 1) starting the gesture immediately without requiring a drag >= touch slope;
 * 2) passing the number of pointers down to [onDragStart].
 */
private suspend fun PointerInputScope.detectDragGestures(
    orientation: Orientation,
    startDragImmediately: () -> Boolean,
    onDragStart: (startedPosition: Offset, pointersDown: Int) -> Unit,
    onDragEnd: () -> Unit,
    onDragCancel: () -> Unit,
    onDrag: (change: PointerInputChange, dragAmount: Float) -> Unit,
) {
    awaitEachGesture {
        val initialDown = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
        var overSlop = 0f
        val drag =
            if (startDragImmediately()) {
                initialDown.consume()
                initialDown
            } else {
                val down = awaitFirstDown(requireUnconsumed = false)
                val onSlopReached = { change: PointerInputChange, over: Float ->
                    change.consume()
                    overSlop = over
                }

                // TODO(b/291055080): Replace by await[Orientation]PointerSlopOrCancellation once
                // it is public.
                when (orientation) {
                    Orientation.Horizontal ->
                        awaitHorizontalTouchSlopOrCancellation(down.id, onSlopReached)
                    Orientation.Vertical ->
                        awaitVerticalTouchSlopOrCancellation(down.id, onSlopReached)
                }
            }

        if (drag != null) {
            // Count the number of pressed pointers.
            val pressed = mutableSetOf<PointerId>()
            currentEvent.changes.fastForEach { change ->
                if (change.pressed) {
                    pressed.add(change.id)
                }
            }

            onDragStart(drag.position, pressed.size)
            onDrag(drag, overSlop)

            val successful =
                when (orientation) {
                    Orientation.Horizontal ->
                        horizontalDrag(drag.id) {
                            onDrag(it, it.positionChange().x)
                            it.consume()
                        }
                    Orientation.Vertical ->
                        verticalDrag(drag.id) {
                            onDrag(it, it.positionChange().y)
                            it.consume()
                        }
                }

            if (successful) {
                onDragEnd()
            } else {
                onDragCancel()
            }
        }
    }
}
+0 −17
Original line number Original line Diff line number Diff line
@@ -16,7 +16,6 @@


package com.android.compose.animation.scene
package com.android.compose.animation.scene


import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.State
@@ -101,19 +100,3 @@ private class SceneScopeImpl(
        MovableElement(layoutImpl, scene, key, modifier, content)
        MovableElement(layoutImpl, scene, key, modifier, content)
    }
    }
}
}

/** The destination scene when swiping up or left from [upOrLeft]. */
internal fun Scene.upOrLeft(orientation: Orientation): SceneKey? {
    return when (orientation) {
        Orientation.Vertical -> userActions[Swipe.Up]
        Orientation.Horizontal -> userActions[Swipe.Left]
    }
}

/** The destination scene when swiping down or right from [downOrRight]. */
internal fun Scene.downOrRight(orientation: Orientation): SceneKey? {
    return when (orientation) {
        Orientation.Vertical -> userActions[Swipe.Down]
        Orientation.Horizontal -> userActions[Swipe.Right]
    }
}
+6 −5
Original line number Original line Diff line number Diff line
@@ -16,6 +16,7 @@


package com.android.compose.animation.scene
package com.android.compose.animation.scene


import androidx.compose.foundation.gestures.Orientation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.compose.runtime.remember
@@ -191,9 +192,9 @@ data class Swipe(
    }
    }
}
}


enum class SwipeDirection {
enum class SwipeDirection(val orientation: Orientation) {
    Up,
    Up(Orientation.Vertical),
    Down,
    Down(Orientation.Vertical),
    Left,
    Left(Orientation.Horizontal),
    Right,
    Right(Orientation.Horizontal),
}
}
+62 −16
Original line number Original line Diff line number Diff line
@@ -22,8 +22,6 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.gestures.Orientation
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.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.getValue
@@ -55,7 +53,7 @@ internal fun Modifier.swipeToScene(


    /** Whether swipe should be enabled in the given [orientation]. */
    /** Whether swipe should be enabled in the given [orientation]. */
    fun Scene.shouldEnableSwipes(orientation: Orientation): Boolean =
    fun Scene.shouldEnableSwipes(orientation: Orientation): Boolean =
        upOrLeft(orientation) != null || downOrRight(orientation) != null
        userActions.keys.any { it is Swipe && it.direction.orientation == orientation }


    val currentScene = gestureHandler.currentScene
    val currentScene = gestureHandler.currentScene
    val canSwipe = currentScene.shouldEnableSwipes(orientation)
    val canSwipe = currentScene.shouldEnableSwipes(orientation)
@@ -68,8 +66,7 @@ internal fun Modifier.swipeToScene(
        )
        )


    return nestedScroll(connection = gestureHandler.nestedScroll.connection)
    return nestedScroll(connection = gestureHandler.nestedScroll.connection)
        .draggable(
        .multiPointerDraggable(
            state = rememberDraggableState(onDelta = gestureHandler.draggable::onDelta),
            orientation = orientation,
            orientation = orientation,
            enabled = gestureHandler.isDrivingTransition || canSwipe,
            enabled = gestureHandler.isDrivingTransition || canSwipe,
            // Immediately start the drag if this our [transition] is currently animating to a scene
            // Immediately start the drag if this our [transition] is currently animating to a scene
@@ -80,6 +77,7 @@ internal fun Modifier.swipeToScene(
                    gestureHandler.isAnimatingOffset &&
                    gestureHandler.isAnimatingOffset &&
                    !canOppositeSwipe,
                    !canOppositeSwipe,
            onDragStarted = gestureHandler.draggable::onDragStarted,
            onDragStarted = gestureHandler.draggable::onDragStarted,
            onDragDelta = gestureHandler.draggable::onDelta,
            onDragStopped = gestureHandler.draggable::onDragStopped,
            onDragStopped = gestureHandler.draggable::onDragStopped,
        )
        )
}
}
@@ -159,7 +157,7 @@ class SceneGestureHandler(


    internal var gestureWithPriority: Any? = null
    internal var gestureWithPriority: Any? = null


    internal fun onDragStarted() {
    internal fun onDragStarted(pointersDown: Int) {
        if (isDrivingTransition) {
        if (isDrivingTransition) {
            // This [transition] was already driving the animation: simply take over it.
            // This [transition] was already driving the animation: simply take over it.
            // Stop animating and start from where the current offset.
            // Stop animating and start from where the current offset.
@@ -199,6 +197,26 @@ class SceneGestureHandler(
                Orientation.Vertical -> layoutImpl.size.height
                Orientation.Vertical -> layoutImpl.size.height
            }.toFloat()
            }.toFloat()


        swipeTransition.actionUpOrLeft =
            Swipe(
                direction =
                    when (orientation) {
                        Orientation.Horizontal -> SwipeDirection.Left
                        Orientation.Vertical -> SwipeDirection.Up
                    },
                pointerCount = pointersDown,
            )

        swipeTransition.actionDownOrRight =
            Swipe(
                direction =
                    when (orientation) {
                        Orientation.Horizontal -> SwipeDirection.Right
                        Orientation.Vertical -> SwipeDirection.Down
                    },
                pointerCount = pointersDown,
            )

        if (swipeTransition.absoluteDistance > 0f) {
        if (swipeTransition.absoluteDistance > 0f) {
            transitionState = swipeTransition
            transitionState = swipeTransition
        }
        }
@@ -246,11 +264,15 @@ class SceneGestureHandler(
        // to the next screen or go back to the previous one.
        // to the next screen or go back to the previous one.
        val offset = swipeTransition.dragOffset
        val offset = swipeTransition.dragOffset
        val absoluteDistance = swipeTransition.absoluteDistance
        val absoluteDistance = swipeTransition.absoluteDistance
        if (offset <= -absoluteDistance && fromScene.upOrLeft(orientation) == toScene.key) {
        if (
            offset <= -absoluteDistance &&
                fromScene.userActions[swipeTransition.actionUpOrLeft] == toScene.key
        ) {
            swipeTransition.dragOffset += absoluteDistance
            swipeTransition.dragOffset += absoluteDistance
            swipeTransition._fromScene = toScene
            swipeTransition._fromScene = toScene
        } else if (
        } else if (
            offset >= absoluteDistance && fromScene.downOrRight(orientation) == toScene.key
            offset >= absoluteDistance &&
                fromScene.userActions[swipeTransition.actionDownOrRight] == toScene.key
        ) {
        ) {
            swipeTransition.dragOffset -= absoluteDistance
            swipeTransition.dragOffset -= absoluteDistance
            swipeTransition._fromScene = toScene
            swipeTransition._fromScene = toScene
@@ -272,8 +294,8 @@ class SceneGestureHandler(
                Orientation.Vertical -> layoutImpl.size.height
                Orientation.Vertical -> layoutImpl.size.height
            }.toFloat()
            }.toFloat()


        val upOrLeft = upOrLeft(orientation)
        val upOrLeft = userActions[swipeTransition.actionUpOrLeft]
        val downOrRight = downOrRight(orientation)
        val downOrRight = userActions[swipeTransition.actionDownOrRight]


        // Compute the target scene depending on the current offset.
        // Compute the target scene depending on the current offset.
        return when {
        return when {
@@ -516,6 +538,10 @@ class SceneGestureHandler(
        var _distance by mutableFloatStateOf(0f)
        var _distance by mutableFloatStateOf(0f)
        val distance: Float
        val distance: Float
            get() = _distance
            get() = _distance

        /** The [UserAction]s associated to this swipe. */
        var actionUpOrLeft: UserAction = Back
        var actionDownOrRight: UserAction = Back
    }
    }


    companion object {
    companion object {
@@ -526,9 +552,9 @@ class SceneGestureHandler(
private class SceneDraggableHandler(
private class SceneDraggableHandler(
    private val gestureHandler: SceneGestureHandler,
    private val gestureHandler: SceneGestureHandler,
) : DraggableHandler {
) : DraggableHandler {
    override suspend fun onDragStarted(coroutineScope: CoroutineScope, startedPosition: Offset) {
    override fun onDragStarted(startedPosition: Offset, pointersDown: Int) {
        gestureHandler.gestureWithPriority = this
        gestureHandler.gestureWithPriority = this
        gestureHandler.onDragStarted()
        gestureHandler.onDragStarted(pointersDown)
    }
    }


    override fun onDelta(pixels: Float) {
    override fun onDelta(pixels: Float) {
@@ -537,7 +563,7 @@ private class SceneDraggableHandler(
        }
        }
    }
    }


    override suspend fun onDragStopped(coroutineScope: CoroutineScope, velocity: Float) {
    override fun onDragStopped(velocity: Float) {
        if (gestureHandler.gestureWithPriority == this) {
        if (gestureHandler.gestureWithPriority == this) {
            gestureHandler.gestureWithPriority = null
            gestureHandler.gestureWithPriority = null
            gestureHandler.onDragStopped(velocity = velocity, canChangeScene = true)
            gestureHandler.onDragStopped(velocity = velocity, canChangeScene = true)
@@ -580,11 +606,31 @@ class SceneNestedScrollHandler(
        // moving on to the next scene.
        // moving on to the next scene.
        var gestureStartedOnNestedChild = false
        var gestureStartedOnNestedChild = false


        val actionUpOrLeft =
            Swipe(
                direction =
                    when (gestureHandler.orientation) {
                        Orientation.Horizontal -> SwipeDirection.Left
                        Orientation.Vertical -> SwipeDirection.Up
                    },
                pointerCount = 1,
            )

        val actionDownOrRight =
            Swipe(
                direction =
                    when (gestureHandler.orientation) {
                        Orientation.Horizontal -> SwipeDirection.Right
                        Orientation.Vertical -> SwipeDirection.Down
                    },
                pointerCount = 1,
            )

        fun findNextScene(amount: Float): SceneKey? {
        fun findNextScene(amount: Float): SceneKey? {
            val fromScene = gestureHandler.currentScene
            val fromScene = gestureHandler.currentScene
            return when {
            return when {
                amount < 0f -> fromScene.upOrLeft(gestureHandler.orientation)
                amount < 0f -> fromScene.userActions[actionUpOrLeft]
                amount > 0f -> fromScene.downOrRight(gestureHandler.orientation)
                amount > 0f -> fromScene.userActions[actionDownOrRight]
                else -> null
                else -> null
            }
            }
        }
        }
@@ -625,7 +671,7 @@ class SceneNestedScrollHandler(
            onStart = {
            onStart = {
                gestureHandler.gestureWithPriority = this
                gestureHandler.gestureWithPriority = this
                priorityScene = nextScene
                priorityScene = nextScene
                gestureHandler.onDragStarted()
                gestureHandler.onDragStarted(pointersDown = 1)
            },
            },
            onScroll = { offsetAvailable ->
            onScroll = { offsetAvailable ->
                if (gestureHandler.gestureWithPriority != this) {
                if (gestureHandler.gestureWithPriority != this) {
Loading