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

Commit de93b945 authored by omarmt's avatar omarmt
Browse files

[STL] handling scenarios where scrollable elements disappear

The idea is that the pointerInput controls the gesture only when none of
the descendants cannot consume it (so it always waits in the Main step).

 When the scrollable disappears, it stops consuming the gesture,
allowing the pointerInput to regain control.

Details explained in go/stl-gesture

Test: atest MultiPointerDraggableTest
Test: Manually tested on our demo app and Flexiglass
Bug: 336710596
Flag: NA
Change-Id: I48f48d624ba853a918f49993be277058bc2dfb3a
parent 78a7610a
Loading
Loading
Loading
Loading
+115 −66
Original line number Diff line number Diff line
@@ -17,8 +17,6 @@
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
@@ -26,12 +24,14 @@ import androidx.compose.foundation.gestures.verticalDrag
import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
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.changedToDownIgnoreConsumed
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.addPointerInputChange
@@ -45,8 +45,12 @@ import androidx.compose.ui.node.observeReads
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.util.fastAll
import androidx.compose.ui.util.fastForEach
import kotlin.coroutines.cancellation.CancellationException
import kotlin.math.sign
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive

/**
 * Make an element draggable in the given [orientation].
@@ -163,6 +167,10 @@ internal class MultiPointerDraggableNode(
            return
        }

        coroutineScope {
            awaitPointerEventScope {
                while (isActive) {
                    try {
                        detectDragGestures(
                            orientation = orientation,
                            startDragImmediately = startDragImmediately,
@@ -176,7 +184,8 @@ internal class MultiPointerDraggableNode(
                            },
                            onDragEnd = { controller ->
                                val viewConfiguration = currentValueOf(LocalViewConfiguration)
                val maxVelocity = viewConfiguration.maximumFlingVelocity.let { Velocity(it, it) }
                                val maxVelocity =
                                    viewConfiguration.maximumFlingVelocity.let { Velocity(it, it) }
                                val velocity = velocityTracker.calculateVelocity(maxVelocity)
                                controller.onStop(
                                    velocity =
@@ -191,6 +200,14 @@ internal class MultiPointerDraggableNode(
                                controller.onStop(velocity = 0f, canChangeScene = true)
                            },
                        )
                    } catch (exception: CancellationException) {
                        // If the coroutine scope is active, we can just restart the drag cycle.
                        if (!isActive) {
                            throw exception
                        }
                    }
                }
            }
        }
    }

@@ -202,36 +219,43 @@ internal class MultiPointerDraggableNode(
     * 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(
    private suspend fun AwaitPointerEventScope.detectDragGestures(
        orientation: Orientation,
        startDragImmediately: (startedPosition: Offset) -> Boolean,
    onDragStart: (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController,
    onDragEnd: (controller: DragController) -> Unit,
    onDragCancel: (controller: DragController) -> Unit,
        onDragStart:
            (startedPosition: Offset, overSlop: Float, pointersDown: Int) -> DragController,
        onDrag: (controller: DragController, change: PointerInputChange, dragAmount: Float) -> Unit,
        onDragEnd: (controller: DragController) -> Unit,
        onDragCancel: (controller: DragController) -> Unit
    ) {
    awaitEachGesture {
        val initialDown = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
        // Wait for a consumable event in [PointerEventPass.Main] pass
        val consumablePointer = awaitConsumableEvent().changes.first()

        var overSlop = 0f
        val drag =
            if (startDragImmediately(initialDown.position)) {
                initialDown.consume()
                initialDown
            if (startDragImmediately(consumablePointer.position)) {
                consumablePointer.consume()
                consumablePointer
            } 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.
                // TODO(b/291055080): Replace by await[Orientation]PointerSlopOrCancellation once it
                // is public.
                val drag =
                    when (orientation) {
                        Orientation.Horizontal ->
                            awaitHorizontalTouchSlopOrCancellation(down.id, onSlopReached)
                            awaitHorizontalTouchSlopOrCancellation(
                                consumablePointer.id,
                                onSlopReached
                            )
                        Orientation.Vertical ->
                            awaitVerticalTouchSlopOrCancellation(down.id, onSlopReached)
                            awaitVerticalTouchSlopOrCancellation(
                                consumablePointer.id,
                                onSlopReached
                            )
                    }

                // Make sure that overSlop is not 0f. This can happen when the user drags by exactly
@@ -240,16 +264,10 @@ private suspend fun PointerInputScope.detectDragGestures(
                // we intercept an ongoing swipe transition (i.e. startDragImmediately() returned
                // true).
                if (drag != null && overSlop == 0f) {
                    val deltaOffset = drag.position - initialDown.position
                    val delta =
                        when (orientation) {
                            Orientation.Horizontal -> deltaOffset.x
                            Orientation.Vertical -> deltaOffset.y
                        }
                    val delta = (drag.position - consumablePointer.position).toFloat()
                    check(delta != 0f) { "delta is equal to 0" }
                    overSlop = delta.sign
                }

                drag
            }

@@ -272,12 +290,12 @@ private suspend fun PointerInputScope.detectDragGestures(
                    when (orientation) {
                        Orientation.Horizontal ->
                            horizontalDrag(drag.id) {
                                onDrag(controller, it, it.positionChange().x)
                                onDrag(controller, it, it.positionChange().toFloat())
                                it.consume()
                            }
                        Orientation.Vertical ->
                            verticalDrag(drag.id) {
                                onDrag(controller, it, it.positionChange().y)
                                onDrag(controller, it, it.positionChange().toFloat())
                                it.consume()
                            }
                    }
@@ -293,4 +311,35 @@ private suspend fun PointerInputScope.detectDragGestures(
            }
        }
    }

    private suspend fun AwaitPointerEventScope.awaitConsumableEvent(): PointerEvent {
        fun canBeConsumed(changes: List<PointerInputChange>): Boolean {
            // All pointers must be:
            return changes.fastAll {
                // A) recently pressed: even if the event has already been consumed, we can still
                // use the recently added finger event to determine whether to initiate dragging the
                // scene.
                it.changedToDownIgnoreConsumed() ||
                    // B) unconsumed AND in a new position (on the current axis)
                    it.positionChange().toFloat() != 0f
            }
        }

        var event: PointerEvent
        do {
            // To allow the descendants with the opportunity to consume the event, we wait for it in
            // the Main pass.
            event = awaitPointerEvent()
        } while (!canBeConsumed(event.changes))

        // We found a consumable event in the Main pass
        return event
    }

    private fun Offset.toFloat(): Float {
        return when (orientation) {
            Orientation.Vertical -> y
            Orientation.Horizontal -> x
        }
    }
}
+236 −0
Original line number Diff line number Diff line
@@ -17,7 +17,10 @@
package com.android.compose.animation.scene

import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.rememberScrollableState
import androidx.compose.foundation.gestures.scrollable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -25,6 +28,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.test.junit4.createComposeRule
@@ -32,6 +38,8 @@ import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performTouchInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -110,4 +118,232 @@ class MultiPointerDraggableTest {
        assertThat(dragged).isTrue()
        assertThat(stopped).isTrue()
    }

    @Test
    fun handleDisappearingScrollableDuringAGesture() {
        val size = 200f
        val middle = Offset(size / 2f, size / 2f)

        var started = false
        var dragged = false
        var stopped = false
        var consumedByScroll = false
        var hasScrollable by mutableStateOf(true)

        var touchSlop = 0f
        rule.setContent {
            touchSlop = LocalViewConfiguration.current.touchSlop
            Box(
                Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() })
                    .multiPointerDraggable(
                        orientation = Orientation.Vertical,
                        enabled = { true },
                        startDragImmediately = { false },
                        onDragStarted = { _, _, _ ->
                            started = true
                            object : DragController {
                                override fun onDrag(delta: Float) {
                                    dragged = true
                                }

                                override fun onStop(velocity: Float, canChangeScene: Boolean) {
                                    stopped = true
                                }
                            }
                        },
                    )
            ) {
                if (hasScrollable) {
                    Box(
                        Modifier.scrollable(
                                // Consume all the vertical scroll gestures
                                rememberScrollableState(
                                    consumeScrollDelta = {
                                        consumedByScroll = true
                                        it
                                    }
                                ),
                                Orientation.Vertical
                            )
                            .fillMaxSize()
                    )
                }
            }
        }

        fun startDraggingDown() {
            rule.onRoot().performTouchInput {
                down(middle)
                moveBy(Offset(0f, touchSlop))
            }
        }

        fun continueDraggingDown() {
            rule.onRoot().performTouchInput { moveBy(Offset(0f, touchSlop)) }
        }

        fun releaseFinger() {
            rule.onRoot().performTouchInput { up() }
        }

        // Swipe down. This should intercepted by the scrollable modifier.
        startDraggingDown()
        assertThat(consumedByScroll).isTrue()
        assertThat(started).isFalse()
        assertThat(dragged).isFalse()
        assertThat(stopped).isFalse()

        // Reset the scroll state for the test
        consumedByScroll = false

        // Suddenly remove the scrollable container
        hasScrollable = false
        rule.waitForIdle()

        // Swipe down. This will be intercepted by multiPointerDraggable, it will wait touchSlop
        // before consuming it.
        continueDraggingDown()
        assertThat(consumedByScroll).isFalse()
        assertThat(started).isFalse()
        assertThat(dragged).isFalse()
        assertThat(stopped).isFalse()

        // Swipe down. This should both call onDragStarted() and onDragDelta().
        continueDraggingDown()
        assertThat(consumedByScroll).isFalse()
        assertThat(started).isTrue()
        assertThat(dragged).isTrue()
        assertThat(stopped).isFalse()

        rule.waitForIdle()
        releaseFinger()
        assertThat(stopped).isTrue()
    }

    @Test
    fun multiPointerWaitAConsumableEventInMainPass() {
        val size = 200f
        val middle = Offset(size / 2f, size / 2f)

        var started = false
        var dragged = false
        var stopped = false

        var childConsumesOnPass: PointerEventPass? = null

        suspend fun AwaitPointerEventScope.childPointerInputScope() {
            awaitPointerEvent(PointerEventPass.Initial).also { initial ->
                // Check unconsumed: it should be always true
                assertThat(initial.changes.any { it.isConsumed }).isFalse()

                if (childConsumesOnPass == PointerEventPass.Initial) {
                    initial.changes.first().consume()
                }
            }

            awaitPointerEvent(PointerEventPass.Main).also { main ->
                // Check unconsumed
                if (childConsumesOnPass != PointerEventPass.Initial) {
                    assertThat(main.changes.any { it.isConsumed }).isFalse()
                }

                if (childConsumesOnPass == PointerEventPass.Main) {
                    main.changes.first().consume()
                }
            }
        }

        var touchSlop = 0f
        rule.setContent {
            touchSlop = LocalViewConfiguration.current.touchSlop
            Box(
                Modifier.size(with(LocalDensity.current) { Size(size, size).toDpSize() })
                    .multiPointerDraggable(
                        orientation = Orientation.Vertical,
                        enabled = { true },
                        startDragImmediately = { false },
                        onDragStarted = { _, _, _ ->
                            started = true
                            object : DragController {
                                override fun onDrag(delta: Float) {
                                    dragged = true
                                }

                                override fun onStop(velocity: Float, canChangeScene: Boolean) {
                                    stopped = true
                                }
                            }
                        },
                    )
            ) {
                Box(
                    Modifier.pointerInput(Unit) {
                            coroutineScope {
                                awaitPointerEventScope {
                                    while (isActive) {
                                        childPointerInputScope()
                                    }
                                }
                            }
                        }
                        .fillMaxSize()
                )
            }
        }

        fun startDraggingDown() {
            rule.onRoot().performTouchInput {
                down(middle)
                moveBy(Offset(0f, touchSlop))
            }
        }

        fun continueDraggingDown() {
            rule.onRoot().performTouchInput { moveBy(Offset(0f, touchSlop)) }
        }

        childConsumesOnPass = PointerEventPass.Initial

        startDraggingDown()
        assertThat(started).isFalse()
        assertThat(dragged).isFalse()
        assertThat(stopped).isFalse()

        continueDraggingDown()
        assertThat(started).isFalse()
        assertThat(dragged).isFalse()
        assertThat(stopped).isFalse()

        childConsumesOnPass = PointerEventPass.Main

        continueDraggingDown()
        assertThat(started).isFalse()
        assertThat(dragged).isFalse()
        assertThat(stopped).isFalse()

        continueDraggingDown()
        assertThat(started).isFalse()
        assertThat(dragged).isFalse()
        assertThat(stopped).isFalse()

        childConsumesOnPass = null

        // Swipe down. This will be intercepted by multiPointerDraggable, it will wait touchSlop
        // before consuming it.
        continueDraggingDown()
        assertThat(started).isFalse()
        assertThat(dragged).isFalse()
        assertThat(stopped).isFalse()

        // Swipe down. This should both call onDragStarted() and onDragDelta().
        continueDraggingDown()
        assertThat(started).isTrue()
        assertThat(dragged).isTrue()
        assertThat(stopped).isFalse()

        childConsumesOnPass = PointerEventPass.Main

        continueDraggingDown()
        assertThat(stopped).isTrue()
    }
}