1 /*
2 * Copyright 2020 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 androidx.compose.ui.input.nestedscroll
18
19 import androidx.compose.ui.ExperimentalComposeUiApi
20 import androidx.compose.ui.Modifier
21 import androidx.compose.ui.geometry.Offset
22 import androidx.compose.ui.internal.JvmDefaultWithCompatibility
23 import androidx.compose.ui.node.ModifierNodeElement
24 import androidx.compose.ui.platform.InspectorInfo
25 import androidx.compose.ui.unit.Velocity
26 import kotlinx.coroutines.CoroutineScope
27
28 /**
29 * Interface to connect to the nested scroll system.
30 *
31 * Pass this connection to the [nestedScroll] modifier to participate in the nested scroll hierarchy
32 * and to receive nested scroll events when they are dispatched by the scrolling child (scrolling
33 * child - the element that actually receives scrolling events and dispatches them via
34 * [NestedScrollDispatcher]).
35 *
36 * @see NestedScrollDispatcher to learn how to dispatch nested scroll events to become a scrolling
37 * child
38 * @see nestedScroll to attach this connection to the nested scroll system
39 */
40 @JvmDefaultWithCompatibility
41 interface NestedScrollConnection {
42
43 /**
44 * Pre scroll event chain. Called by children to allow parents to consume a portion of a drag
45 * event beforehand
46 *
47 * @param available the delta available to consume for pre scroll
48 * @param source the source of the scroll event
49 * @return the amount this connection consumed
50 * @see NestedScrollSource
51 */
onPreScrollnull52 fun onPreScroll(available: Offset, source: NestedScrollSource): Offset = Offset.Zero
53
54 /**
55 * Post scroll event pass. This pass occurs when the dispatching (scrolling) descendant made
56 * their consumption and notifies ancestors with what's left for them to consume.
57 *
58 * @param consumed the amount that was consumed by all nested scroll nodes below the hierarchy
59 * @param available the amount of delta available for this connection to consume
60 * @param source source of the scroll
61 * @return the amount that was consumed by this connection
62 * @see NestedScrollSource
63 */
64 fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset =
65 Offset.Zero
66
67 /**
68 * Pre fling event chain. Called by children when they are about to perform fling to allow
69 * parents to intercept and consume part of the initial velocity
70 *
71 * @param available the velocity which is available to pre consume and with which the child is
72 * about to fling
73 * @return the amount this connection wants to consume and take from the child
74 */
75 suspend fun onPreFling(available: Velocity): Velocity = Velocity.Zero
76
77 /**
78 * Post fling event chain. Called by the child when it is finished flinging (and sending
79 * [onPreScroll] & [onPostScroll] events)
80 *
81 * @param consumed the amount of velocity consumed by the child
82 * @param available the amount of velocity left for a parent to fling after the child (if
83 * desired)
84 * @return the amount of velocity consumed by the fling operation in this connection
85 */
86 suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
87 return Velocity.Zero
88 }
89 }
90
91 /**
92 * Nested scroll events dispatcher to notify the nested scroll system about the scrolling events
93 * that are happening on the element.
94 *
95 * If the element/modifier itself is able to receive scroll events (from the touch, fling, mouse,
96 * etc) and it would like to respect nested scrolling by notifying elements above, it should
97 * properly dispatch nested scroll events when being scrolled
98 *
99 * It is important to dispatch these events at the right time, provide valid information to the
100 * parents and react to the feedback received from them in order to provide good user experience
101 * with other nested scrolling nodes.
102 *
103 * @see nestedScroll for the reference of the nested scroll process and more details
104 * @see NestedScrollConnection to connect to the nested scroll system
105 */
106 class NestedScrollDispatcher {
107
108 internal var nestedScrollNode: NestedScrollNode? = null
109
110 // caches last known parent for fling clean up use.
111 internal var lastKnownParentNode: NestedScrollNode? = null
112
113 // lambda to calculate the most outer nested scroll scope for this dispatcher on demand
<lambda>null114 internal var calculateNestedScrollScope: () -> CoroutineScope? = { scope }
115
116 // the original nested scroll scope for this dispatcher (immediate scope it was created in)
117 internal var scope: CoroutineScope? = null
118
119 /**
120 * Get the outer coroutine scope to dispatch nested fling on.
121 *
122 * There might be situations when then component that is dispatching preFling or postFling to
123 * parent can be disposed together with its scope, so it's recommended to use launch nested
124 * fling dispatch using this scope to prevent abrupt scrolling user experience.
125 *
126 * **Note:** this scope is retrieved from the parent nestedScroll participants, unless the node
127 * knows its parent (which is usually after first composition commits), this will throw
128 * [IllegalStateException].
129 *
130 * @throws IllegalStateException when this field is accessed before the [nestedScroll] modifier
131 * with this [NestedScrollDispatcher] provided knows its nested scroll parent. Should be safe
132 * to access after the initial composition commits.
133 */
134 val coroutineScope: CoroutineScope
135 /**
136 * @throws IllegalStateException when this field is accessed before the [nestedScroll]
137 * modifier with this [NestedScrollDispatcher] provided knows its nested scroll parent.
138 * Should be safe to access after the initial composition commits.
139 */
140 get() =
141 calculateNestedScrollScope.invoke()
142 ?: throw IllegalStateException(
143 "in order to access nested coroutine scope you need to attach dispatcher to the " +
144 "`Modifier.nestedScroll` first."
145 )
146
147 /**
148 * Parent to be set when attached to nested scrolling chain. `null` is valid and means there no
149 * nested scrolling parent above. The last known attached parent might be used in case this
150 * dispatcher is not attached to any node, that is [nestedScrollNode?.parentNestedScrollNode] is
151 * null.
152 */
153 internal val parent: NestedScrollConnection?
154 get() = nestedScrollNode?.parentNestedScrollNode
155
156 /**
157 * Dispatch pre scroll pass. This triggers [NestedScrollConnection.onPreScroll] on all the
158 * ancestors giving them possibility to pre-consume delta if they desire so.
159 *
160 * @param available the delta arrived from a scroll event
161 * @param source the source of the scroll event
162 * @return total delta that is pre-consumed by all ancestors in the chain. This delta is
163 * unavailable for this node to consume, so it should adjust the consumption accordingly
164 */
dispatchPreScrollnull165 fun dispatchPreScroll(available: Offset, source: NestedScrollSource): Offset {
166 return parent?.onPreScroll(available, source) ?: Offset.Zero
167 }
168
169 /**
170 * Dispatch nested post-scrolling pass. This triggers [NestedScrollConnection.onPostScroll] on
171 * all the ancestors giving them possibility to react of the scroll deltas that are left after
172 * the dispatching node itself and other [NestedScrollConnection]s below consumed the desired
173 * amount.
174 *
175 * @param consumed the amount that this node consumed already
176 * @param available the amount of delta left for ancestors
177 * @param source source of the scroll
178 * @return the amount of scroll that was consumed by all ancestors
179 */
dispatchPostScrollnull180 fun dispatchPostScroll(
181 consumed: Offset,
182 available: Offset,
183 source: NestedScrollSource
184 ): Offset {
185 return parent?.onPostScroll(consumed, available, source) ?: Offset.Zero
186 }
187
188 /**
189 * Dispatch pre fling pass and suspend until all the interested participants performed velocity
190 * pre consumption. This triggers [NestedScrollConnection.onPreFling] on all the ancestors
191 * giving them a possibility to react on the fling that is about to happen and consume part of
192 * the velocity.
193 *
194 * @param available velocity from the scroll evens that this node is about to fling with
195 * @return total velocity that is pre-consumed by all ancestors in the chain. This velocity is
196 * unavailable for this node to consume, so it should adjust the consumption accordingly
197 */
dispatchPreFlingnull198 suspend fun dispatchPreFling(available: Velocity): Velocity {
199 return parent?.onPreFling(available) ?: Velocity.Zero
200 }
201
202 /**
203 * Dispatch post fling pass and suspend until all the interested participants performed velocity
204 * process. This triggers [NestedScrollConnection.onPostFling] on all the ancestors, giving them
205 * possibility to react of the velocity that is left after the dispatching node itself flung
206 * with the desired amount.
207 *
208 * @param consumed velocity already consumed by this node
209 * @param available velocity that is left for ancestors to consume
210 * @return velocity that has been consumed by all the ancestors
211 */
212 @OptIn(ExperimentalComposeUiApi::class)
dispatchPostFlingnull213 suspend fun dispatchPostFling(consumed: Velocity, available: Velocity): Velocity {
214 // lastKnownParentNode can be used to send clean up signals.
215 // If this dispatcher's regular parent is not present it means either it never attached or
216 // it was detached. If it was detached we have information about its last known parent so
217 // we use it to send the post fling signal. We don't need to do the same for the other
218 // methods because the problem with parity in this API comes from a node that detaches
219 // during a fling. By the time a node detaches it already sent the onPreFling event and
220 // consumers of Nested Scroll might expect an onPostFling event to close the cycle.
221 return if (parent == null) {
222 lastKnownParentNode?.onPostFling(consumed, available) ?: Velocity.Zero
223 } else {
224 parent?.onPostFling(consumed, available) ?: Velocity.Zero
225 }
226 }
227 }
228
229 /** Possible sources of scroll events in the [NestedScrollConnection] */
230 @kotlin.jvm.JvmInline
231 value class NestedScrollSource internal constructor(@Suppress("unused") private val value: Int) {
toStringnull232 override fun toString(): String {
233 @Suppress("DEPRECATION")
234 return when (this) {
235 UserInput -> "UserInput"
236 SideEffect -> "SideEffect"
237 Relocate -> "Relocate"
238 else -> "Invalid"
239 }
240 }
241
242 companion object {
243
244 /**
245 * Represents any source of scroll events originated from a user interaction: mouse, touch,
246 * key events.
247 */
248 val UserInput: NestedScrollSource = NestedScrollSource(1)
249
250 /**
251 * Represents any other source of scroll events that are not a direct user input. (e.g
252 * animations, fling)
253 */
254 val SideEffect: NestedScrollSource = NestedScrollSource(2)
255
256 /** Dragging via mouse/touch/etc events. */
257 @Deprecated(
258 "This has been replaced by UserInput.",
259 replaceWith =
260 ReplaceWith(
261 "NestedScrollSource.UserInput",
262 "import androidx.compose.ui.input.nestedscroll." +
263 "NestedScrollSource.Companion.UserInput"
264 )
265 )
266 val Drag: NestedScrollSource = UserInput
267
268 /** Flinging after the drag has ended with velocity. */
269 @Deprecated(
270 "This has been replaced by SideEffect.",
271 replaceWith =
272 ReplaceWith(
273 "NestedScrollSource.SideEffect",
274 "import androidx.compose.ui.input.nestedscroll." +
275 "NestedScrollSource.Companion.SideEffect"
276 )
277 )
278 val Fling: NestedScrollSource = SideEffect
279
280 /** Relocating when a component asks parents to scroll to bring it into view. */
281 @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET")
282 @Deprecated("Do not use. Will be removed in the future.")
283 val Relocate: NestedScrollSource = NestedScrollSource(3)
284
285 /** Scrolling via mouse wheel. */
286 @Deprecated(
287 "This has been replaced by UserInput.",
288 replaceWith =
289 ReplaceWith(
290 "NestedScrollSource.UserInput",
291 "import androidx.compose.ui.input.nestedscroll." +
292 "NestedScrollSource.Companion.UserInput"
293 )
294 )
295 val Wheel: NestedScrollSource = UserInput
296 }
297 }
298
299 /**
300 * Modify element to make it participate in the nested scrolling hierarchy.
301 *
302 * There are two ways to participate in the nested scroll: as a scrolling child by dispatching
303 * scrolling events via [NestedScrollDispatcher] to the nested scroll chain; and as a member of
304 * nested scroll chain by providing [NestedScrollConnection], which will be called when another
305 * nested scrolling child below dispatches scrolling events.
306 *
307 * It's mandatory to participate as a [NestedScrollConnection] in the chain, but dispatching
308 * scrolling events is optional since there are cases where an element wants to participate in
309 * nested scrolling without being directly scrollable.
310 *
311 * Here's the collapsing toolbar example that participates in a chain, but doesn't dispatch:
312 *
313 * @sample androidx.compose.ui.samples.NestedScrollConnectionSample
314 *
315 * On the other side, dispatch via [NestedScrollDispatcher] is optional. It's needed if a component
316 * is able to receive and react to the drag/fling events and you want this components to be able to
317 * notify parents when scroll occurs, resulting in better overall coordination.
318 *
319 * Here's the example of the component that is draggable and dispatches nested scroll to participate
320 * in the nested scroll chain:
321 *
322 * @sample androidx.compose.ui.samples.NestedScrollDispatcherSample
323 *
324 * **Note:** It is recommended to reuse [NestedScrollConnection] and [NestedScrollDispatcher]
325 * objects between recompositions since different object will cause nested scroll graph to be
326 * recalculated unnecessary.
327 *
328 * There are 4 main phases in nested scrolling system:
329 * 1. Pre-scroll. This callback is triggered when the descendant is about to perform a scroll
330 * operation and gives parent an opportunity to consume part of child's delta beforehand. This
331 * pass should happen every time scrollable components receives delta and dispatches it via
332 * [NestedScrollDispatcher]. Dispatching child should take into account how much all ancestors
333 * above the hierarchy consumed and adjust the consumption accordingly.
334 * 2. Post-scroll. This callback is triggered when the descendant consumed the delta already (after
335 * taking into account what parents pre-consumed in 1.) and wants to notify the ancestors with
336 * the amount of delta unconsumed. This pass should happen every time scrollable components
337 * receives delta and dispatches it via [NestedScrollDispatcher]. Any parent that receives
338 * [NestedScrollConnection.onPostScroll] should consume no more than `left` and return the amount
339 * consumed.
340 * 3. Pre-fling. Pass that happens when the scrolling descendant stopped dragging and about to fling
341 * with the some velocity. This callback allows ancestors to consume part of the velocity. This
342 * pass should happen before the fling itself happens. Similar to pre-scroll, parent can consume
343 * part of the velocity and nodes below (including the dispatching child) should adjust their
344 * logic to accommodate only the velocity left.
345 * 4. Post-fling. Pass that happens after the scrolling descendant stopped flinging and wants to
346 * notify ancestors about that fact, providing velocity left to consume as a part of this. This
347 * pass should happen after the fling itself happens on the scrolling child. Ancestors of the
348 * dispatching node will have opportunity to fling themselves with the `velocityLeft` provided.
349 * Parent must call `notifySelfFinish` callback in order to continue the propagation of the
350 * velocity that is left to ancestors above.
351 *
352 * [androidx.compose.foundation.lazy.LazyColumn], [androidx.compose.foundation.verticalScroll] and
353 * [androidx.compose.foundation.gestures.scrollable] have build in support for nested scrolling,
354 * however, it's desirable to be able to react and influence their scroll via nested scroll system.
355 *
356 * **Note:** The nested scroll system is orientation independent. This mean it is based off the
357 * screen direction (x and y coordinates) rather than being locked to a specific orientation.
358 *
359 * @param connection connection to the nested scroll system to participate in the event chaining,
360 * receiving events when scrollable descendant is being scrolled.
361 * @param dispatcher object to be attached to the nested scroll system on which `dispatch*` methods
362 * can be called to notify ancestors within nested scroll system about scrolling happening
363 */
nestedScrollnull364 fun Modifier.nestedScroll(
365 connection: NestedScrollConnection,
366 dispatcher: NestedScrollDispatcher? = null
367 ): Modifier = this then NestedScrollElement(connection, dispatcher)
368
369 private class NestedScrollElement(
370 val connection: NestedScrollConnection,
371 val dispatcher: NestedScrollDispatcher?
372 ) : ModifierNodeElement<NestedScrollNode>() {
373 override fun create(): NestedScrollNode {
374 return NestedScrollNode(connection, dispatcher)
375 }
376
377 override fun update(node: NestedScrollNode) {
378 node.updateNode(connection, dispatcher)
379 }
380
381 override fun hashCode(): Int {
382 var result = connection.hashCode()
383 result = 31 * result + dispatcher.hashCode()
384 return result
385 }
386
387 override fun equals(other: Any?): Boolean {
388 if (other !is NestedScrollElement) return false
389 if (other.connection != connection) return false
390 if (other.dispatcher != dispatcher) return false
391 return true
392 }
393
394 override fun InspectorInfo.inspectableProperties() {
395 name = "nestedScroll"
396 properties["connection"] = connection
397 properties["dispatcher"] = dispatcher
398 }
399 }
400