1 /*
2 * Copyright (C) 2024 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package com.android.compose.gesture
18
19 import androidx.compose.runtime.Composable
20 import androidx.compose.runtime.getValue
21 import androidx.compose.runtime.mutableStateOf
22 import androidx.compose.runtime.setValue
23 import androidx.compose.ui.Modifier
24 import androidx.compose.ui.geometry.Offset
25 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
26 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
27 import androidx.compose.ui.input.nestedscroll.nestedScrollModifierNode
28 import androidx.compose.ui.node.DelegatingNode
29 import androidx.compose.ui.node.ModifierNodeElement
30 import androidx.compose.ui.platform.LocalLayoutDirection
31 import androidx.compose.ui.unit.LayoutDirection
32 import androidx.compose.ui.unit.Velocity
33
34 /**
35 * Update [state] and disallow outer scroll after a child node consumed a non-zero scroll amount
36 * before reaching its [bounds], so that the child is overscrolled instead of letting the outer
37 * scrollable(s) consume the extra scroll.
38 *
39 * Example:
40 * ```
41 * val nestedScrollControlState = remember { NestedScrollControlState() }
42 * Column(
43 * Modifier
44 * // Note: Any scrollable/draggable parent should use nestedScrollControlState to
45 * // enable/disable themselves.
46 * .verticalScroll(
47 * rememberScrollState(),
48 * enabled = nestedScrollControlState.isOuterScrollAllowed,
49 * )
50 * ) {
51 * Column(
52 * Modifier
53 * .nestedScrollController(nestedScrollControlState)
54 * .verticalScroll(rememberScrollState())
55 * ) { ...}
56 * }
57 * ```
58 */
nestedScrollControllernull59 fun Modifier.nestedScrollController(
60 state: NestedScrollControlState,
61 bounds: NestedScrollableBound = NestedScrollableBound.Any,
62 ): Modifier {
63 return this.then(NestedScrollControllerElement(state, bounds))
64 }
65
66 /**
67 * A state that should be used by outer scrollables to disable themselves so that nested scrollables
68 * will overscroll when reaching their bounds.
69 *
70 * @see nestedScrollController
71 */
72 class NestedScrollControlState {
73 var isOuterScrollAllowed by mutableStateOf(true)
74 internal set
75 }
76
77 /**
78 * Specifies when to disable outer scroll after reaching the bounds of a nested scrollable.
79 *
80 * @see nestedScrollController
81 */
82 enum class NestedScrollableBound {
83 /** Disable after reaching any of the scrollable bounds. */
84 Any,
85
86 /** Disable after reaching the top (left) bound when scrolling vertically (horizontally). */
87 TopLeft,
88
89 /** Disable after reaching the bottom (right) bound when scrolling vertically (horizontally). */
90 BottomRight;
91
92 companion object {
93 /**
94 * Disable after reaching the left (right) bound when scrolling horizontally in a LTR (RTL)
95 * layout.
96 */
97 val Start: NestedScrollableBound
98 @Composable
99 get() =
100 when (LocalLayoutDirection.current) {
101 LayoutDirection.Ltr -> TopLeft
102 LayoutDirection.Rtl -> BottomRight
103 }
104
105 /**
106 * Disable after reaching the right (left) bound when scrolling horizontally in a LTR (RTL)
107 * layout.
108 */
109 val End: NestedScrollableBound
110 @Composable
111 get() =
112 when (LocalLayoutDirection.current) {
113 LayoutDirection.Ltr -> BottomRight
114 LayoutDirection.Rtl -> TopLeft
115 }
116 }
117 }
118
119 private data class NestedScrollControllerElement(
120 private val state: NestedScrollControlState,
121 private val bounds: NestedScrollableBound,
122 ) : ModifierNodeElement<NestedScrollControllerNode>() {
createnull123 override fun create(): NestedScrollControllerNode {
124 return NestedScrollControllerNode(state, bounds)
125 }
126
updatenull127 override fun update(node: NestedScrollControllerNode) {
128 node.update(state, bounds)
129 }
130 }
131
132 private class NestedScrollControllerNode(
133 private var state: NestedScrollControlState,
134 private var bounds: NestedScrollableBound,
135 ) : DelegatingNode(), NestedScrollConnection {
136 private var childrenConsumedAnyScroll = false
137 private var availableOnPreScroll = Offset.Zero
138
139 init {
140 delegate(nestedScrollModifierNode(this, dispatcher = null))
141 }
142
onDetachnull143 override fun onDetach() {
144 state.isOuterScrollAllowed = true
145 }
146
updatenull147 fun update(controller: NestedScrollControlState, bounds: NestedScrollableBound) {
148 if (controller != this.state) {
149 controller.isOuterScrollAllowed = this.state.isOuterScrollAllowed
150 this.state.isOuterScrollAllowed = true
151 this.state = controller
152 }
153
154 this.bounds = bounds
155 }
156
onPreScrollnull157 override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
158 availableOnPreScroll = available
159 return Offset.Zero
160 }
161
onPostScrollnull162 override fun onPostScroll(
163 consumed: Offset,
164 available: Offset,
165 source: NestedScrollSource,
166 ): Offset {
167 val consumedIncludingPreScroll = availableOnPreScroll - available
168 if (
169 hasConsumedScrollInBounds(consumedIncludingPreScroll.x) ||
170 hasConsumedScrollInBounds(consumedIncludingPreScroll.y)
171 ) {
172 childrenConsumedAnyScroll = true
173 }
174
175 if (!childrenConsumedAnyScroll) {
176 state.isOuterScrollAllowed = true
177 } else {
178 state.isOuterScrollAllowed = false
179 }
180
181 return Offset.Zero
182 }
183
onPostFlingnull184 override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
185 childrenConsumedAnyScroll = false
186 state.isOuterScrollAllowed = true
187 return super.onPostFling(consumed, available)
188 }
189
hasConsumedScrollInBoundsnull190 private fun hasConsumedScrollInBounds(consumed: Float): Boolean {
191 return when {
192 consumed < 0f ->
193 bounds == NestedScrollableBound.Any || bounds == NestedScrollableBound.BottomRight
194
195 consumed > 0f ->
196 bounds == NestedScrollableBound.Any || bounds == NestedScrollableBound.TopLeft
197
198 else -> false
199 }
200 }
201 }
202