diff options
| author | 2025-01-06 04:59:51 -0800 | |
|---|---|---|
| committer | 2025-01-06 04:59:51 -0800 | |
| commit | 4c48104154c87d9b25c32a65dc78141e24b39b07 (patch) | |
| tree | 3092f9080e233273d5a016da0691e7841a6827af | |
| parent | 66e4d6028d51367d34a2413c843de96a8b842ecf (diff) | |
| parent | 17bf8a2680b8b9c2f1c62816fc33eab692af7a60 (diff) | |
Merge "Introduce Modifier.nestedScrollController()" into main
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() + } +} |