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

Commit 2c02d345 authored by omarmt's avatar omarmt
Browse files

Introduction of the GestureHandler interface

The GestureHandler interface allows us to simulate gesture events
(scroll and drag) generated by Compose. This is useful for testing the
behavior of our components, as we can verify that they are responding to
 gestures as expected.

Test: atest SceneGestureHandlerTest
Bug: 291025415
Change-Id: If8afda7f7ea02703cc6829c1a967a616dfa09859
parent fb0b1e0f
Loading
Loading
Loading
Loading
+20 −0
Original line number Diff line number Diff line
package com.android.compose.animation.scene

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

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

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

interface NestedScrollHandler {
    val connection: NestedScrollConnection
}
+17 −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
@@ -100,3 +101,19 @@ private class SceneScopeImpl(
        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]
    }
}
+4 −2
Original line number Diff line number Diff line
@@ -17,6 +17,7 @@
package com.android.compose.animation.scene

import androidx.activity.compose.BackHandler
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
@@ -39,7 +40,8 @@ import androidx.compose.ui.unit.IntSize
import com.android.compose.ui.util.fastForEach
import kotlinx.coroutines.channels.Channel

internal class SceneTransitionLayoutImpl(
@VisibleForTesting
class SceneTransitionLayoutImpl(
    onChangeScene: (SceneKey) -> Unit,
    builder: SceneTransitionLayoutScope.() -> Unit,
    transitions: SceneTransitions,
@@ -60,7 +62,7 @@ internal class SceneTransitionLayoutImpl(
     * The size of this layout. Note that this could be [IntSize.Zero] if this layour does not have
     * any scene configured or right before the first measure pass of the layout.
     */
    internal var size by mutableStateOf(IntSize.Zero)
    @VisibleForTesting var size by mutableStateOf(IntSize.Zero)

    init {
        setScenes(builder)
+490 −528

File changed.

Preview size limit exceeded, changes collapsed.

+284 −0
Original line number Diff line number Diff line
package com.android.compose.animation.scene

import androidx.compose.foundation.gestures.Orientation
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.Velocity
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.android.compose.animation.scene.TestScenes.SceneA
import com.android.compose.animation.scene.TestScenes.SceneB
import com.android.compose.animation.scene.TestScenes.SceneC
import com.android.compose.animation.scene.TransitionState.Idle
import com.android.compose.animation.scene.TransitionState.Transition
import com.android.compose.test.MonotonicClockTestScope
import com.android.compose.test.runMonotonicClockTest
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import org.junit.Test
import org.junit.runner.RunWith

private const val SCREEN_SIZE = 100f

@RunWith(AndroidJUnit4::class)
class SceneGestureHandlerTest {
    private class TestGestureScope(
        val coroutineScope: MonotonicClockTestScope,
    ) {
        private var internalCurrentScene: SceneKey by mutableStateOf(SceneA)

        private val layoutState: SceneTransitionLayoutState =
            SceneTransitionLayoutState(internalCurrentScene)

        private val scenesBuilder: SceneTransitionLayoutScope.() -> Unit = {
            scene(
                key = SceneA,
                userActions = mapOf(Swipe.Up to SceneB, Swipe.Down to SceneC),
            ) {
                Text("SceneA")
            }
            scene(SceneB) { Text("SceneB") }
            scene(SceneC) { Text("SceneC") }
        }

        private val sceneGestureHandler =
            SceneGestureHandler(
                layoutImpl =
                    SceneTransitionLayoutImpl(
                            onChangeScene = { internalCurrentScene = it },
                            builder = scenesBuilder,
                            transitions = EmptyTestTransitions,
                            state = layoutState,
                            density = Density(1f)
                        )
                        .also { it.size = IntSize(SCREEN_SIZE.toInt(), SCREEN_SIZE.toInt()) },
                orientation = Orientation.Vertical,
                coroutineScope = coroutineScope,
            )

        val draggable = sceneGestureHandler.draggable

        val nestedScroll = sceneGestureHandler.nestedScroll.connection

        val velocityThreshold = sceneGestureHandler.velocityThreshold

        // 10% of the screen
        val deltaInPixels10 = SCREEN_SIZE * 0.1f

        // Offset y: 10% of the screen
        val offsetY10 = Offset(x = 0f, y = deltaInPixels10)

        val transitionState: TransitionState
            get() = layoutState.transitionState

        fun advanceUntilIdle() {
            coroutineScope.testScheduler.advanceUntilIdle()
        }

        fun assertScene(currentScene: SceneKey, isIdle: Boolean) {
            val idleMsg = if (isIdle) "MUST" else "MUST NOT"
            assertWithMessage("transitionState $idleMsg be Idle")
                .that(transitionState is Idle)
                .isEqualTo(isIdle)
            assertThat(transitionState.currentScene).isEqualTo(currentScene)
        }
    }

    @OptIn(ExperimentalTestApi::class)
    private fun runGestureTest(block: suspend TestGestureScope.() -> Unit) {
        runMonotonicClockTest { TestGestureScope(coroutineScope = this).block() }
    }

    @Test
    fun testPreconditions() = runGestureTest { assertScene(currentScene = SceneA, isIdle = true) }

    @Test
    fun onDragStarted_shouldStartATransition() = runGestureTest {
        draggable.onDragStarted(coroutineScope = coroutineScope, startedPosition = Offset.Zero)
        assertScene(currentScene = SceneA, isIdle = false)
    }

    @Test
    fun afterSceneTransitionIsStarted_interceptDragEvents() = runGestureTest {
        draggable.onDragStarted(coroutineScope = coroutineScope, startedPosition = Offset.Zero)
        assertScene(currentScene = SceneA, isIdle = false)
        val transition = transitionState as Transition

        draggable.onDelta(pixels = deltaInPixels10)
        assertThat(transition.progress).isEqualTo(0.1f)

        draggable.onDelta(pixels = deltaInPixels10)
        assertThat(transition.progress).isEqualTo(0.2f)
    }

    @Test
    fun onDragStoppedAfterDrag_velocityLowerThanThreshold_remainSameScene() = runGestureTest {
        draggable.onDragStarted(coroutineScope = coroutineScope, startedPosition = Offset.Zero)
        assertScene(currentScene = SceneA, isIdle = false)

        draggable.onDelta(pixels = deltaInPixels10)
        assertScene(currentScene = SceneA, isIdle = false)

        draggable.onDragStopped(
            coroutineScope = coroutineScope,
            velocity = velocityThreshold - 0.01f,
        )
        assertScene(currentScene = SceneA, isIdle = false)

        // wait for the stop animation
        advanceUntilIdle()
        assertScene(currentScene = SceneA, isIdle = true)
    }

    @Test
    fun onDragStoppedAfterDrag_velocityAtLeastThreshold_goToNextScene() = runGestureTest {
        draggable.onDragStarted(coroutineScope = coroutineScope, startedPosition = Offset.Zero)
        assertScene(currentScene = SceneA, isIdle = false)

        draggable.onDelta(pixels = deltaInPixels10)
        assertScene(currentScene = SceneA, isIdle = false)

        draggable.onDragStopped(
            coroutineScope = coroutineScope,
            velocity = velocityThreshold,
        )
        assertScene(currentScene = SceneC, isIdle = false)

        // wait for the stop animation
        advanceUntilIdle()
        assertScene(currentScene = SceneC, isIdle = true)
    }

    @Test
    fun onDragStoppedAfterStarted_returnImmediatelyToIdle() = runGestureTest {
        draggable.onDragStarted(coroutineScope = coroutineScope, startedPosition = Offset.Zero)
        assertScene(currentScene = SceneA, isIdle = false)

        draggable.onDragStopped(coroutineScope = coroutineScope, velocity = 0f)
        assertScene(currentScene = SceneA, isIdle = true)
    }

    @Test
    fun onInitialPreScroll_doNotChangeState() = runGestureTest {
        nestedScroll.onPreScroll(available = offsetY10, source = NestedScrollSource.Drag)
        assertScene(currentScene = SceneA, isIdle = true)
    }

    @Test
    fun onPostScrollWithNothingAvailable_doNotChangeState() = runGestureTest {
        val consumed =
            nestedScroll.onPostScroll(
                consumed = Offset.Zero,
                available = Offset.Zero,
                source = NestedScrollSource.Drag
            )

        assertScene(currentScene = SceneA, isIdle = true)
        assertThat(consumed).isEqualTo(Offset.Zero)
    }

    @Test
    fun onPostScrollWithSomethingAvailable_startSceneTransition() = runGestureTest {
        val consumed =
            nestedScroll.onPostScroll(
                consumed = Offset.Zero,
                available = offsetY10,
                source = NestedScrollSource.Drag
            )

        assertScene(currentScene = SceneA, isIdle = false)
        val transition = transitionState as Transition
        assertThat(transition.progress).isEqualTo(0.1f)
        assertThat(consumed).isEqualTo(offsetY10)
    }

    private fun TestGestureScope.nestedScrollEvents(
        available: Offset,
        consumedByScroll: Offset = Offset.Zero,
    ) {
        val consumedByPreScroll =
            nestedScroll.onPreScroll(available = available, source = NestedScrollSource.Drag)
        val consumed = consumedByPreScroll + consumedByScroll
        nestedScroll.onPostScroll(
            consumed = consumed,
            available = available - consumed,
            source = NestedScrollSource.Drag
        )
    }

    @Test
    fun afterSceneTransitionIsStarted_interceptPreScrollEvents() = runGestureTest {
        nestedScrollEvents(available = offsetY10)
        assertScene(currentScene = SceneA, isIdle = false)

        val transition = transitionState as Transition
        assertThat(transition.progress).isEqualTo(0.1f)

        // start intercept preScroll
        val consumed =
            nestedScroll.onPreScroll(available = offsetY10, source = NestedScrollSource.Drag)
        assertThat(transition.progress).isEqualTo(0.2f)

        // do nothing on postScroll
        nestedScroll.onPostScroll(
            consumed = consumed,
            available = Offset.Zero,
            source = NestedScrollSource.Drag
        )
        assertThat(transition.progress).isEqualTo(0.2f)

        nestedScrollEvents(available = offsetY10)
        assertThat(transition.progress).isEqualTo(0.3f)
        assertScene(currentScene = SceneA, isIdle = false)
    }

    @Test
    fun onPreFling_velocityLowerThanThreshold_remainSameScene() = runGestureTest {
        nestedScrollEvents(available = offsetY10)
        assertScene(currentScene = SceneA, isIdle = false)

        nestedScroll.onPreFling(available = Velocity.Zero)
        assertScene(currentScene = SceneA, isIdle = false)

        // wait for the stop animation
        advanceUntilIdle()
        assertScene(currentScene = SceneA, isIdle = true)
    }

    @Test
    fun onPreFling_velocityAtLeastThreshold_goToNextScene() = runGestureTest {
        nestedScrollEvents(available = offsetY10)
        assertScene(currentScene = SceneA, isIdle = false)

        nestedScroll.onPreFling(available = Velocity(0f, velocityThreshold))
        assertScene(currentScene = SceneC, isIdle = false)

        // wait for the stop animation
        advanceUntilIdle()
        assertScene(currentScene = SceneC, isIdle = true)
    }

    @Test
    fun scrollStartedInScene_doOverscrollAnimation() = runGestureTest {
        // we started the scroll in the scene
        nestedScrollEvents(available = offsetY10, consumedByScroll = offsetY10)

        // now we can intercept the scroll events
        nestedScrollEvents(available = offsetY10)
        assertScene(currentScene = SceneA, isIdle = false)

        nestedScroll.onPreFling(available = Velocity(0f, velocityThreshold))
        // should start an overscroll animation (the gesture started in the scene)
        assertScene(currentScene = SceneA, isIdle = false)

        // wait for the stop animation
        advanceUntilIdle()
        assertScene(currentScene = SceneA, isIdle = true)
    }
}
Loading