Merge "Introduce disableSwipesWhenScrolling()" into main
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 c704a3e..de428a7 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
@@ -35,6 +35,7 @@
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
+import com.android.compose.gesture.NestedScrollableBound
import com.android.compose.gesture.effect.ContentOverscrollEffect
/**
@@ -238,6 +239,18 @@
fun Modifier.noResizeDuringTransitions(): Modifier
/**
+ * Temporarily disable this content swipe actions when any scrollable below this modifier has
+ * consumed any amount of scroll delta, until the scroll gesture is finished.
+ *
+ * This can for instance be used to ensure that a scrollable list is overscrolled once it
+ * reached its bounds instead of directly starting a scene transition from the same scroll
+ * gesture.
+ */
+ fun Modifier.disableSwipesWhenScrolling(
+ bounds: NestedScrollableBound = NestedScrollableBound.Any
+ ): Modifier
+
+ /**
* A [NestedSceneTransitionLayout] will share its elements with its ancestor STLs therefore
* enabling sharedElement transitions between them.
*/
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 c5b3df2..3f6bce7 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
@@ -54,7 +54,7 @@
/** Whether swipe should be enabled in the given [orientation]. */
internal fun Content.shouldEnableSwipes(orientation: Orientation): Boolean {
- if (userActions.isEmpty()) {
+ if (userActions.isEmpty() || !areSwipesAllowed()) {
return false
}
@@ -69,6 +69,10 @@
* @return The best matching [UserActionResult], or `null` if no match is found.
*/
internal fun Content.findActionResultBestMatch(swipe: Swipe.Resolved): UserActionResult? {
+ if (!areSwipesAllowed()) {
+ return null
+ }
+
var bestPoints = Int.MIN_VALUE
var bestMatch: UserActionResult? = null
userActions.forEach { (actionSwipe, actionResult) ->
diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/Content.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/Content.kt
index 4c15f7a..59b4a09 100644
--- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/Content.kt
+++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/Content.kt
@@ -56,7 +56,10 @@
import com.android.compose.animation.scene.effect.VisualEffect
import com.android.compose.animation.scene.element
import com.android.compose.animation.scene.modifiers.noResizeDuringTransitions
+import com.android.compose.gesture.NestedScrollControlState
+import com.android.compose.gesture.NestedScrollableBound
import com.android.compose.gesture.effect.OffsetOverscrollEffect
+import com.android.compose.gesture.nestedScrollController
import com.android.compose.modifiers.thenIf
import com.android.compose.ui.graphics.ContainerState
import com.android.compose.ui.graphics.container
@@ -70,7 +73,8 @@
actions: Map<UserAction.Resolved, UserActionResult>,
zIndex: Float,
) {
- internal val scope = ContentScopeImpl(layoutImpl, content = this)
+ private val nestedScrollControlState = NestedScrollControlState()
+ internal val scope = ContentScopeImpl(layoutImpl, content = this, nestedScrollControlState)
val containerState = ContainerState()
var content by mutableStateOf(content)
@@ -101,11 +105,14 @@
scope.content()
}
}
+
+ fun areSwipesAllowed(): Boolean = nestedScrollControlState.isOuterScrollAllowed
}
internal class ContentScopeImpl(
private val layoutImpl: SceneTransitionLayoutImpl,
private val content: Content,
+ private val nestedScrollControlState: NestedScrollControlState,
) : ContentScope, ElementStateScope by layoutImpl.elementStateScope {
override val contentKey: ContentKey
get() = content.key
@@ -176,6 +183,10 @@
return noResizeDuringTransitions(layoutState = layoutImpl.state)
}
+ override fun Modifier.disableSwipesWhenScrolling(bounds: NestedScrollableBound): Modifier {
+ return nestedScrollController(nestedScrollControlState, bounds)
+ }
+
@Composable
override fun NestedSceneTransitionLayout(
state: SceneTransitionLayoutState,
diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ContentTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ContentTest.kt
new file mode 100644
index 0000000..06a9735
--- /dev/null
+++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ContentTest.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) 2025 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.foundation.gestures.rememberScrollableState
+import androidx.compose.foundation.gestures.scrollable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onRoot
+import androidx.compose.ui.test.performTouchInput
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.compose.animation.scene.TestScenes.SceneA
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ContentTest {
+ @get:Rule val rule = createComposeRule()
+
+ @Test
+ fun disableSwipesWhenScrolling() {
+ lateinit var layoutImpl: SceneTransitionLayoutImpl
+ rule.setContent {
+ SceneTransitionLayoutForTesting(
+ remember { MutableSceneTransitionLayoutState(SceneA) },
+ onLayoutImpl = { layoutImpl = it },
+ ) {
+ scene(SceneA) {
+ Box(
+ Modifier.fillMaxSize()
+ .disableSwipesWhenScrolling()
+ .scrollable(rememberScrollableState { it }, Orientation.Vertical)
+ )
+ }
+ }
+ }
+
+ val content = layoutImpl.content(SceneA)
+ assertThat(content.areSwipesAllowed()).isTrue()
+ rule.onRoot().performTouchInput {
+ down(topLeft)
+ moveBy(bottomLeft)
+ }
+
+ assertThat(content.areSwipesAllowed()).isFalse()
+ rule.onRoot().performTouchInput { up() }
+ assertThat(content.areSwipesAllowed()).isTrue()
+ }
+}