• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2023 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.nestedscroll
18 
19 import androidx.compose.foundation.gestures.FlingBehavior
20 import androidx.compose.foundation.gestures.Orientation
21 import androidx.compose.foundation.gestures.ScrollScope
22 import androidx.compose.ui.geometry.Offset
23 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
24 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
25 import androidx.compose.ui.unit.Velocity
26 import com.android.compose.ui.util.SpaceVectorConverter
27 import kotlinx.coroutines.Deferred
28 import kotlinx.coroutines.async
29 import kotlinx.coroutines.coroutineScope
30 
31 /**
32  * The [ScrollController] provides control over the scroll gesture. It allows you to:
33  * - Scroll the content by a given pixel amount.
34  * - Cancel the current scroll operation.
35  * - Stop the scrolling with a given initial velocity.
36  *
37  * **Important Notes:**
38  * - [onCancel] is called only when [PriorityNestedScrollConnection.reset] is invoked or when
39  *   [canCancelScroll] returns `true` after a call to [onScroll]. It is never called after [onStop].
40  * - [onStop] can be interrupted by a new gesture. In such cases, you need to handle a potential
41  *   cancellation within your implementation of [onStop], although [onCancel] will not be called.
42  */
43 interface ScrollController {
44     /**
45      * Scrolls the current content by [deltaScroll] pixels.
46      *
47      * @param deltaScroll The amount of pixels to scroll by.
48      * @param source The source of the scroll event.
49      * @return The amount of [deltaScroll] that was consumed.
50      */
onScrollnull51     fun onScroll(deltaScroll: Float, source: NestedScrollSource): Float
52 
53     /**
54      * Checks if the current scroll operation can be canceled. This is typically called after
55      * [onScroll] to determine if the [ScrollController] has lost priority and should cancel the
56      * ongoing scroll operation.
57      *
58      * @param available The total amount of scroll available.
59      * @param consumed The amount of scroll consumed by [onScroll].
60      * @return `true` if the scroll can be canceled.
61      */
62     fun canCancelScroll(available: Float, consumed: Float): Boolean {
63         return consumed == 0f
64     }
65 
66     /**
67      * Cancels the current scroll operation. This method is called when
68      * [PriorityNestedScrollConnection.reset] is invoked or when [canCancelScroll] returns `true`.
69      */
onCancelnull70     fun onCancel()
71 
72     /**
73      * Checks if the scroll can be stopped during the [NestedScrollConnection.onPreFling] phase.
74      *
75      * @return `true` if the scroll can be stopped.
76      */
77     fun canStopOnPreFling(): Boolean
78 
79     /**
80      * Stops the controller with the given [initialVelocity]. This typically starts a decay
81      * animation to smoothly bring the scrolling to a stop. This method can be interrupted by a new
82      * gesture, requiring you to handle potential cancellation within your implementation.
83      *
84      * @param initialVelocity The initial velocity of the scroll when stopping.
85      * @return The consumed [initialVelocity] when the animation completes.
86      */
87     suspend fun OnStopScope.onStop(initialVelocity: Float): Float
88 }
89 
90 interface OnStopScope {
91     /**
92      * Emits scroll events by using the [initialVelocity] and the [FlingBehavior].
93      *
94      * @return consumed velocity
95      */
96     suspend fun flingToScroll(initialVelocity: Float, flingBehavior: FlingBehavior): Float
97 }
98 
99 /**
100  * A [NestedScrollConnection] that lets you implement custom scroll behaviors that take priority
101  * over the default nested scrolling logic.
102  *
103  * When started, this connection intercepts scroll events *before* they reach child composables.
104  * This "priority mode" is activated when either [canStartPreScroll] or [canStartPostScroll] returns
105  * `true`.
106  *
107  * Once started, the [onStart] lambda provides a [ScrollController] to manage the scrolling. This
108  * controller allows you to directly manipulate the scroll state and define how scroll events are
109  * consumed.
110  *
111  * **Important Considerations:**
112  * - When started, scroll events are typically consumed in `onPreScroll`.
113  * - The provided [ScrollController] should handle potential cancellation of `onStop` due to new
114  *   gestures.
115  * - Use [reset] to release the current [ScrollController] and reset the connection to its initial
116  *   state.
117  *
118  * @param orientation The orientation of the scroll.
119  * @param canStartPreScroll A lambda that returns `true` if the connection should enter priority
120  *   mode during the pre-scroll phase. This is called before child connections have a chance to
121  *   consume the scroll.
122  * @param canStartPostScroll A lambda that returns `true` if the connection should enter priority
123  *   mode during the post-scroll phase. This is called after child connections have consumed the
124  *   scroll.
125  * @param onStart A lambda that is called when the connection enters priority mode. It should return
126  *   a [ScrollController] that will be used to control the scroll.
127  * @sample LargeTopAppBarNestedScrollConnection
128  * @sample com.android.compose.animation.scene.NestedScrollHandlerImpl.nestedScrollConnection
129  */
130 class PriorityNestedScrollConnection(
131     orientation: Orientation,
132     private val canStartPreScroll:
133         (offsetAvailable: Float, offsetBeforeStart: Float, source: NestedScrollSource) -> Boolean,
134     private val canStartPostScroll:
135         (offsetAvailable: Float, offsetBeforeStart: Float, source: NestedScrollSource) -> Boolean,
136     private val onStart: (firstScroll: Float) -> ScrollController,
<lambda>null137 ) : NestedScrollConnection, SpaceVectorConverter by SpaceVectorConverter(orientation) {
138 
139     /** The currently active [ScrollController], or `null` if not in priority mode. */
140     private var currentController: ScrollController? = null
141 
142     /**
143      * A [Deferred] representing the ongoing `onStop` animation. Used to interrupt the animation if
144      * a new gesture occurs.
145      */
146     private var stoppingJob: Deferred<Float>? = null
147 
148     /**
149      * Indicates whether the connection is currently in the process of stopping the scroll with the
150      * [ScrollController.onStop] animation.
151      */
152     private val isStopping
153         get() = stoppingJob?.isActive ?: false
154 
155     /**
156      * Tracks the cumulative scroll offset that has been consumed by other composables before this
157      * connection enters priority mode. This is used to determine when the connection should take
158      * over scrolling based on the [canStartPreScroll] and [canStartPostScroll] conditions.
159      */
160     private var offsetScrolledBeforePriorityMode = 0f
161 
162     override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
163         // If stopping, interrupt the animation and clear the controller.
164         if (isStopping) {
165             interruptStopping()
166         }
167 
168         // If in priority mode, consume the scroll using the current controller.
169         if (currentController != null) {
170             return scroll(available.toFloat(), source)
171         }
172 
173         // Check if pre-scroll condition is met, and start priority mode if necessary.
174         val availableFloat = available.toFloat()
175         if (canStartPreScroll(availableFloat, offsetScrolledBeforePriorityMode, source)) {
176             start(availableFloat)
177             return scroll(availableFloat, source)
178         }
179 
180         // Track offset consumed before entering priority mode.
181         offsetScrolledBeforePriorityMode += availableFloat
182         return Offset.Zero
183     }
184 
185     override fun onPostScroll(
186         consumed: Offset,
187         available: Offset,
188         source: NestedScrollSource,
189     ): Offset {
190         // If in priority mode, scroll events are consumed only in pre-scroll phase.
191         if (currentController != null) return Offset.Zero
192 
193         // Check if post-scroll condition is met, and start priority mode if necessary.
194         val availableFloat = available.toFloat()
195         val offsetBeforeStart = offsetScrolledBeforePriorityMode - availableFloat
196         if (canStartPostScroll(availableFloat, offsetBeforeStart, source)) {
197             start(availableFloat)
198             return scroll(availableFloat, source)
199         }
200 
201         // Do not consume the offset if priority mode is not activated.
202         return Offset.Zero
203     }
204 
205     override suspend fun onPreFling(available: Velocity): Velocity {
206         // Note: This method may be called multiple times. Due to NestedScrollDispatcher, the order
207         // of method calls (pre/post scroll/fling) cannot be guaranteed.
208         if (isStopping) return Velocity.Zero
209         val controller = currentController ?: return Velocity.Zero
210 
211         // If in priority mode and can stop on pre-fling phase, stop the scroll.
212         if (controller.canStopOnPreFling()) {
213             return stop(velocity = available.toFloat())
214         }
215 
216         // Do not consume the velocity if not stopping on pre-fling phase.
217         return Velocity.Zero
218     }
219 
220     override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
221         // Note: This method may be called multiple times. Due to NestedScrollDispatcher, the order
222         // of method calls (pre/post scroll/fling) cannot be guaranteed.
223         if (isStopping) return Velocity.Zero
224         val availableFloat = available.toFloat()
225         val controller = currentController
226 
227         // If in priority mode, stop the scroll.
228         if (controller != null) {
229             return stop(velocity = availableFloat)
230         }
231 
232         // Reset offset tracking after the fling gesture is finished.
233         resetOffsetTracker()
234         return Velocity.Zero
235     }
236 
237     /**
238      * Resets the connection to its initial state. This cancels any ongoing scroll operation and
239      * clears the current [ScrollController].
240      */
241     fun reset() {
242         if (currentController != null && !isStopping) {
243             cancel()
244         } else {
245             resetOffsetTracker()
246         }
247     }
248 
249     /**
250      * Starts priority mode by creating a new [ScrollController] using the [onStart] lambda.
251      *
252      * @param availableOffset The initial scroll offset available.
253      */
254     private fun start(availableOffset: Float) {
255         check(currentController == null) { "Another controller is active: $currentController" }
256 
257         resetOffsetTracker()
258 
259         currentController = onStart(availableOffset)
260     }
261 
262     /**
263      * Retrieves the current [ScrollController], ensuring that it is not null and that the
264      * [isStopping] state matches the expected value.
265      */
266     private fun requireController(isStopping: Boolean): ScrollController {
267         check(this.isStopping == isStopping) {
268             "isStopping is ${this.isStopping}, instead of $isStopping"
269         }
270         check(offsetScrolledBeforePriorityMode == 0f) {
271             "offset scrolled should be zero, but it was $offsetScrolledBeforePriorityMode"
272         }
273         return checkNotNull(currentController) { "The controller is $currentController" }
274     }
275 
276     /**
277      * Scrolls the content using the current [ScrollController].
278      *
279      * @param delta The amount of scroll to apply.
280      * @param source The source of the scroll event.
281      * @return The amount of scroll consumed.
282      */
283     private fun scroll(delta: Float, source: NestedScrollSource): Offset {
284         val controller = requireController(isStopping = false)
285         val consumedByScroll = controller.onScroll(delta, source)
286 
287         if (controller.canCancelScroll(delta, consumedByScroll)) {
288             // We have lost priority and we no longer need to intercept scroll events.
289             cancel()
290             offsetScrolledBeforePriorityMode = delta - consumedByScroll
291         }
292 
293         return consumedByScroll.toOffset()
294     }
295 
296     /** Cancels the current scroll operation and clears the current [ScrollController]. */
297     private fun cancel() {
298         requireController(isStopping = false).onCancel()
299         currentController = null
300     }
301 
302     /**
303      * Stops the scroll with the given velocity using the current [ScrollController].
304      *
305      * @param velocity The velocity to stop with.
306      * @return The consumed velocity.
307      */
308     suspend fun stop(velocity: Float): Velocity {
309         if (isStopping) return Velocity.Zero
310         val controller = requireController(isStopping = false)
311         return coroutineScope {
312             try {
313                 async {
314                         with(controller) {
315                             OnStopScopeImpl(controller = controller).onStop(velocity)
316                         }
317                     }
318                     // Allows others to interrupt the job.
319                     .also { stoppingJob = it }
320                     // Note: this can be cancelled by [interruptStopping]
321                     .await()
322                     .toVelocity()
323             } finally {
324                 // If the job is interrupted, it might take a while to cancel. We need to make sure
325                 // the current controller is still the initial one.
326                 if (currentController == controller) {
327                     currentController = null
328                 }
329             }
330         }
331     }
332 
333     /** Interrupts the ongoing stop animation and clears the current [ScrollController]. */
334     private fun interruptStopping() {
335         requireController(isStopping = true)
336         // We are throwing a CancellationException in the [ScrollController.onStop] method.
337         stoppingJob?.cancel()
338         currentController = null
339     }
340 
341     /** Resets the tracking of consumed offsets before entering priority mode. */
342     private fun resetOffsetTracker() {
343         offsetScrolledBeforePriorityMode = 0f
344     }
345 }
346 
347 private class OnStopScopeImpl(private val controller: ScrollController) : OnStopScope {
flingToScrollnull348     override suspend fun flingToScroll(
349         initialVelocity: Float,
350         flingBehavior: FlingBehavior,
351     ): Float {
352         return with(flingBehavior) {
353             val remainingVelocity =
354                 object : ScrollScope {
355                         override fun scrollBy(pixels: Float): Float {
356                             return controller.onScroll(pixels, NestedScrollSource.SideEffect)
357                         }
358                     }
359                     .performFling(initialVelocity)
360 
361             // returns the consumed velocity
362             initialVelocity - remainingVelocity
363         }
364     }
365 }
366