1 /*
<lambda>null2  * 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.foundation.gestures
18 
19 import androidx.compose.animation.core.AnimationState
20 import androidx.compose.animation.core.DecayAnimationSpec
21 import androidx.compose.animation.core.animate
22 import androidx.compose.animation.core.animateDecay
23 import androidx.compose.animation.rememberSplineBasedDecay
24 import androidx.compose.animation.splineBasedDecay
25 import androidx.compose.foundation.ComposeFoundationFlags.isOnScrollChangedCallbackEnabled
26 import androidx.compose.foundation.ExperimentalFoundationApi
27 import androidx.compose.foundation.FocusedBoundsObserverNode
28 import androidx.compose.foundation.LocalOverscrollFactory
29 import androidx.compose.foundation.MutatePriority
30 import androidx.compose.foundation.OverscrollEffect
31 import androidx.compose.foundation.gestures.Orientation.Horizontal
32 import androidx.compose.foundation.gestures.Orientation.Vertical
33 import androidx.compose.foundation.interaction.MutableInteractionSource
34 import androidx.compose.foundation.internal.PlatformOptimizedCancellationException
35 import androidx.compose.foundation.relocation.BringIntoViewResponderNode
36 import androidx.compose.foundation.rememberOverscrollEffect
37 import androidx.compose.foundation.rememberPlatformOverscrollEffect
38 import androidx.compose.runtime.Composable
39 import androidx.compose.runtime.Stable
40 import androidx.compose.runtime.remember
41 import androidx.compose.ui.Modifier
42 import androidx.compose.ui.MotionDurationScale
43 import androidx.compose.ui.focus.FocusTargetModifierNode
44 import androidx.compose.ui.focus.Focusability
45 import androidx.compose.ui.geometry.Offset
46 import androidx.compose.ui.input.key.Key
47 import androidx.compose.ui.input.key.KeyEvent
48 import androidx.compose.ui.input.key.KeyEventType
49 import androidx.compose.ui.input.key.KeyInputModifierNode
50 import androidx.compose.ui.input.key.isCtrlPressed
51 import androidx.compose.ui.input.key.key
52 import androidx.compose.ui.input.key.type
53 import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
54 import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher
55 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
56 import androidx.compose.ui.input.nestedscroll.NestedScrollSource.Companion.SideEffect
57 import androidx.compose.ui.input.nestedscroll.NestedScrollSource.Companion.UserInput
58 import androidx.compose.ui.input.nestedscroll.nestedScrollModifierNode
59 import androidx.compose.ui.input.pointer.PointerEvent
60 import androidx.compose.ui.input.pointer.PointerEventPass
61 import androidx.compose.ui.input.pointer.PointerEventType
62 import androidx.compose.ui.input.pointer.PointerInputChange
63 import androidx.compose.ui.input.pointer.PointerType
64 import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
65 import androidx.compose.ui.node.DelegatableNode
66 import androidx.compose.ui.node.ModifierNodeElement
67 import androidx.compose.ui.node.SemanticsModifierNode
68 import androidx.compose.ui.node.TraversableNode
69 import androidx.compose.ui.node.dispatchOnScrollChanged
70 import androidx.compose.ui.node.invalidateSemantics
71 import androidx.compose.ui.node.requireDensity
72 import androidx.compose.ui.platform.InspectorInfo
73 import androidx.compose.ui.platform.LocalLayoutDirection
74 import androidx.compose.ui.semantics.SemanticsPropertyReceiver
75 import androidx.compose.ui.semantics.scrollBy
76 import androidx.compose.ui.semantics.scrollByOffset
77 import androidx.compose.ui.unit.Density
78 import androidx.compose.ui.unit.IntSize
79 import androidx.compose.ui.unit.LayoutDirection
80 import androidx.compose.ui.unit.Velocity
81 import androidx.compose.ui.util.fastAny
82 import kotlin.math.abs
83 import kotlin.math.absoluteValue
84 import kotlinx.coroutines.CancellationException
85 import kotlinx.coroutines.launch
86 import kotlinx.coroutines.withContext
87 
88 /**
89  * Configure touch scrolling and flinging for the UI element in a single [Orientation].
90  *
91  * Users should update their state themselves using default [ScrollableState] and its
92  * `consumeScrollDelta` callback or by implementing [ScrollableState] interface manually and reflect
93  * their own state in UI when using this component.
94  *
95  * If you don't need to have fling or nested scroll support, but want to make component simply
96  * draggable, consider using [draggable].
97  *
98  * @sample androidx.compose.foundation.samples.ScrollableSample
99  * @param state [ScrollableState] state of the scrollable. Defines how scroll events will be
100  *   interpreted by the user land logic and contains useful information about on-going events.
101  * @param orientation orientation of the scrolling
102  * @param enabled whether or not scrolling in enabled
103  * @param reverseDirection reverse the direction of the scroll, so top to bottom scroll will behave
104  *   like bottom to top and left to right will behave like right to left.
105  * @param flingBehavior logic describing fling behavior when drag has finished with velocity. If
106  *   `null`, default from [ScrollableDefaults.flingBehavior] will be used.
107  * @param interactionSource [MutableInteractionSource] that will be used to emit drag events when
108  *   this scrollable is being dragged.
109  */
110 @Stable
111 fun Modifier.scrollable(
112     state: ScrollableState,
113     orientation: Orientation,
114     enabled: Boolean = true,
115     reverseDirection: Boolean = false,
116     flingBehavior: FlingBehavior? = null,
117     interactionSource: MutableInteractionSource? = null
118 ): Modifier =
119     scrollable(
120         state = state,
121         orientation = orientation,
122         enabled = enabled,
123         reverseDirection = reverseDirection,
124         flingBehavior = flingBehavior,
125         interactionSource = interactionSource,
126         overscrollEffect = null
127     )
128 
129 /**
130  * Configure touch scrolling and flinging for the UI element in a single [Orientation].
131  *
132  * Users should update their state themselves using default [ScrollableState] and its
133  * `consumeScrollDelta` callback or by implementing [ScrollableState] interface manually and reflect
134  * their own state in UI when using this component.
135  *
136  * If you don't need to have fling or nested scroll support, but want to make component simply
137  * draggable, consider using [draggable].
138  *
139  * This overload provides the access to [OverscrollEffect] that defines the behaviour of the over
140  * scrolling logic. Use [androidx.compose.foundation.rememberOverscrollEffect] to create an instance
141  * of the current provided overscroll implementation.
142  *
143  * @sample androidx.compose.foundation.samples.ScrollableSample
144  * @param state [ScrollableState] state of the scrollable. Defines how scroll events will be
145  *   interpreted by the user land logic and contains useful information about on-going events.
146  * @param orientation orientation of the scrolling
147  * @param overscrollEffect effect to which the deltas will be fed when the scrollable have some
148  *   scrolling delta left. Pass `null` for no overscroll. If you pass an effect you should also
149  *   apply [androidx.compose.foundation.overscroll] modifier.
150  * @param enabled whether or not scrolling in enabled
151  * @param reverseDirection reverse the direction of the scroll, so top to bottom scroll will behave
152  *   like bottom to top and left to right will behave like right to left.
153  * @param flingBehavior logic describing fling behavior when drag has finished with velocity. If
154  *   `null`, default from [ScrollableDefaults.flingBehavior] will be used.
155  * @param interactionSource [MutableInteractionSource] that will be used to emit drag events when
156  *   this scrollable is being dragged.
157  * @param bringIntoViewSpec The configuration that this scrollable should use to perform scrolling
158  *   when scroll requests are received from the focus system. If null is provided the system will
159  *   use the behavior provided by [LocalBringIntoViewSpec] which by default has a platform dependent
160  *   implementation.
161  */
162 @Stable
163 fun Modifier.scrollable(
164     state: ScrollableState,
165     orientation: Orientation,
166     overscrollEffect: OverscrollEffect?,
167     enabled: Boolean = true,
168     reverseDirection: Boolean = false,
169     flingBehavior: FlingBehavior? = null,
170     interactionSource: MutableInteractionSource? = null,
171     bringIntoViewSpec: BringIntoViewSpec? = null
172 ) =
173     this then
174         ScrollableElement(
175             state,
176             orientation,
177             overscrollEffect,
178             enabled,
179             reverseDirection,
180             flingBehavior,
181             interactionSource,
182             bringIntoViewSpec
183         )
184 
185 private class ScrollableElement(
186     val state: ScrollableState,
187     val orientation: Orientation,
188     val overscrollEffect: OverscrollEffect?,
189     val enabled: Boolean,
190     val reverseDirection: Boolean,
191     val flingBehavior: FlingBehavior?,
192     val interactionSource: MutableInteractionSource?,
193     val bringIntoViewSpec: BringIntoViewSpec?
194 ) : ModifierNodeElement<ScrollableNode>() {
195     override fun create(): ScrollableNode {
196         return ScrollableNode(
197             state,
198             overscrollEffect,
199             flingBehavior,
200             orientation,
201             enabled,
202             reverseDirection,
203             interactionSource,
204             bringIntoViewSpec
205         )
206     }
207 
208     override fun update(node: ScrollableNode) {
209         node.update(
210             state,
211             orientation,
212             overscrollEffect,
213             enabled,
214             reverseDirection,
215             flingBehavior,
216             interactionSource,
217             bringIntoViewSpec
218         )
219     }
220 
221     override fun hashCode(): Int {
222         var result = state.hashCode()
223         result = 31 * result + orientation.hashCode()
224         result = 31 * result + overscrollEffect.hashCode()
225         result = 31 * result + enabled.hashCode()
226         result = 31 * result + reverseDirection.hashCode()
227         result = 31 * result + flingBehavior.hashCode()
228         result = 31 * result + interactionSource.hashCode()
229         result = 31 * result + bringIntoViewSpec.hashCode()
230         return result
231     }
232 
233     override fun equals(other: Any?): Boolean {
234         if (this === other) return true
235 
236         if (other !is ScrollableElement) return false
237 
238         if (state != other.state) return false
239         if (orientation != other.orientation) return false
240         if (overscrollEffect != other.overscrollEffect) return false
241         if (enabled != other.enabled) return false
242         if (reverseDirection != other.reverseDirection) return false
243         if (flingBehavior != other.flingBehavior) return false
244         if (interactionSource != other.interactionSource) return false
245         if (bringIntoViewSpec != other.bringIntoViewSpec) return false
246 
247         return true
248     }
249 
250     override fun InspectorInfo.inspectableProperties() {
251         name = "scrollable"
252         properties["orientation"] = orientation
253         properties["state"] = state
254         properties["overscrollEffect"] = overscrollEffect
255         properties["enabled"] = enabled
256         properties["reverseDirection"] = reverseDirection
257         properties["flingBehavior"] = flingBehavior
258         properties["interactionSource"] = interactionSource
259         properties["bringIntoViewSpec"] = bringIntoViewSpec
260     }
261 }
262 
263 internal class ScrollableNode(
264     state: ScrollableState,
265     private var overscrollEffect: OverscrollEffect?,
266     private var flingBehavior: FlingBehavior?,
267     orientation: Orientation,
268     enabled: Boolean,
269     reverseDirection: Boolean,
270     interactionSource: MutableInteractionSource?,
271     bringIntoViewSpec: BringIntoViewSpec?
272 ) :
273     DragGestureNode(
274         canDrag = CanDragCalculation,
275         enabled = enabled,
276         interactionSource = interactionSource,
277         orientationLock = orientation
278     ),
279     KeyInputModifierNode,
280     SemanticsModifierNode,
281     CompositionLocalConsumerModifierNode,
282     OnScrollChangedDispatcher {
283 
284     override val shouldAutoInvalidate: Boolean = false
285 
286     private val nestedScrollDispatcher = NestedScrollDispatcher()
287 
288     private val scrollableContainerNode = delegate(ScrollableContainerNode(enabled))
289 
290     // Place holder fling behavior, we'll initialize it when the density is available.
291     // TODO: It should differ between platforms, move it under expect/actual
292     private val defaultFlingBehavior = DefaultFlingBehavior(splineBasedDecay(UnityDensity))
293 
294     private val scrollingLogic =
295         ScrollingLogic(
296             scrollableState = state,
297             orientation = orientation,
298             overscrollEffect = overscrollEffect,
299             reverseDirection = reverseDirection,
300             flingBehavior = flingBehavior ?: defaultFlingBehavior,
301             nestedScrollDispatcher = nestedScrollDispatcher,
302             onScrollChangedDispatcher = this,
<lambda>null303             isScrollableNodeAttached = { isAttached }
304         )
305 
306     private val nestedScrollConnection =
307         ScrollableNestedScrollConnection(enabled = enabled, scrollingLogic = scrollingLogic)
308 
309     private val contentInViewNode =
310         delegate(
311             ContentInViewNode(orientation, scrollingLogic, reverseDirection, bringIntoViewSpec)
312         )
313 
314     private var scrollByAction: ((x: Float, y: Float) -> Boolean)? = null
315     private var scrollByOffsetAction: (suspend (Offset) -> Offset)? = null
316 
317     private var mouseWheelScrollingLogic: MouseWheelScrollingLogic? = null
318 
319     init {
320         /** Nested scrolling */
321         delegate(nestedScrollModifierNode(nestedScrollConnection, nestedScrollDispatcher))
322 
323         /** Focus scrolling */
324         delegate(FocusTargetModifierNode(focusability = Focusability.Never))
325         delegate(BringIntoViewResponderNode(contentInViewNode))
<lambda>null326         delegate(FocusedBoundsObserverNode { contentInViewNode.onFocusBoundsChanged(it) })
327     }
328 
dispatchScrollDeltaInfonull329     override fun dispatchScrollDeltaInfo(delta: Offset) {
330         if (!isAttached) return
331         dispatchOnScrollChanged(delta)
332     }
333 
dragnull334     override suspend fun drag(
335         forEachDelta: suspend ((dragDelta: DragEvent.DragDelta) -> Unit) -> Unit
336     ) {
337         with(scrollingLogic) {
338             scroll(scrollPriority = MutatePriority.UserInput) {
339                 forEachDelta {
340                     scrollByWithOverscroll(it.delta.singleAxisOffset(), source = UserInput)
341                 }
342             }
343         }
344     }
345 
onDragStartednull346     override fun onDragStarted(startedPosition: Offset) {}
347 
onDragStoppednull348     override fun onDragStopped(velocity: Velocity) {
349         nestedScrollDispatcher.coroutineScope.launch {
350             scrollingLogic.onScrollStopped(velocity, isMouseWheel = false)
351         }
352     }
353 
onWheelScrollStoppednull354     private fun onWheelScrollStopped(velocity: Velocity) {
355         nestedScrollDispatcher.coroutineScope.launch {
356             scrollingLogic.onScrollStopped(velocity, isMouseWheel = true)
357         }
358     }
359 
startDragImmediatelynull360     override fun startDragImmediately(): Boolean {
361         return scrollingLogic.shouldScrollImmediately()
362     }
363 
ensureMouseWheelScrollNodeInitializednull364     private fun ensureMouseWheelScrollNodeInitialized() {
365         if (mouseWheelScrollingLogic == null) {
366             mouseWheelScrollingLogic =
367                 MouseWheelScrollingLogic(
368                     scrollingLogic = scrollingLogic,
369                     mouseWheelScrollConfig = platformScrollConfig(),
370                     onScrollStopped = ::onWheelScrollStopped,
371                     density = requireDensity()
372                 )
373         }
374 
375         mouseWheelScrollingLogic?.startReceivingMouseWheelEvents(coroutineScope)
376     }
377 
updatenull378     fun update(
379         state: ScrollableState,
380         orientation: Orientation,
381         overscrollEffect: OverscrollEffect?,
382         enabled: Boolean,
383         reverseDirection: Boolean,
384         flingBehavior: FlingBehavior?,
385         interactionSource: MutableInteractionSource?,
386         bringIntoViewSpec: BringIntoViewSpec?
387     ) {
388         var shouldInvalidateSemantics = false
389         if (this.enabled != enabled) { // enabled changed
390             nestedScrollConnection.enabled = enabled
391             scrollableContainerNode.update(enabled)
392             shouldInvalidateSemantics = true
393         }
394         // a new fling behavior was set, change the resolved one.
395         val resolvedFlingBehavior = flingBehavior ?: defaultFlingBehavior
396 
397         val resetPointerInputHandling =
398             scrollingLogic.update(
399                 scrollableState = state,
400                 orientation = orientation,
401                 overscrollEffect = overscrollEffect,
402                 reverseDirection = reverseDirection,
403                 flingBehavior = resolvedFlingBehavior,
404                 nestedScrollDispatcher = nestedScrollDispatcher
405             )
406         contentInViewNode.update(orientation, reverseDirection, bringIntoViewSpec)
407 
408         this.overscrollEffect = overscrollEffect
409         this.flingBehavior = flingBehavior
410 
411         // update DragGestureNode
412         update(
413             canDrag = CanDragCalculation,
414             enabled = enabled,
415             interactionSource = interactionSource,
416             orientationLock = if (scrollingLogic.isVertical()) Vertical else Horizontal,
417             shouldResetPointerInputHandling = resetPointerInputHandling
418         )
419 
420         if (shouldInvalidateSemantics) {
421             clearScrollSemanticsActions()
422             invalidateSemantics()
423         }
424     }
425 
onAttachnull426     override fun onAttach() {
427         updateDefaultFlingBehavior()
428         mouseWheelScrollingLogic?.updateDensity(requireDensity())
429     }
430 
updateDefaultFlingBehaviornull431     private fun updateDefaultFlingBehavior() {
432         if (!isAttached) return
433         val density = requireDensity()
434         defaultFlingBehavior.updateDensity(density)
435     }
436 
onDensityChangenull437     override fun onDensityChange() {
438         onCancelPointerInput()
439         updateDefaultFlingBehavior()
440         mouseWheelScrollingLogic?.updateDensity(requireDensity())
441     }
442 
443     // Key handler for Page up/down scrolling behavior.
onKeyEventnull444     override fun onKeyEvent(event: KeyEvent): Boolean {
445         return if (
446             enabled &&
447                 (event.key == Key.PageDown || event.key == Key.PageUp) &&
448                 (event.type == KeyEventType.KeyDown) &&
449                 (!event.isCtrlPressed)
450         ) {
451 
452             val scrollAmount: Offset =
453                 if (scrollingLogic.isVertical()) {
454                     val viewportHeight = contentInViewNode.viewportSize.height
455 
456                     val yAmount =
457                         if (event.key == Key.PageUp) {
458                             viewportHeight.toFloat()
459                         } else {
460                             -viewportHeight.toFloat()
461                         }
462 
463                     Offset(0f, yAmount)
464                 } else {
465                     val viewportWidth = contentInViewNode.viewportSize.width
466 
467                     val xAmount =
468                         if (event.key == Key.PageUp) {
469                             viewportWidth.toFloat()
470                         } else {
471                             -viewportWidth.toFloat()
472                         }
473 
474                     Offset(xAmount, 0f)
475                 }
476 
477             // A coroutine is launched for every individual scroll event in the
478             // larger scroll gesture. If we see degradation in the future (that is,
479             // a fast scroll gesture on a slow device causes UI jank [not seen up to
480             // this point), we can switch to a more efficient solution where we
481             // lazily launch one coroutine (with the first event) and use a Channel
482             // to communicate the scroll amount to the UI thread.
483             coroutineScope.launch {
484                 scrollingLogic.scroll(scrollPriority = MutatePriority.UserInput) {
485                     scrollBy(offset = scrollAmount, source = UserInput)
486                 }
487             }
488             true
489         } else {
490             false
491         }
492     }
493 
onPreKeyEventnull494     override fun onPreKeyEvent(event: KeyEvent) = false
495 
496     // Forward all PointerInputModifierNode method calls to `mmouseWheelScrollNode.pointerInputNode`
497     // See explanation in `MouseWheelScrollNode.pointerInputNode`
498 
499     override fun onPointerEvent(
500         pointerEvent: PointerEvent,
501         pass: PointerEventPass,
502         bounds: IntSize
503     ) {
504         if (pointerEvent.changes.fastAny { canDrag.invoke(it) }) {
505             super.onPointerEvent(pointerEvent, pass, bounds)
506         }
507         if (enabled) {
508             if (pass == PointerEventPass.Initial && pointerEvent.type == PointerEventType.Scroll) {
509                 ensureMouseWheelScrollNodeInitialized()
510             }
511             mouseWheelScrollingLogic?.onPointerEvent(pointerEvent, pass, bounds)
512         }
513     }
514 
applySemanticsnull515     override fun SemanticsPropertyReceiver.applySemantics() {
516         if (enabled && (scrollByAction == null || scrollByOffsetAction == null)) {
517             setScrollSemanticsActions()
518         }
519 
520         scrollByAction?.let { scrollBy(action = it) }
521 
522         scrollByOffsetAction?.let { scrollByOffset(action = it) }
523     }
524 
setScrollSemanticsActionsnull525     private fun setScrollSemanticsActions() {
526         scrollByAction = { x, y ->
527             coroutineScope.launch { scrollingLogic.semanticsScrollBy(Offset(x, y)) }
528             true
529         }
530 
531         scrollByOffsetAction = { offset -> scrollingLogic.semanticsScrollBy(offset) }
532     }
533 
clearScrollSemanticsActionsnull534     private fun clearScrollSemanticsActions() {
535         scrollByAction = null
536         scrollByOffsetAction = null
537     }
538 }
539 
540 /** Contains the default values used by [scrollable] */
541 object ScrollableDefaults {
542 
543     /** Create and remember default [FlingBehavior] that will represent natural fling curve. */
544     // TODO: It should differ between platforms, move it under expect/actual
545     @Composable
flingBehaviornull546     fun flingBehavior(): FlingBehavior {
547         val flingSpec = rememberSplineBasedDecay<Float>()
548         return remember(flingSpec) { DefaultFlingBehavior(flingSpec) }
549     }
550 
551     /**
552      * Returns a remembered [OverscrollEffect] created from the current value of
553      * [LocalOverscrollFactory].
554      *
555      * This API has been deprpecated, and replaced with [rememberOverscrollEffect]
556      */
557     @Deprecated(
558         "This API has been replaced with rememberOverscrollEffect, which queries theme provided OverscrollFactory values instead of the 'platform default' without customization.",
559         replaceWith =
560             ReplaceWith(
561                 "rememberOverscrollEffect()",
562                 "androidx.compose.foundation.rememberOverscrollEffect"
563             )
564     )
565     @Composable
overscrollEffectnull566     fun overscrollEffect(): OverscrollEffect {
567         return rememberPlatformOverscrollEffect() ?: NoOpOverscrollEffect
568     }
569 
570     private object NoOpOverscrollEffect : OverscrollEffect {
applyToScrollnull571         override fun applyToScroll(
572             delta: Offset,
573             source: NestedScrollSource,
574             performScroll: (Offset) -> Offset
575         ): Offset = performScroll(delta)
576 
577         override suspend fun applyToFling(
578             velocity: Velocity,
579             performFling: suspend (Velocity) -> Velocity
580         ) {
581             performFling(velocity)
582         }
583 
584         override val isInProgress: Boolean
585             get() = false
586 
587         override val node: DelegatableNode
588             get() = object : Modifier.Node() {}
589     }
590 
591     /**
592      * Used to determine the value of `reverseDirection` parameter of [Modifier.scrollable] in
593      * scrollable layouts.
594      *
595      * @param layoutDirection current layout direction (e.g. from [LocalLayoutDirection])
596      * @param orientation orientation of scroll
597      * @param reverseScrolling whether scrolling direction should be reversed
598      * @return `true` if scroll direction should be reversed, `false` otherwise.
599      */
reverseDirectionnull600     fun reverseDirection(
601         layoutDirection: LayoutDirection,
602         orientation: Orientation,
603         reverseScrolling: Boolean
604     ): Boolean {
605         // A finger moves with the content, not with the viewport. Therefore,
606         // always reverse once to have "natural" gesture that goes reversed to layout
607         var reverseDirection = !reverseScrolling
608         // But if rtl and horizontal, things move the other way around
609         val isRtl = layoutDirection == LayoutDirection.Rtl
610         if (isRtl && orientation != Orientation.Vertical) {
611             reverseDirection = !reverseDirection
612         }
613         return reverseDirection
614     }
615 }
616 
617 internal interface ScrollConfig {
618 
619     /** Enables animated transition of scroll on mouse wheel events. */
620     val isSmoothScrollingEnabled: Boolean
621         get() = true
622 
isPreciseWheelScrollnull623     fun isPreciseWheelScroll(event: PointerEvent): Boolean = false
624 
625     fun Density.calculateMouseWheelScroll(event: PointerEvent, bounds: IntSize): Offset
626 }
627 
628 internal expect fun CompositionLocalConsumerModifierNode.platformScrollConfig(): ScrollConfig
629 
630 // TODO: provide public way to drag by mouse (especially requested for Pager)
631 private val CanDragCalculation: (PointerInputChange) -> Boolean = { change ->
632     change.type != PointerType.Mouse
633 }
634 
635 /**
636  * Holds all scrolling related logic: controls nested scrolling, flinging, overscroll and delta
637  * dispatching.
638  */
639 internal class ScrollingLogic(
640     var scrollableState: ScrollableState,
641     private var overscrollEffect: OverscrollEffect?,
642     private var flingBehavior: FlingBehavior,
643     private var orientation: Orientation,
644     private var reverseDirection: Boolean,
645     private var nestedScrollDispatcher: NestedScrollDispatcher,
646     private var onScrollChangedDispatcher: OnScrollChangedDispatcher,
647     private val isScrollableNodeAttached: () -> Boolean
648 ) {
649     // specifies if this scrollable node is currently flinging
650     var isFlinging = false
651         private set
652 
Floatnull653     fun Float.toOffset(): Offset =
654         when {
655             this == 0f -> Offset.Zero
656             orientation == Horizontal -> Offset(this, 0f)
657             else -> Offset(0f, this)
658         }
659 
Offsetnull660     fun Offset.singleAxisOffset(): Offset =
661         if (orientation == Horizontal) copy(y = 0f) else copy(x = 0f)
662 
663     fun Offset.toFloat(): Float = if (orientation == Horizontal) this.x else this.y
664 
665     fun Float.toVelocity(): Velocity =
666         when {
667             this == 0f -> Velocity.Zero
668             orientation == Horizontal -> Velocity(this, 0f)
669             else -> Velocity(0f, this)
670         }
671 
toFloatnull672     private fun Velocity.toFloat(): Float = if (orientation == Horizontal) this.x else this.y
673 
674     private fun Velocity.singleAxisVelocity(): Velocity =
675         if (orientation == Horizontal) copy(y = 0f) else copy(x = 0f)
676 
677     private fun Velocity.update(newValue: Float): Velocity =
678         if (orientation == Horizontal) copy(x = newValue) else copy(y = newValue)
679 
680     fun Float.reverseIfNeeded(): Float = if (reverseDirection) this * -1 else this
681 
682     fun Offset.reverseIfNeeded(): Offset = if (reverseDirection) this * -1f else this
683 
684     private var latestScrollSource = UserInput
685     private var outerStateScope = NoOpScrollScope
686 
687     private val nestedScrollScope =
688         object : NestedScrollScope {
689             override fun scrollBy(offset: Offset, source: NestedScrollSource): Offset {
690                 return with(outerStateScope) { performScroll(offset, source) }
691             }
692 
693             override fun scrollByWithOverscroll(
694                 offset: Offset,
695                 source: NestedScrollSource
696             ): Offset {
697                 latestScrollSource = source
698                 val overscroll = overscrollEffect
699                 return if (overscroll != null && shouldDispatchOverscroll) {
700                     overscroll.applyToScroll(offset, latestScrollSource, performScrollForOverscroll)
701                 } else {
702                     with(outerStateScope) { performScroll(offset, source) }
703                 }
704             }
705         }
706 
deltanull707     private val performScrollForOverscroll: (Offset) -> Offset = { delta ->
708         with(outerStateScope) { performScroll(delta, latestScrollSource) }
709     }
710 
711     @OptIn(ExperimentalFoundationApi::class)
ScrollScopenull712     private fun ScrollScope.performScroll(delta: Offset, source: NestedScrollSource): Offset {
713         val consumedByPreScroll = nestedScrollDispatcher.dispatchPreScroll(delta, source)
714 
715         val scrollAvailableAfterPreScroll = delta - consumedByPreScroll
716 
717         val singleAxisDeltaForSelfScroll =
718             scrollAvailableAfterPreScroll.singleAxisOffset().reverseIfNeeded().toFloat()
719 
720         // Consume on a single axis.
721         val consumedBySelfScroll =
722             scrollBy(singleAxisDeltaForSelfScroll).toOffset().reverseIfNeeded()
723 
724         // Trigger on scroll changed callback
725         if (isOnScrollChangedCallbackEnabled) {
726             onScrollChangedDispatcher.dispatchScrollDeltaInfo(consumedBySelfScroll)
727         }
728 
729         val deltaAvailableAfterScroll = scrollAvailableAfterPreScroll - consumedBySelfScroll
730         val consumedByPostScroll =
731             nestedScrollDispatcher.dispatchPostScroll(
732                 consumedBySelfScroll,
733                 deltaAvailableAfterScroll,
734                 source
735             )
736         return consumedByPreScroll + consumedBySelfScroll + consumedByPostScroll
737     }
738 
739     private val shouldDispatchOverscroll
740         get() = scrollableState.canScrollForward || scrollableState.canScrollBackward
741 
performRawScrollnull742     fun performRawScroll(scroll: Offset): Offset {
743         return if (scrollableState.isScrollInProgress) {
744             Offset.Zero
745         } else {
746             dispatchRawDelta(scroll)
747         }
748     }
749 
dispatchRawDeltanull750     private fun dispatchRawDelta(scroll: Offset): Offset {
751         return scrollableState
752             .dispatchRawDelta(scroll.toFloat().reverseIfNeeded())
753             .reverseIfNeeded()
754             .toOffset()
755     }
756 
onScrollStoppednull757     suspend fun onScrollStopped(initialVelocity: Velocity, isMouseWheel: Boolean) {
758         if (isMouseWheel && !flingBehavior.shouldBeTriggeredByMouseWheel) {
759             return
760         }
761         val availableVelocity = initialVelocity.singleAxisVelocity()
762 
763         val performFling: suspend (Velocity) -> Velocity = { velocity ->
764             val preConsumedByParent = nestedScrollDispatcher.dispatchPreFling(velocity)
765             val available = velocity - preConsumedByParent
766 
767             val velocityLeft = doFlingAnimation(available)
768 
769             val consumedPost =
770                 nestedScrollDispatcher.dispatchPostFling((available - velocityLeft), velocityLeft)
771             val totalLeft = velocityLeft - consumedPost
772             velocity - totalLeft
773         }
774 
775         val overscroll = overscrollEffect
776         if (overscroll != null && shouldDispatchOverscroll) {
777             overscroll.applyToFling(availableVelocity, performFling)
778         } else {
779             performFling(availableVelocity)
780         }
781     }
782 
783     // fling should be cancelled if we try to scroll more than we can or if this node
784     // is detached during a fling.
shouldCancelFlingnull785     private fun shouldCancelFling(pixels: Float): Boolean {
786         // tries to scroll forward but cannot.
787         return (pixels > 0.0f && !scrollableState.canScrollForward) ||
788             // tries to scroll backward but cannot.
789             (pixels < 0.0f && !scrollableState.canScrollBackward) ||
790             // node is detached.
791             !isScrollableNodeAttached.invoke()
792     }
793 
794     @OptIn(ExperimentalFoundationApi::class)
doFlingAnimationnull795     suspend fun doFlingAnimation(available: Velocity): Velocity {
796         var result: Velocity = available
797         isFlinging = true
798         try {
799             scroll(scrollPriority = MutatePriority.Default) {
800                 val nestedScrollScope = this
801                 val reverseScope =
802                     object : ScrollScope {
803                         override fun scrollBy(pixels: Float): Float {
804                             // Fling has hit the bounds or node left composition,
805                             // cancel it to allow continuation. This will conclude this node's
806                             // fling,
807                             // allowing the onPostFling signal to be called
808                             // with the leftover velocity from the fling animation. Any nested
809                             // scroll
810                             // node above will be able to pick up the left over velocity and
811                             // continue
812                             // the fling.
813                             if (pixels.absoluteValue != 0.0f && shouldCancelFling(pixels)) {
814                                 throw FlingCancellationException()
815                             }
816 
817                             return nestedScrollScope
818                                 .scrollByWithOverscroll(
819                                     offset = pixels.toOffset().reverseIfNeeded(),
820                                     source = SideEffect
821                                 )
822                                 .toFloat()
823                                 .reverseIfNeeded()
824                         }
825                     }
826                 with(reverseScope) {
827                     with(flingBehavior) {
828                         result =
829                             result.update(
830                                 performFling(available.toFloat().reverseIfNeeded())
831                                     .reverseIfNeeded()
832                             )
833                     }
834                 }
835             }
836         } finally {
837             isFlinging = false
838         }
839 
840         return result
841     }
842 
shouldScrollImmediatelynull843     fun shouldScrollImmediately(): Boolean {
844         return scrollableState.isScrollInProgress || overscrollEffect?.isInProgress ?: false
845     }
846 
847     /** Opens a scrolling session with nested scrolling and overscroll support. */
scrollnull848     suspend fun scroll(
849         scrollPriority: MutatePriority = MutatePriority.Default,
850         block: suspend NestedScrollScope.() -> Unit
851     ) {
852         scrollableState.scroll(scrollPriority) {
853             outerStateScope = this
854             block.invoke(nestedScrollScope)
855         }
856     }
857 
858     /** @return true if the pointer input should be reset */
updatenull859     fun update(
860         scrollableState: ScrollableState,
861         orientation: Orientation,
862         overscrollEffect: OverscrollEffect?,
863         reverseDirection: Boolean,
864         flingBehavior: FlingBehavior,
865         nestedScrollDispatcher: NestedScrollDispatcher
866     ): Boolean {
867         var resetPointerInputHandling = false
868         if (this.scrollableState != scrollableState) {
869             this.scrollableState = scrollableState
870             resetPointerInputHandling = true
871         }
872         this.overscrollEffect = overscrollEffect
873         if (this.orientation != orientation) {
874             this.orientation = orientation
875             resetPointerInputHandling = true
876         }
877         if (this.reverseDirection != reverseDirection) {
878             this.reverseDirection = reverseDirection
879             resetPointerInputHandling = true
880         }
881         this.flingBehavior = flingBehavior
882         this.nestedScrollDispatcher = nestedScrollDispatcher
883         return resetPointerInputHandling
884     }
885 
isVerticalnull886     fun isVertical(): Boolean = orientation == Vertical
887 }
888 
889 private val NoOpScrollScope: ScrollScope =
890     object : ScrollScope {
891         override fun scrollBy(pixels: Float): Float = pixels
892     }
893 
894 private class ScrollableNestedScrollConnection(
895     val scrollingLogic: ScrollingLogic,
896     var enabled: Boolean
897 ) : NestedScrollConnection {
898 
onPostScrollnull899     override fun onPostScroll(
900         consumed: Offset,
901         available: Offset,
902         source: NestedScrollSource
903     ): Offset =
904         if (enabled) {
905             scrollingLogic.performRawScroll(available)
906         } else {
907             Offset.Zero
908         }
909 
910     @OptIn(ExperimentalFoundationApi::class)
onPostFlingnull911     override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
912         return if (enabled) {
913             val velocityLeft =
914                 if (scrollingLogic.isFlinging) {
915                     Velocity.Zero
916                 } else {
917                     scrollingLogic.doFlingAnimation(available)
918                 }
919             available - velocityLeft
920         } else {
921             Velocity.Zero
922         }
923     }
924 }
925 
926 /** Compatibility interface for default fling behaviors that depends on [Density]. */
927 internal interface ScrollableDefaultFlingBehavior : FlingBehavior {
928     /**
929      * Update the internal parameters of FlingBehavior in accordance with the new
930      * [androidx.compose.ui.unit.Density] value.
931      *
932      * @param density new density value.
933      */
updateDensitynull934     fun updateDensity(density: Density) = Unit
935 }
936 
937 /**
938  * TODO: Move it to public interface Currently, default [FlingBehavior] is not triggered at all to
939  *   avoid unexpected effects during regular scrolling. However, custom one must be triggered
940  *   because it's used not only for "inertia", but also for snapping in
941  *   [androidx.compose.foundation.pager.Pager] or
942  *   [androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior].
943  */
944 private val FlingBehavior.shouldBeTriggeredByMouseWheel
945     // TODO: Figure out more precise condition to trigger fling by mouse wheel.
946     get() = false // this !is ScrollableDefaultFlingBehavior
947 
948 internal class DefaultFlingBehavior(
949     private var flingDecay: DecayAnimationSpec<Float>,
950     private val motionDurationScale: MotionDurationScale = DefaultScrollMotionDurationScale
951 ) : ScrollableDefaultFlingBehavior {
952 
953     // For Testing
954     var lastAnimationCycleCount = 0
955 
956     override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
957         lastAnimationCycleCount = 0
958         // come up with the better threshold, but we need it since spline curve gives us NaNs
959         return withContext(motionDurationScale) {
960             if (abs(initialVelocity) > 1f) {
961                 var velocityLeft = initialVelocity
962                 var lastValue = 0f
963                 val animationState =
964                     AnimationState(
965                         initialValue = 0f,
966                         initialVelocity = initialVelocity,
967                     )
968                 try {
969                     animationState.animateDecay(flingDecay) {
970                         val delta = value - lastValue
971                         val consumed = scrollBy(delta)
972                         lastValue = value
973                         velocityLeft = this.velocity
974                         // avoid rounding errors and stop if anything is unconsumed
975                         if (abs(delta - consumed) > 0.5f) this.cancelAnimation()
976                         lastAnimationCycleCount++
977                     }
978                 } catch (exception: CancellationException) {
979                     velocityLeft = animationState.velocity
980                 }
981                 velocityLeft
982             } else {
983                 initialVelocity
984             }
985         }
986     }
987 
988     override fun updateDensity(density: Density) {
989         flingDecay = splineBasedDecay(density)
990     }
991 }
992 
993 private const val DefaultScrollMotionDurationScaleFactor = 1f
994 internal val DefaultScrollMotionDurationScale =
995     object : MotionDurationScale {
996         override val scaleFactor: Float
997             get() = DefaultScrollMotionDurationScaleFactor
998     }
999 
1000 /**
1001  * (b/311181532): This could not be flattened so we moved it to TraversableNode, but ideally
1002  * ScrollabeNode should be the one to be travesable.
1003  */
1004 internal class ScrollableContainerNode(enabled: Boolean) : Modifier.Node(), TraversableNode {
1005     override val traverseKey: Any = TraverseKey
1006 
1007     var enabled: Boolean = enabled
1008         private set
1009 
1010     companion object TraverseKey
1011 
updatenull1012     fun update(enabled: Boolean) {
1013         this.enabled = enabled
1014     }
1015 }
1016 
1017 private val UnityDensity =
1018     object : Density {
1019         override val density: Float
1020             get() = 1f
1021 
1022         override val fontScale: Float
1023             get() = 1f
1024     }
1025 
1026 /** A scroll scope for nested scrolling and overscroll support. */
1027 internal interface NestedScrollScope {
scrollBynull1028     fun scrollBy(offset: Offset, source: NestedScrollSource): Offset
1029 
1030     fun scrollByWithOverscroll(offset: Offset, source: NestedScrollSource): Offset
1031 }
1032 
1033 /**
1034  * Scroll deltas originating from the semantics system. Should be dispatched as an animation driven
1035  * event.
1036  */
1037 private suspend fun ScrollingLogic.semanticsScrollBy(offset: Offset): Offset {
1038     var previousValue = 0f
1039     scroll(scrollPriority = MutatePriority.Default) {
1040         animate(0f, offset.toFloat()) { currentValue, _ ->
1041             val delta = currentValue - previousValue
1042             val consumed =
1043                 scrollBy(offset = delta.reverseIfNeeded().toOffset(), source = UserInput)
1044                     .toFloat()
1045                     .reverseIfNeeded()
1046             previousValue += consumed
1047         }
1048     }
1049     return previousValue.toOffset()
1050 }
1051 
1052 internal class FlingCancellationException :
1053     PlatformOptimizedCancellationException("The fling animation was cancelled")
1054 
1055 internal interface OnScrollChangedDispatcher {
dispatchScrollDeltaInfonull1056     fun dispatchScrollDeltaInfo(delta: Offset)
1057 }
1058