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