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

Commit 8fc9ea4a authored by Jordan Demeulenaere's avatar Jordan Demeulenaere Committed by Android (Google) Code Review
Browse files

Merge "Introduce draggable that supports nested scrolls and overscroll effects" into main

parents 30b420fa eca7aa55
Loading
Loading
Loading
Loading
+484 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.gesture

import androidx.compose.foundation.OverscrollEffect
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.foundation.overscroll
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScrollModifierNode
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEvent
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.SuspendingPointerInputModifierNode
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.node.CompositionLocalConsumerModifierNode
import androidx.compose.ui.node.DelegatingNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.PointerInputModifierNode
import androidx.compose.ui.node.currentValueOf
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.util.fastAny
import com.android.compose.modifiers.thenIf
import kotlin.math.sign
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.launch

/**
 * A draggable that plays nicely with the nested scroll mechanism.
 *
 * This can be used whenever you need a draggable inside a scrollable or a draggable that contains a
 * scrollable.
 */
interface NestedDraggable {
    /**
     * Called when a drag is started in the given [position] (*before* dragging the touch slop) and
     * in the direction given by [sign].
     */
    fun onDragStarted(position: Offset, sign: Float): Controller

    /**
     * Whether this draggable should consume any scroll amount with the given [sign] coming from a
     * nested scrollable.
     *
     * This is called whenever a nested scrollable does not consume some scroll amount. If this
     * returns `true`, then [onDragStarted] will be called and this draggable will have priority and
     * consume all future events during preScroll until the nested scroll is finished.
     */
    fun shouldConsumeNestedScroll(sign: Float): Boolean

    interface Controller {
        /**
         * Drag by [delta] pixels.
         *
         * @return the consumed [delta]. Any non-consumed delta will be dispatched to the next
         *   nested scroll connection to be consumed by any composable above in the hierarchy. If
         *   the drag was performed on this draggable directly (instead of on a nested scrollable),
         *   any remaining delta will be used to overscroll this draggable.
         */
        fun onDrag(delta: Float): Float

        /**
         * Stop the current drag with the given [velocity].
         *
         * @return the consumed [velocity]. Any non-consumed velocity will be dispatched to the next
         *   nested scroll connection to be consumed by any composable above in the hierarchy. If
         *   the drag was performed on this draggable directly (instead of on a nested scrollable),
         *   any remaining velocity will be used to animate the overscroll of this draggable.
         */
        suspend fun onDragStopped(velocity: Float): Float
    }
}

/**
 * A draggable that supports nested scrolling and overscroll effects.
 *
 * @see NestedDraggable
 */
fun Modifier.nestedDraggable(
    draggable: NestedDraggable,
    orientation: Orientation,
    overscrollEffect: OverscrollEffect? = null,
): Modifier {
    return this.thenIf(overscrollEffect != null) { Modifier.overscroll(overscrollEffect) }
        .then(NestedDraggableElement(draggable, orientation, overscrollEffect))
}

private data class NestedDraggableElement(
    private val draggable: NestedDraggable,
    private val orientation: Orientation,
    private val overscrollEffect: OverscrollEffect?,
) : ModifierNodeElement<NestedDraggableNode>() {
    override fun create(): NestedDraggableNode {
        return NestedDraggableNode(draggable, orientation, overscrollEffect)
    }

    override fun update(node: NestedDraggableNode) {
        node.update(draggable, orientation, overscrollEffect)
    }
}

private class NestedDraggableNode(
    private var draggable: NestedDraggable,
    override var orientation: Orientation,
    private var overscrollEffect: OverscrollEffect?,
) :
    DelegatingNode(),
    PointerInputModifierNode,
    NestedScrollConnection,
    CompositionLocalConsumerModifierNode,
    OrientationAware {
    private val nestedScrollDispatcher = NestedScrollDispatcher()
    private var trackDownPositionDelegate: SuspendingPointerInputModifierNode? = null
        set(value) {
            field?.let { undelegate(it) }
            field = value?.also { delegate(it) }
        }

    private var detectDragsDelegate: SuspendingPointerInputModifierNode? = null
        set(value) {
            field?.let { undelegate(it) }
            field = value?.also { delegate(it) }
        }

    /** The controller created by the nested scroll logic (and *not* the drag logic). */
    private var nestedScrollController: WrappedController? = null
        set(value) {
            field?.ensureOnDragStoppedIsCalled()
            field = value
        }

    /**
     * The last pointer which was the first down since the last time all pointers were up.
     *
     * This is use to track the started position of a drag started on a nested scrollable.
     */
    private var lastFirstDown: Offset? = null

    init {
        delegate(nestedScrollModifierNode(this, nestedScrollDispatcher))
    }

    override fun onDetach() {
        nestedScrollController?.ensureOnDragStoppedIsCalled()
    }

    fun update(
        draggable: NestedDraggable,
        orientation: Orientation,
        overscrollEffect: OverscrollEffect?,
    ) {
        this.draggable = draggable
        this.orientation = orientation
        this.overscrollEffect = overscrollEffect

        trackDownPositionDelegate?.resetPointerInputHandler()
        detectDragsDelegate?.resetPointerInputHandler()
        nestedScrollController?.ensureOnDragStoppedIsCalled()
    }

    override fun onPointerEvent(
        pointerEvent: PointerEvent,
        pass: PointerEventPass,
        bounds: IntSize,
    ) {
        if (trackDownPositionDelegate == null) {
            check(detectDragsDelegate == null)
            trackDownPositionDelegate = SuspendingPointerInputModifierNode { trackDownPosition() }
            detectDragsDelegate = SuspendingPointerInputModifierNode { detectDrags() }
        }

        checkNotNull(trackDownPositionDelegate).onPointerEvent(pointerEvent, pass, bounds)
        checkNotNull(detectDragsDelegate).onPointerEvent(pointerEvent, pass, bounds)
    }

    override fun onCancelPointerInput() {
        trackDownPositionDelegate?.onCancelPointerInput()
        detectDragsDelegate?.onCancelPointerInput()
    }

    /*
     * ======================================
     * ===== Pointer input (drag) logic =====
     * ======================================
     */

    private suspend fun PointerInputScope.detectDrags() {
        // Lazily create the velocity tracker when the pointer input restarts.
        val velocityTracker = VelocityTracker()

        awaitEachGesture {
            val down = awaitFirstDown(requireUnconsumed = false)
            var overSlop = 0f
            val onTouchSlopReached = { change: PointerInputChange, over: Float ->
                change.consume()
                overSlop = over
            }

            suspend fun AwaitPointerEventScope.awaitTouchSlopOrCancellation(
                pointerId: PointerId
            ): PointerInputChange? {
                return when (orientation) {
                    Orientation.Horizontal ->
                        awaitHorizontalTouchSlopOrCancellation(pointerId, onTouchSlopReached)
                    Orientation.Vertical ->
                        awaitVerticalTouchSlopOrCancellation(pointerId, onTouchSlopReached)
                }
            }

            var drag = awaitTouchSlopOrCancellation(down.id)

            // We try to pick-up the drag gesture in case the touch slop swipe was consumed by a
            // nested scrollable child that disappeared.
            // This was copied from http://shortn/_10L8U02IoL.
            // TODO(b/380838584): Reuse detect(Horizontal|Vertical)DragGestures() instead.
            while (drag == null && currentEvent.changes.fastAny { it.pressed }) {
                var event: PointerEvent
                do {
                    event = awaitPointerEvent()
                } while (
                    event.changes.fastAny { it.isConsumed } && event.changes.fastAny { it.pressed }
                )

                // An event was not consumed and there's still a pointer in the screen.
                if (event.changes.fastAny { it.pressed }) {
                    // Await touch slop again, using the initial down as starting point.
                    // For most cases this should return immediately since we probably moved
                    // far enough from the initial down event.
                    drag = awaitTouchSlopOrCancellation(down.id)
                }
            }

            if (drag != null) {
                velocityTracker.resetTracking()

                val sign = (drag.position - down.position).toFloat().sign
                val wrappedController =
                    WrappedController(coroutineScope, draggable.onDragStarted(down.position, sign))
                if (overSlop != 0f) {
                    onDrag(wrappedController, drag, overSlop, velocityTracker)
                }

                // If a drag was started, we cancel any other drag started by a nested scrollable.
                //
                // Note: we cancel the nested drag here *after* starting the new drag so that in the
                // STL case, the cancelled drag will not change the current scene of the STL.
                nestedScrollController?.ensureOnDragStoppedIsCalled()

                val isSuccessful =
                    try {
                        val onDrag = { change: PointerInputChange ->
                            onDrag(
                                wrappedController,
                                change,
                                change.positionChange().toFloat(),
                                velocityTracker,
                            )
                            change.consume()
                        }

                        when (orientation) {
                            Orientation.Horizontal -> horizontalDrag(drag.id, onDrag)
                            Orientation.Vertical -> verticalDrag(drag.id, onDrag)
                        }
                    } catch (t: Throwable) {
                        wrappedController.ensureOnDragStoppedIsCalled()
                        throw t
                    }

                if (isSuccessful) {
                    val maxVelocity = currentValueOf(LocalViewConfiguration).maximumFlingVelocity
                    val velocity =
                        velocityTracker
                            .calculateVelocity(Velocity(maxVelocity, maxVelocity))
                            .toFloat()
                    onDragStopped(wrappedController, velocity)
                } else {
                    onDragStopped(wrappedController, velocity = 0f)
                }
            }
        }
    }

    private fun onDrag(
        controller: NestedDraggable.Controller,
        change: PointerInputChange,
        delta: Float,
        velocityTracker: VelocityTracker,
    ) {
        velocityTracker.addPointerInputChange(change)

        scrollWithOverscroll(delta) { deltaFromOverscroll ->
            scrollWithNestedScroll(deltaFromOverscroll) { deltaFromNestedScroll ->
                controller.onDrag(deltaFromNestedScroll)
            }
        }
    }

    private fun onDragStopped(controller: WrappedController, velocity: Float) {
        coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) {
            try {
                flingWithOverscroll(velocity) { velocityFromOverscroll ->
                    flingWithNestedScroll(velocityFromOverscroll) { velocityFromNestedScroll ->
                        controller.onDragStopped(velocityFromNestedScroll)
                    }
                }
            } finally {
                controller.ensureOnDragStoppedIsCalled()
            }
        }
    }

    private fun scrollWithOverscroll(delta: Float, performScroll: (Float) -> Float): Float {
        val effect = overscrollEffect
        return if (effect != null) {
            effect
                .applyToScroll(delta.toOffset(), source = NestedScrollSource.UserInput) {
                    performScroll(it.toFloat()).toOffset()
                }
                .toFloat()
        } else {
            performScroll(delta)
        }
    }

    private fun scrollWithNestedScroll(delta: Float, performScroll: (Float) -> Float): Float {
        val preConsumed =
            nestedScrollDispatcher
                .dispatchPreScroll(
                    available = delta.toOffset(),
                    source = NestedScrollSource.UserInput,
                )
                .toFloat()
        val available = delta - preConsumed
        val consumed = performScroll(available)
        val left = available - consumed
        val postConsumed =
            nestedScrollDispatcher
                .dispatchPostScroll(
                    consumed = (preConsumed + consumed).toOffset(),
                    available = left.toOffset(),
                    source = NestedScrollSource.UserInput,
                )
                .toFloat()
        return consumed + preConsumed + postConsumed
    }

    private suspend fun flingWithOverscroll(
        velocity: Float,
        performFling: suspend (Float) -> Float,
    ) {
        val effect = overscrollEffect
        if (effect != null) {
            effect.applyToFling(velocity.toVelocity()) { performFling(it.toFloat()).toVelocity() }
        } else {
            performFling(velocity)
        }
    }

    private suspend fun flingWithNestedScroll(
        velocity: Float,
        performFling: suspend (Float) -> Float,
    ): Float {
        val preConsumed = nestedScrollDispatcher.dispatchPreFling(available = velocity.toVelocity())
        val available = velocity - preConsumed.toFloat()
        val consumed = performFling(available)
        val left = available - consumed
        return nestedScrollDispatcher
            .dispatchPostFling(
                consumed = consumed.toVelocity() + preConsumed,
                available = left.toVelocity(),
            )
            .toFloat()
    }

    /*
     * ===============================
     * ===== Nested scroll logic =====
     * ===============================
     */

    private suspend fun PointerInputScope.trackDownPosition() {
        awaitEachGesture { lastFirstDown = awaitFirstDown(requireUnconsumed = false).position }
    }

    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        val controller = nestedScrollController ?: return Offset.Zero
        val consumed = controller.onDrag(available.toFloat())
        return consumed.toOffset()
    }

    override fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource,
    ): Offset {
        if (source == NestedScrollSource.SideEffect) {
            check(nestedScrollController == null)
            return Offset.Zero
        }

        val offset = available.toFloat()
        if (offset == 0f) {
            return Offset.Zero
        }

        val sign = offset.sign
        if (nestedScrollController == null && draggable.shouldConsumeNestedScroll(sign)) {
            val startedPosition = checkNotNull(lastFirstDown) { "lastFirstDown is not set" }
            nestedScrollController =
                WrappedController(coroutineScope, draggable.onDragStarted(startedPosition, sign))
        }

        val controller = nestedScrollController ?: return Offset.Zero
        return controller.onDrag(offset).toOffset()
    }

    override suspend fun onPreFling(available: Velocity): Velocity {
        val controller = nestedScrollController ?: return Velocity.Zero
        nestedScrollController = null

        val consumed = controller.onDragStopped(available.toFloat())
        return consumed.toVelocity()
    }
}

/**
 * A controller that wraps [delegate] and can be used to ensure that [onDragStopped] is called, but
 * not more than once.
 */
private class WrappedController(
    private val coroutineScope: CoroutineScope,
    private val delegate: NestedDraggable.Controller,
) : NestedDraggable.Controller by delegate {
    private var onDragStoppedCalled = false

    override fun onDrag(delta: Float): Float {
        if (onDragStoppedCalled) return 0f
        return delegate.onDrag(delta)
    }

    override suspend fun onDragStopped(velocity: Float): Float {
        if (onDragStoppedCalled) return 0f
        onDragStoppedCalled = true
        return delegate.onDragStopped(velocity)
    }

    fun ensureOnDragStoppedIsCalled() {
        // Start with UNDISPATCHED so that onDragStopped() is always run until its first suspension
        // point, even if coroutineScope is cancelled.
        coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) { onDragStopped(velocity = 0f) }
    }
}
+57 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.gesture

import androidx.compose.foundation.gestures.Orientation
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.Velocity

/**
 * An interface to conveniently convert a [Float] to and from an [Offset] or a [Velocity] given an
 * [orientation].
 */
interface OrientationAware {
    val orientation: Orientation

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

    fun Float.toVelocity(): Velocity {
        return when (orientation) {
            Orientation.Horizontal -> Velocity(x = this, y = 0f)
            Orientation.Vertical -> Velocity(x = 0f, y = this)
        }
    }

    fun Offset.toFloat(): Float {
        return when (orientation) {
            Orientation.Horizontal -> this.x
            Orientation.Vertical -> this.y
        }
    }

    fun Velocity.toFloat(): Float {
        return when (orientation) {
            Orientation.Horizontal -> this.x
            Orientation.Vertical -> this.y
        }
    }
}
+404 −0

File added.

Preview size limit exceeded, changes collapsed.