Add SceneScope.nestedScrollToScene() modifier
Defines the behavior to use when the scrollable element can no longer
handle scroll events, and the conditions under which these events can be
used to transition to the next scene.
Also in this CL:
- GestureHandler has been removed, it is no longer needed.
- Overscroll can change the scene based on four configurations:
DuringTransitionBetweenScenes, EdgeNoOverscroll, EdgeWithOverscroll, or
Always.
- Simplified SceneNestedScrollHandler (it is no longer necessary to
define the priorityScene since the connection is reset when the scene
changes).
Test: atest SceneGestureHandlerTest
Bug: 291053278
Flag: NA
Change-Id: Ia4874bff4bdca0a9628d77a3a04c58ee86de47e0
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/GestureHandler.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/GestureHandler.kt
index ae7d8f5..216608a 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/GestureHandler.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/GestureHandler.kt
@@ -3,11 +3,6 @@
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
-interface GestureHandler {
- val draggable: DraggableHandler
- val nestedScroll: NestedScrollHandler
-}
-
interface DraggableHandler {
fun onDragStarted(startedPosition: Offset, pointersDown: Int = 1)
fun onDelta(pixels: Float)
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/NestedScrollToScene.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/NestedScrollToScene.kt
new file mode 100644
index 0000000..658b45f
--- /dev/null
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/NestedScrollToScene.kt
@@ -0,0 +1,103 @@
+/*
+ * 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.runtime.DisposableEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+
+/**
+ * Defines the behavior of the [SceneTransitionLayout] when a scrollable component is scrolled.
+ *
+ * By default, scrollable elements within the scene have priority during the user's gesture and are
+ * not consumed by the [SceneTransitionLayout] unless specifically requested via
+ * [nestedScrollToScene].
+ */
+enum class NestedScrollBehavior(val canStartOnPostFling: Boolean) {
+ /**
+ * During scene transitions, scroll events are consumed by the [SceneTransitionLayout] instead
+ * of the scrollable component.
+ */
+ DuringTransitionBetweenScenes(canStartOnPostFling = false),
+
+ /**
+ * Overscroll will only be used by the [SceneTransitionLayout] to move to the next scene if the
+ * gesture begins at the edge of the scrollable component (so that a scroll in that direction
+ * can no longer be consumed). If the gesture is partially consumed by the scrollable component,
+ * there will be NO overscroll effect between scenes.
+ *
+ * In addition, during scene transitions, scroll events are consumed by the
+ * [SceneTransitionLayout] instead of the scrollable component.
+ */
+ EdgeNoOverscroll(canStartOnPostFling = false),
+
+ /**
+ * Overscroll will only be used by the [SceneTransitionLayout] to move to the next scene if the
+ * gesture begins at the edge of the scrollable component. If the gesture is partially consumed
+ * by the scrollable component, there will be an overscroll effect between scenes.
+ *
+ * In addition, during scene transitions, scroll events are consumed by the
+ * [SceneTransitionLayout] instead of the scrollable component.
+ */
+ EdgeWithOverscroll(canStartOnPostFling = true),
+
+ /**
+ * Any overscroll will be used by the [SceneTransitionLayout] to move to the next scene.
+ *
+ * In addition, during scene transitions, scroll events are consumed by the
+ * [SceneTransitionLayout] instead of the scrollable component.
+ */
+ Always(canStartOnPostFling = true),
+}
+
+internal fun Modifier.nestedScrollToScene(
+ layoutImpl: SceneTransitionLayoutImpl,
+ orientation: Orientation,
+ startBehavior: NestedScrollBehavior,
+ endBehavior: NestedScrollBehavior,
+): Modifier = composed {
+ val connection =
+ remember(layoutImpl, orientation, startBehavior, endBehavior) {
+ scenePriorityNestedScrollConnection(
+ layoutImpl = layoutImpl,
+ orientation = orientation,
+ startBehavior = startBehavior,
+ endBehavior = endBehavior
+ )
+ }
+
+ // Make sure we reset the scroll connection when this modifier is removed from composition
+ DisposableEffect(connection) { onDispose { connection.reset() } }
+
+ nestedScroll(connection = connection)
+}
+
+private fun scenePriorityNestedScrollConnection(
+ layoutImpl: SceneTransitionLayoutImpl,
+ orientation: Orientation,
+ startBehavior: NestedScrollBehavior,
+ endBehavior: NestedScrollBehavior,
+) =
+ SceneNestedScrollHandler(
+ gestureHandler = layoutImpl.gestureHandler(orientation = orientation),
+ startBehavior = startBehavior,
+ endBehavior = endBehavior,
+ )
+ .connection
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Scene.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Scene.kt
index 2e50a71..eb5168b 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Scene.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Scene.kt
@@ -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
@@ -68,6 +69,18 @@
return element(layoutImpl, scene, key)
}
+ override fun Modifier.nestedScrollToScene(
+ orientation: Orientation,
+ startBehavior: NestedScrollBehavior,
+ endBehavior: NestedScrollBehavior,
+ ): Modifier =
+ nestedScrollToScene(
+ layoutImpl = layoutImpl,
+ orientation = orientation,
+ startBehavior = startBehavior,
+ endBehavior = endBehavior,
+ )
+
@Composable
override fun <T> animateSharedValueAsState(
value: T,
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneGestureHandler.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneGestureHandler.kt
index 7563e27..9a3a0ae 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneGestureHandler.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneGestureHandler.kt
@@ -1,3 +1,19 @@
+/*
+ * 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 android.util.Log
@@ -25,10 +41,8 @@
private val layoutImpl: SceneTransitionLayoutImpl,
internal val orientation: Orientation,
private val coroutineScope: CoroutineScope,
-) : GestureHandler {
- override val draggable: DraggableHandler = SceneDraggableHandler(this)
-
- override val nestedScroll: SceneNestedScrollHandler = SceneNestedScrollHandler(this)
+) {
+ val draggable: DraggableHandler = SceneDraggableHandler(this)
private var transitionState
get() = layoutImpl.state.transitionState
@@ -521,6 +535,8 @@
@VisibleForTesting
class SceneNestedScrollHandler(
private val gestureHandler: SceneGestureHandler,
+ private val startBehavior: NestedScrollBehavior,
+ private val endBehavior: NestedScrollBehavior,
) : NestedScrollHandler {
override val connection: PriorityNestedScrollConnection = nestedScrollConnection()
@@ -543,15 +559,9 @@
}
private fun nestedScrollConnection(): PriorityNestedScrollConnection {
- // The next potential scene is calculated during the canStart
- var nextScene: SceneKey? = null
-
- // This is the scene on which we will have priority during the scroll gesture.
- var priorityScene: SceneKey? = null
-
// If we performed a long gesture before entering priority mode, we would have to avoid
// moving on to the next scene.
- var gestureStartedOnNestedChild = false
+ var canChangeScene = false
val actionUpOrLeft =
Swipe(
@@ -573,51 +583,70 @@
pointerCount = 1,
)
- fun findNextScene(amount: Float): SceneKey? {
+ fun hasNextScene(amount: Float): Boolean {
val fromScene = gestureHandler.currentScene
- return when {
- amount < 0f -> fromScene.userActions[actionUpOrLeft]
- amount > 0f -> fromScene.userActions[actionDownOrRight]
- else -> null
- }
+ val nextScene =
+ when {
+ amount < 0f -> fromScene.userActions[actionUpOrLeft]
+ amount > 0f -> fromScene.userActions[actionDownOrRight]
+ else -> null
+ }
+ return nextScene != null
}
return PriorityNestedScrollConnection(
canStartPreScroll = { offsetAvailable, offsetBeforeStart ->
- gestureStartedOnNestedChild = offsetBeforeStart != Offset.Zero
-
- val canInterceptPreScroll =
- gestureHandler.isDrivingTransition &&
- !gestureStartedOnNestedChild &&
- offsetAvailable.toAmount() != 0f
-
- if (!canInterceptPreScroll) return@PriorityNestedScrollConnection false
-
- nextScene = gestureHandler.swipeTransitionToScene.key
-
- true
+ canChangeScene = offsetBeforeStart == Offset.Zero
+ gestureHandler.isDrivingTransition &&
+ canChangeScene &&
+ offsetAvailable.toAmount() != 0f
},
canStartPostScroll = { offsetAvailable, offsetBeforeStart ->
val amount = offsetAvailable.toAmount()
- if (amount == 0f) return@PriorityNestedScrollConnection false
+ val behavior: NestedScrollBehavior =
+ when {
+ amount > 0 -> startBehavior
+ amount < 0 -> endBehavior
+ else -> return@PriorityNestedScrollConnection false
+ }
- gestureStartedOnNestedChild = offsetBeforeStart != Offset.Zero
- nextScene = findNextScene(amount)
- nextScene != null
+ val isZeroOffset = offsetBeforeStart == Offset.Zero
+
+ when (behavior) {
+ NestedScrollBehavior.DuringTransitionBetweenScenes -> {
+ canChangeScene = false // unused: added for consistency
+ false
+ }
+ NestedScrollBehavior.EdgeNoOverscroll -> {
+ canChangeScene = isZeroOffset
+ isZeroOffset && hasNextScene(amount)
+ }
+ NestedScrollBehavior.EdgeWithOverscroll -> {
+ canChangeScene = isZeroOffset
+ hasNextScene(amount)
+ }
+ NestedScrollBehavior.Always -> {
+ canChangeScene = true
+ hasNextScene(amount)
+ }
+ }
},
canStartPostFling = { velocityAvailable ->
val amount = velocityAvailable.toAmount()
- if (amount == 0f) return@PriorityNestedScrollConnection false
+ val behavior: NestedScrollBehavior =
+ when {
+ amount > 0 -> startBehavior
+ amount < 0 -> endBehavior
+ else -> return@PriorityNestedScrollConnection false
+ }
// We could start an overscroll animation
- gestureStartedOnNestedChild = true
- nextScene = findNextScene(amount)
- nextScene != null
+ canChangeScene = false
+ behavior.canStartOnPostFling && hasNextScene(amount)
},
- canContinueScroll = { priorityScene == gestureHandler.swipeTransitionToScene.key },
+ canContinueScroll = { true },
onStart = {
gestureHandler.gestureWithPriority = this
- priorityScene = nextScene
gestureHandler.onDragStarted(pointersDown = 1, startedPosition = null)
},
onScroll = { offsetAvailable ->
@@ -638,11 +667,9 @@
return@PriorityNestedScrollConnection Velocity.Zero
}
- priorityScene = null
-
gestureHandler.onDragStopped(
velocity = velocityAvailable.toAmount(),
- canChangeScene = !gestureStartedOnNestedChild
+ canChangeScene = canChangeScene
)
// The onDragStopped animation consumes any remaining velocity.
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
index 35d1f90..efdfe7a 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt
@@ -20,7 +20,9 @@
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.platform.LocalDensity
/**
@@ -52,14 +54,16 @@
scenes: SceneTransitionLayoutScope.() -> Unit,
) {
val density = LocalDensity.current
+ val coroutineScope = rememberCoroutineScope()
val layoutImpl = remember {
SceneTransitionLayoutImpl(
- onChangeScene,
- scenes,
- transitions,
- state,
- density,
- edgeDetector,
+ onChangeScene = onChangeScene,
+ builder = scenes,
+ transitions = transitions,
+ state = state,
+ density = density,
+ edgeDetector = edgeDetector,
+ coroutineScope = coroutineScope,
)
}
@@ -120,6 +124,20 @@
fun Modifier.element(key: ElementKey): Modifier
/**
+ * Adds a [NestedScrollConnection] to intercept scroll events not handled by the scrollable
+ * component.
+ *
+ * @param orientation is used to determine if we handle top/bottom or left/right events.
+ * @param startBehavior when we should perform the overscroll animation at the top/left.
+ * @param endBehavior when we should perform the overscroll animation at the bottom/right.
+ */
+ fun Modifier.nestedScrollToScene(
+ orientation: Orientation,
+ startBehavior: NestedScrollBehavior = NestedScrollBehavior.EdgeNoOverscroll,
+ endBehavior: NestedScrollBehavior = NestedScrollBehavior.EdgeNoOverscroll,
+ ): Modifier
+
+ /**
* Create a *movable* element identified by [key].
*
* This creates an element that will be automatically shared when present in multiple scenes and
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt
index 6618eb0..6edd1b6 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt
@@ -37,6 +37,7 @@
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.util.fastForEach
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
@VisibleForTesting
@@ -47,6 +48,7 @@
internal val state: SceneTransitionLayoutState,
density: Density,
edgeDetector: EdgeDetector,
+ coroutineScope: CoroutineScope,
) {
internal val scenes = SnapshotStateMap<SceneKey, Scene>()
internal val elements = SnapshotStateMap<ElementKey, Element>()
@@ -59,6 +61,9 @@
internal var density: Density by mutableStateOf(density)
internal var edgeDetector by mutableStateOf(edgeDetector)
+ private val horizontalGestureHandler: SceneGestureHandler
+ private val verticalGestureHandler: SceneGestureHandler
+
/**
* 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.
@@ -67,8 +72,30 @@
init {
setScenes(builder)
+
+ // SceneGestureHandler must wait for the scenes to be initialized, in order to access the
+ // current scene (required for SwipeTransition).
+ horizontalGestureHandler =
+ SceneGestureHandler(
+ layoutImpl = this,
+ orientation = Orientation.Horizontal,
+ coroutineScope = coroutineScope,
+ )
+
+ verticalGestureHandler =
+ SceneGestureHandler(
+ layoutImpl = this,
+ orientation = Orientation.Vertical,
+ coroutineScope = coroutineScope,
+ )
}
+ internal fun gestureHandler(orientation: Orientation): SceneGestureHandler =
+ when (orientation) {
+ Orientation.Vertical -> verticalGestureHandler
+ Orientation.Horizontal -> horizontalGestureHandler
+ }
+
internal fun scene(key: SceneKey): Scene {
return scenes[key] ?: error("Scene $key is not configured")
}
@@ -131,18 +158,13 @@
@Composable
internal fun Content(modifier: Modifier) {
- val horizontalGestureHandler =
- rememberSceneGestureHandler(layoutImpl = this, Orientation.Horizontal)
- val verticalGestureHandler =
- rememberSceneGestureHandler(layoutImpl = this, Orientation.Vertical)
-
Box(
modifier
// Handle horizontal and vertical swipes on this layout.
// Note: order here is important and will give a slight priority to the vertical
// swipes.
- .swipeToScene(horizontalGestureHandler)
- .swipeToScene(verticalGestureHandler)
+ .swipeToScene(gestureHandler(Orientation.Horizontal))
+ .swipeToScene(gestureHandler(Orientation.Vertical))
.onSizeChanged { size = it }
) {
LookaheadScope {
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt
index 8980df8..2c78dee 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SwipeToScene.kt
@@ -17,12 +17,7 @@
package com.android.compose.animation.scene
import androidx.compose.foundation.gestures.Orientation
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
-import androidx.compose.ui.input.nestedscroll.nestedScroll
/**
* Configures the swipeable behavior of a [SceneTransitionLayout] depending on the current state.
@@ -43,38 +38,18 @@
}
)
- return nestedScroll(connection = gestureHandler.nestedScroll.connection)
- .multiPointerDraggable(
- orientation = orientation,
- enabled = gestureHandler.isDrivingTransition || canSwipe,
- // Immediately start the drag if this our [transition] is currently animating to a scene
- // (i.e. the user released their input pointer after swiping in this orientation) and
- // the user can't swipe in the other direction.
- startDragImmediately =
- gestureHandler.isDrivingTransition &&
- gestureHandler.isAnimatingOffset &&
- !canOppositeSwipe,
- onDragStarted = gestureHandler.draggable::onDragStarted,
- onDragDelta = gestureHandler.draggable::onDelta,
- onDragStopped = gestureHandler.draggable::onDragStopped,
- )
-}
-
-@Composable
-internal fun rememberSceneGestureHandler(
- layoutImpl: SceneTransitionLayoutImpl,
- orientation: Orientation,
-): SceneGestureHandler {
- val coroutineScope = rememberCoroutineScope()
-
- val gestureHandler =
- remember(layoutImpl, orientation, coroutineScope) {
- SceneGestureHandler(layoutImpl, orientation, coroutineScope)
- }
-
- // Make sure we reset the scroll connection when this handler is removed from composition
- val connection = gestureHandler.nestedScroll.connection
- DisposableEffect(connection) { onDispose { connection.reset() } }
-
- return gestureHandler
+ return multiPointerDraggable(
+ orientation = orientation,
+ enabled = gestureHandler.isDrivingTransition || canSwipe,
+ // Immediately start the drag if this our [transition] is currently animating to a scene
+ // (i.e. the user released their input pointer after swiping in this orientation) and the
+ // user can't swipe in the other direction.
+ startDragImmediately =
+ gestureHandler.isDrivingTransition &&
+ gestureHandler.isAnimatingOffset &&
+ !canOppositeSwipe,
+ onDragStarted = gestureHandler.draggable::onDragStarted,
+ onDragDelta = gestureHandler.draggable::onDelta,
+ onDragStopped = gestureHandler.draggable::onDragStopped,
+ )
}
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneGestureHandlerTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneGestureHandlerTest.kt
index 1eb3392..1e3d011 100644
--- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneGestureHandlerTest.kt
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/SceneGestureHandlerTest.kt
@@ -1,3 +1,19 @@
+/*
+ * 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
@@ -6,12 +22,17 @@
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
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.NestedScrollBehavior.Always
+import com.android.compose.animation.scene.NestedScrollBehavior.DuringTransitionBetweenScenes
+import com.android.compose.animation.scene.NestedScrollBehavior.EdgeNoOverscroll
+import com.android.compose.animation.scene.NestedScrollBehavior.EdgeWithOverscroll
import com.android.compose.animation.scene.TestScenes.SceneA
import com.android.compose.animation.scene.TestScenes.SceneB
import com.android.compose.animation.scene.TestScenes.SceneC
@@ -57,6 +78,7 @@
state = layoutState,
density = Density(1f),
edgeDetector = DefaultEdgeDetector,
+ coroutineScope = coroutineScope,
)
.also { it.size = IntSize(SCREEN_SIZE.toInt(), SCREEN_SIZE.toInt()) },
orientation = Orientation.Vertical,
@@ -65,7 +87,13 @@
val draggable = sceneGestureHandler.draggable
- val nestedScroll = sceneGestureHandler.nestedScroll.connection
+ fun nestedScrollConnection(nestedScrollBehavior: NestedScrollBehavior) =
+ SceneNestedScrollHandler(
+ gestureHandler = sceneGestureHandler,
+ startBehavior = nestedScrollBehavior,
+ endBehavior = nestedScrollBehavior,
+ )
+ .connection
val velocityThreshold = sceneGestureHandler.velocityThreshold
@@ -194,13 +222,15 @@
}
@Test
- fun onInitialPreScroll_doNotChangeState() = runGestureTest {
+ fun onInitialPreScroll_EdgeWithOverscroll_doNotChangeState() = runGestureTest {
+ val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeWithOverscroll)
nestedScroll.onPreScroll(available = offsetY10, source = NestedScrollSource.Drag)
assertScene(currentScene = SceneA, isIdle = true)
}
@Test
- fun onPostScrollWithNothingAvailable_doNotChangeState() = runGestureTest {
+ fun onPostScrollWithNothingAvailable_EdgeWithOverscroll_doNotChangeState() = runGestureTest {
+ val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeWithOverscroll)
val consumed =
nestedScroll.onPostScroll(
consumed = Offset.Zero,
@@ -214,6 +244,7 @@
@Test
fun onPostScrollWithSomethingAvailable_startSceneTransition() = runGestureTest {
+ val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeWithOverscroll)
val consumed =
nestedScroll.onPostScroll(
consumed = Offset.Zero,
@@ -227,14 +258,14 @@
assertThat(consumed).isEqualTo(offsetY10)
}
- private fun TestGestureScope.nestedScrollEvents(
+ private fun NestedScrollConnection.scroll(
available: Offset,
consumedByScroll: Offset = Offset.Zero,
) {
val consumedByPreScroll =
- nestedScroll.onPreScroll(available = available, source = NestedScrollSource.Drag)
+ onPreScroll(available = available, source = NestedScrollSource.Drag)
val consumed = consumedByPreScroll + consumedByScroll
- nestedScroll.onPostScroll(
+ onPostScroll(
consumed = consumed,
available = available - consumed,
source = NestedScrollSource.Drag
@@ -243,7 +274,8 @@
@Test
fun afterSceneTransitionIsStarted_interceptPreScrollEvents() = runGestureTest {
- nestedScrollEvents(available = offsetY10)
+ val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeWithOverscroll)
+ nestedScroll.scroll(available = offsetY10)
assertScene(currentScene = SceneA, isIdle = false)
val transition = transitionState as Transition
@@ -262,14 +294,15 @@
)
assertThat(transition.progress).isEqualTo(0.2f)
- nestedScrollEvents(available = offsetY10)
+ nestedScroll.scroll(available = offsetY10)
assertThat(transition.progress).isEqualTo(0.3f)
assertScene(currentScene = SceneA, isIdle = false)
}
@Test
fun onPreFling_velocityLowerThanThreshold_remainSameScene() = runGestureTest {
- nestedScrollEvents(available = offsetY10)
+ val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeWithOverscroll)
+ nestedScroll.scroll(available = offsetY10)
assertScene(currentScene = SceneA, isIdle = false)
nestedScroll.onPreFling(available = Velocity.Zero)
@@ -280,12 +313,28 @@
assertScene(currentScene = SceneA, isIdle = true)
}
- @Test
- fun onPreFling_velocityAtLeastThreshold_goToNextScene() = runGestureTest {
- nestedScrollEvents(available = offsetY10)
- assertScene(currentScene = SceneA, isIdle = false)
+ private suspend fun TestGestureScope.flingAfterScroll(
+ use: NestedScrollBehavior,
+ idleAfterScroll: Boolean,
+ ) {
+ val nestedScroll = nestedScrollConnection(nestedScrollBehavior = use)
+ nestedScroll.scroll(available = offsetY10)
+ assertScene(currentScene = SceneA, isIdle = idleAfterScroll)
nestedScroll.onPreFling(available = Velocity(0f, velocityThreshold))
+ }
+
+ @Test
+ fun flingAfterScroll_DuringTransitionBetweenScenes_doNothing() = runGestureTest {
+ flingAfterScroll(use = DuringTransitionBetweenScenes, idleAfterScroll = true)
+
+ assertScene(currentScene = SceneA, isIdle = true)
+ }
+
+ @Test
+ fun flingAfterScroll_EdgeNoOverscroll_goToNextScene() = runGestureTest {
+ flingAfterScroll(use = EdgeNoOverscroll, idleAfterScroll = false)
+
assertScene(currentScene = SceneC, isIdle = false)
// wait for the stop animation
@@ -294,16 +343,61 @@
}
@Test
- fun scrollStartedInScene_doOverscrollAnimation() = runGestureTest {
- // we started the scroll in the scene
- nestedScrollEvents(available = offsetY10, consumedByScroll = offsetY10)
+ fun flingAfterScroll_EdgeWithOverscroll_goToNextScene() = runGestureTest {
+ flingAfterScroll(use = EdgeWithOverscroll, idleAfterScroll = false)
- // now we can intercept the scroll events
- nestedScrollEvents(available = offsetY10)
- assertScene(currentScene = SceneA, isIdle = false)
+ assertScene(currentScene = SceneC, isIdle = false)
+
+ // wait for the stop animation
+ advanceUntilIdle()
+ assertScene(currentScene = SceneC, isIdle = true)
+ }
+
+ @Test
+ fun flingAfterScroll_Always_goToNextScene() = runGestureTest {
+ flingAfterScroll(use = Always, idleAfterScroll = false)
+
+ assertScene(currentScene = SceneC, isIdle = false)
+
+ // wait for the stop animation
+ advanceUntilIdle()
+ assertScene(currentScene = SceneC, isIdle = true)
+ }
+
+ /** we started the scroll in the scene, then fling with the velocityThreshold */
+ private suspend fun TestGestureScope.flingAfterScrollStartedInScene(
+ use: NestedScrollBehavior,
+ idleAfterScroll: Boolean,
+ ) {
+ val nestedScroll = nestedScrollConnection(nestedScrollBehavior = use)
+ // scroll consumed in child
+ nestedScroll.scroll(available = offsetY10, consumedByScroll = offsetY10)
+
+ // scroll offsetY10 is all available for parents
+ nestedScroll.scroll(available = offsetY10)
+ assertScene(currentScene = SceneA, isIdle = idleAfterScroll)
nestedScroll.onPreFling(available = Velocity(0f, velocityThreshold))
- // should start an overscroll animation (the gesture started in the scene)
+ }
+
+ @Test
+ fun flingAfterScrollStartedInScene_DuringTransitionBetweenScenes_doNothing() = runGestureTest {
+ flingAfterScrollStartedInScene(use = DuringTransitionBetweenScenes, idleAfterScroll = true)
+
+ assertScene(currentScene = SceneA, isIdle = true)
+ }
+
+ @Test
+ fun flingAfterScrollStartedInScene_EdgeNoOverscroll_doNothing() = runGestureTest {
+ flingAfterScrollStartedInScene(use = EdgeNoOverscroll, idleAfterScroll = true)
+
+ assertScene(currentScene = SceneA, isIdle = true)
+ }
+
+ @Test
+ fun flingAfterScrollStartedInScene_EdgeWithOverscroll_doOverscrollAnimation() = runGestureTest {
+ flingAfterScrollStartedInScene(use = EdgeWithOverscroll, idleAfterScroll = false)
+
assertScene(currentScene = SceneA, isIdle = false)
// wait for the stop animation
@@ -312,6 +406,17 @@
}
@Test
+ fun flingAfterScrollStartedInScene_Always_goToNextScene() = runGestureTest {
+ flingAfterScrollStartedInScene(use = Always, idleAfterScroll = false)
+
+ assertScene(currentScene = SceneC, isIdle = false)
+
+ // wait for the stop animation
+ advanceUntilIdle()
+ assertScene(currentScene = SceneC, isIdle = true)
+ }
+
+ @Test
fun beforeDraggableStart_drag_shouldBeIgnored() = runGestureTest {
draggable.onDelta(deltaInPixels10)
assertScene(currentScene = SceneA, isIdle = true)
@@ -324,12 +429,14 @@
@Test
fun beforeNestedScrollStart_stop_shouldBeIgnored() = runGestureTest {
+ val nestedScroll = nestedScrollConnection(nestedScrollBehavior = EdgeWithOverscroll)
nestedScroll.onPreFling(Velocity(0f, velocityThreshold))
assertScene(currentScene = SceneA, isIdle = true)
}
@Test
fun startNestedScrollWhileDragging() = runGestureTest {
+ val nestedScroll = nestedScrollConnection(nestedScrollBehavior = Always)
draggable.onDragStarted(Offset.Zero)
assertScene(currentScene = SceneA, isIdle = false)
val transition = transitionState as Transition
@@ -338,17 +445,17 @@
assertThat(transition.progress).isEqualTo(0.1f)
// now we can intercept the scroll events
- nestedScrollEvents(available = offsetY10)
+ nestedScroll.scroll(available = offsetY10)
assertThat(transition.progress).isEqualTo(0.2f)
// this should be ignored, we are scrolling now!
draggable.onDragStopped(velocityThreshold)
assertScene(currentScene = SceneA, isIdle = false)
- nestedScrollEvents(available = offsetY10)
+ nestedScroll.scroll(available = offsetY10)
assertThat(transition.progress).isEqualTo(0.3f)
- nestedScrollEvents(available = offsetY10)
+ nestedScroll.scroll(available = offsetY10)
assertThat(transition.progress).isEqualTo(0.4f)
nestedScroll.onPreFling(available = Velocity(0f, velocityThreshold))