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

Commit 02aa28cc authored by Treehugger Robot's avatar Treehugger Robot Committed by Android (Google) Code Review
Browse files

Merge "[STL] handling scenarios where scrollable elements disappear" into main

parents cdb63b98 de93b945
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()
    }
}