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