summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author Treehugger Robot <android-test-infra-autosubmit@system.gserviceaccount.com> 2025-01-06 04:59:51 -0800
committer Android (Google) Code Review <android-gerrit@google.com> 2025-01-06 04:59:51 -0800
commit4c48104154c87d9b25c32a65dc78141e24b39b07 (patch)
tree3092f9080e233273d5a016da0691e7841a6827af
parent66e4d6028d51367d34a2413c843de96a8b842ecf (diff)
parent17bf8a2680b8b9c2f1c62816fc33eab692af7a60 (diff)
Merge "Introduce Modifier.nestedScrollController()" into main
-rw-r--r--packages/SystemUI/compose/core/src/com/android/compose/gesture/NestedScrollController.kt191
-rw-r--r--packages/SystemUI/compose/core/tests/src/com/android/compose/gesture/NestedScrollControllerTest.kt106
2 files changed, 297 insertions, 0 deletions
diff --git a/packages/SystemUI/compose/core/src/com/android/compose/gesture/NestedScrollController.kt b/packages/SystemUI/compose/core/src/com/android/compose/gesture/NestedScrollController.kt
new file mode 100644
index 000000000000..2530a4f240e3
--- /dev/null
+++ b/packages/SystemUI/compose/core/src/com/android/compose/gesture/NestedScrollController.kt
@@ -0,0 +1,191 @@
+/*
+ * Copyright (C) 2024 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.gesture
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.input.nestedscroll.nestedScrollModifierNode
+import androidx.compose.ui.node.DelegatingNode
+import androidx.compose.ui.node.ModifierNodeElement
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.Velocity
+
+/**
+ * Update [state] and disallow outer scroll after a child node consumed a non-zero scroll amount
+ * before reaching its [bounds], so that the child is overscrolled instead of letting the outer
+ * scrollable(s) consume the extra scroll.
+ *
+ * Example:
+ * ```
+ * val nestedScrollControlState = remember { NestedScrollControlState() }
+ * Column(
+ * Modifier
+ * // Note: Any scrollable/draggable parent should use nestedScrollControlState to
+ * // enable/disable themselves.
+ * .verticalScroll(
+ * rememberScrollState(),
+ * enabled = nestedScrollControlState.isOuterScrollAllowed,
+ * )
+ * ) {
+ * Column(
+ * Modifier
+ * .nestedScrollController(nestedScrollControlState)
+ * .verticalScroll(rememberScrollState())
+ * ) { ...}
+ * }
+ * ```
+ */
+fun Modifier.nestedScrollController(
+ state: NestedScrollControlState,
+ bounds: NestedScrollableBound = NestedScrollableBound.Any,
+): Modifier {
+ return this.then(NestedScrollControllerElement(state, bounds))
+}
+
+/**
+ * A state that should be used by outer scrollables to disable themselves so that nested scrollables
+ * will overscroll when reaching their bounds.
+ *
+ * @see nestedScrollController
+ */
+class NestedScrollControlState {
+ var isOuterScrollAllowed by mutableStateOf(true)
+ internal set
+}
+
+/**
+ * Specifies when to disable outer scroll after reaching the bounds of a nested scrollable.
+ *
+ * @see nestedScrollController
+ */
+enum class NestedScrollableBound {
+ /** Disable after reaching any of the scrollable bounds. */
+ Any,
+
+ /** Disable after reaching the top (left) bound when scrolling vertically (horizontally). */
+ TopLeft,
+
+ /** Disable after reaching the bottom (right) bound when scrolling vertically (horizontally). */
+ BottomRight;
+
+ companion object {
+ /**
+ * Disable after reaching the left (right) bound when scrolling horizontally in a LTR (RTL)
+ * layout.
+ */
+ val Start: NestedScrollableBound
+ @Composable
+ get() =
+ when (LocalLayoutDirection.current) {
+ LayoutDirection.Ltr -> TopLeft
+ LayoutDirection.Rtl -> BottomRight
+ }
+
+ /**
+ * Disable after reaching the right (left) bound when scrolling horizontally in a LTR (RTL)
+ * layout.
+ */
+ val End: NestedScrollableBound
+ @Composable
+ get() =
+ when (LocalLayoutDirection.current) {
+ LayoutDirection.Ltr -> BottomRight
+ LayoutDirection.Rtl -> TopLeft
+ }
+ }
+}
+
+private data class NestedScrollControllerElement(
+ private val state: NestedScrollControlState,
+ private val bounds: NestedScrollableBound,
+) : ModifierNodeElement<NestedScrollControllerNode>() {
+ override fun create(): NestedScrollControllerNode {
+ return NestedScrollControllerNode(state, bounds)
+ }
+
+ override fun update(node: NestedScrollControllerNode) {
+ node.update(state, bounds)
+ }
+}
+
+private class NestedScrollControllerNode(
+ private var state: NestedScrollControlState,
+ private var bounds: NestedScrollableBound,
+) : DelegatingNode(), NestedScrollConnection {
+ private var childrenConsumedAnyScroll = false
+
+ init {
+ delegate(nestedScrollModifierNode(this, dispatcher = null))
+ }
+
+ override fun onDetach() {
+ state.isOuterScrollAllowed = true
+ }
+
+ fun update(controller: NestedScrollControlState, bounds: NestedScrollableBound) {
+ if (controller != this.state) {
+ controller.isOuterScrollAllowed = this.state.isOuterScrollAllowed
+ this.state.isOuterScrollAllowed = true
+ this.state = controller
+ }
+
+ this.bounds = bounds
+ }
+
+ override fun onPostScroll(
+ consumed: Offset,
+ available: Offset,
+ source: NestedScrollSource,
+ ): Offset {
+ if (hasConsumedScrollInBounds(consumed.x) || hasConsumedScrollInBounds(consumed.y)) {
+ childrenConsumedAnyScroll = true
+ }
+
+ if (!childrenConsumedAnyScroll) {
+ state.isOuterScrollAllowed = true
+ } else {
+ state.isOuterScrollAllowed = false
+ }
+
+ return Offset.Zero
+ }
+
+ override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
+ childrenConsumedAnyScroll = false
+ state.isOuterScrollAllowed = true
+ return super.onPostFling(consumed, available)
+ }
+
+ private fun hasConsumedScrollInBounds(consumed: Float): Boolean {
+ return when {
+ consumed < 0f ->
+ bounds == NestedScrollableBound.Any || bounds == NestedScrollableBound.BottomRight
+
+ consumed > 0f ->
+ bounds == NestedScrollableBound.Any || bounds == NestedScrollableBound.TopLeft
+
+ else -> false
+ }
+ }
+}
diff --git a/packages/SystemUI/compose/core/tests/src/com/android/compose/gesture/NestedScrollControllerTest.kt b/packages/SystemUI/compose/core/tests/src/com/android/compose/gesture/NestedScrollControllerTest.kt
new file mode 100644
index 000000000000..424af3395913
--- /dev/null
+++ b/packages/SystemUI/compose/core/tests/src/com/android/compose/gesture/NestedScrollControllerTest.kt
@@ -0,0 +1,106 @@
+/*
+ * 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.gesture
+
+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.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+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.modifiers.thenIf
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class NestedScrollControllerTest {
+ @get:Rule val rule = createComposeRule()
+
+ @Test
+ fun nestedScrollController() {
+ val state = NestedScrollControlState()
+ var nestedScrollConsumesDelta = false
+ rule.setContent {
+ Box(
+ Modifier.fillMaxSize()
+ .nestedScrollController(state)
+ .scrollable(
+ rememberScrollableState { if (nestedScrollConsumesDelta) it else 0f },
+ Orientation.Vertical,
+ )
+ )
+ }
+
+ // If the nested child does not consume scrolls, then outer scrolling is allowed.
+ assertThat(state.isOuterScrollAllowed).isTrue()
+ nestedScrollConsumesDelta = false
+ rule.onRoot().performTouchInput {
+ down(topLeft)
+ moveBy(Offset(0f, bottom))
+ }
+ assertThat(state.isOuterScrollAllowed).isTrue()
+ rule.onRoot().performTouchInput { up() }
+
+ // If the nested child consumes scrolls, then outer scrolling is disabled.
+ nestedScrollConsumesDelta = true
+ rule.onRoot().performTouchInput {
+ down(topLeft)
+ moveBy(Offset(0f, bottom))
+ }
+ assertThat(state.isOuterScrollAllowed).isFalse()
+
+ // Outer scrolling is enabled again when stopping the scroll.
+ rule.onRoot().performTouchInput { up() }
+ assertThat(state.isOuterScrollAllowed).isTrue()
+ }
+
+ @Test
+ fun nestedScrollController_detached() {
+ val state = NestedScrollControlState()
+ var composeNestedScroll by mutableStateOf(true)
+ rule.setContent {
+ val scrollableState = rememberScrollableState { it }
+ Box(
+ Modifier.fillMaxSize().thenIf(composeNestedScroll) {
+ Modifier.nestedScrollController(state)
+ .scrollable(scrollableState, Orientation.Vertical)
+ }
+ )
+ }
+ // The nested child consumes scrolls, so outer scrolling is disabled.
+ rule.onRoot().performTouchInput {
+ down(topLeft)
+ moveBy(Offset(0f, bottom))
+ }
+ assertThat(state.isOuterScrollAllowed).isFalse()
+
+ // Outer scrolling is enabled again when removing the controller from composition.
+ composeNestedScroll = false
+ rule.waitForIdle()
+ assertThat(state.isOuterScrollAllowed).isTrue()
+ }
+}