1 /*
2  * Copyright 2019 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
18 
19 import androidx.compose.animation.core.AnimationSpec
20 import androidx.compose.animation.core.SpringSpec
21 import androidx.compose.foundation.gestures.FlingBehavior
22 import androidx.compose.foundation.gestures.Orientation
23 import androidx.compose.foundation.gestures.ScrollScope
24 import androidx.compose.foundation.gestures.ScrollableDefaults
25 import androidx.compose.foundation.gestures.ScrollableState
26 import androidx.compose.foundation.gestures.animateScrollBy
27 import androidx.compose.foundation.gestures.scrollBy
28 import androidx.compose.foundation.interaction.InteractionSource
29 import androidx.compose.foundation.interaction.MutableInteractionSource
30 import androidx.compose.runtime.Composable
31 import androidx.compose.runtime.Stable
32 import androidx.compose.runtime.derivedStateOf
33 import androidx.compose.runtime.getValue
34 import androidx.compose.runtime.mutableIntStateOf
35 import androidx.compose.runtime.remember
36 import androidx.compose.runtime.saveable.Saver
37 import androidx.compose.runtime.saveable.rememberSaveable
38 import androidx.compose.runtime.setValue
39 import androidx.compose.runtime.snapshots.Snapshot
40 import androidx.compose.ui.Modifier
41 import androidx.compose.ui.layout.IntrinsicMeasurable
42 import androidx.compose.ui.layout.IntrinsicMeasureScope
43 import androidx.compose.ui.layout.Measurable
44 import androidx.compose.ui.layout.MeasureResult
45 import androidx.compose.ui.layout.MeasureScope
46 import androidx.compose.ui.node.LayoutModifierNode
47 import androidx.compose.ui.node.ModifierNodeElement
48 import androidx.compose.ui.node.SemanticsModifierNode
49 import androidx.compose.ui.platform.InspectorInfo
50 import androidx.compose.ui.semantics.ScrollAxisRange
51 import androidx.compose.ui.semantics.SemanticsPropertyReceiver
52 import androidx.compose.ui.semantics.horizontalScrollAxisRange
53 import androidx.compose.ui.semantics.isTraversalGroup
54 import androidx.compose.ui.semantics.verticalScrollAxisRange
55 import androidx.compose.ui.unit.Constraints
56 import androidx.compose.ui.util.fastCoerceIn
57 import androidx.compose.ui.util.fastRoundToInt
58 
59 /**
60  * Create and [remember] the [ScrollState] based on the currently appropriate scroll configuration
61  * to allow changing scroll position or observing scroll behavior.
62  *
63  * Learn how to control the state of [Modifier.verticalScroll] or [Modifier.horizontalScroll]:
64  *
65  * @sample androidx.compose.foundation.samples.ControlledScrollableRowSample
66  * @param initial initial scroller position to start with
67  */
68 @Composable
rememberScrollStatenull69 fun rememberScrollState(initial: Int = 0): ScrollState {
70     return rememberSaveable(saver = ScrollState.Saver) { ScrollState(initial = initial) }
71 }
72 
73 /**
74  * State of the scroll. Allows the developer to change the scroll position or get current state by
75  * calling methods on this object. To be hosted and passed to [Modifier.verticalScroll] or
76  * [Modifier.horizontalScroll]
77  *
78  * To create and automatically remember [ScrollState] with default parameters use
79  * [rememberScrollState].
80  *
81  * Learn how to control the state of [Modifier.verticalScroll] or [Modifier.horizontalScroll]:
82  *
83  * @sample androidx.compose.foundation.samples.ControlledScrollableRowSample
84  * @param initial value of the scroll
85  */
86 @Stable
87 class ScrollState(initial: Int) : ScrollableState {
88 
89     /** current scroll position value in pixels */
90     var value: Int by mutableIntStateOf(initial)
91         private set
92 
93     /** maximum bound for [value], or [Int.MAX_VALUE] if still unknown */
94     var maxValue: Int
95         get() = _maxValueState.intValue
96         internal set(newMax) {
97             _maxValueState.intValue = newMax
<lambda>null98             Snapshot.withoutReadObservation {
99                 if (value > newMax) {
100                     value = newMax
101                 }
102             }
103         }
104 
105     /**
106      * Size of the viewport on the scrollable axis, or 0 if still unknown. Note that this value is
107      * only populated after the first measure pass.
108      */
109     var viewportSize: Int by mutableIntStateOf(0)
110         internal set
111 
112     /**
113      * [InteractionSource] that will be used to dispatch drag events when this list is being
114      * dragged. If you want to know whether the fling (or smooth scroll) is in progress, use
115      * [isScrollInProgress].
116      */
117     val interactionSource: InteractionSource
118         get() = internalInteractionSource
119 
120     internal val internalInteractionSource: MutableInteractionSource = MutableInteractionSource()
121 
122     private var _maxValueState = mutableIntStateOf(Int.MAX_VALUE)
123 
124     /**
125      * We receive scroll events in floats but represent the scroll position in ints so we have to
126      * manually accumulate the fractional part of the scroll to not completely ignore it.
127      */
128     private var accumulator: Float = 0f
129 
<lambda>null130     private val scrollableState = ScrollableState {
131         val absolute = (value + it + accumulator)
132         val newValue = absolute.coerceIn(0f, maxValue.toFloat())
133         val changed = absolute != newValue
134         val consumed = newValue - value
135         val consumedInt = consumed.fastRoundToInt()
136         value += consumedInt
137         accumulator = consumed - consumedInt
138 
139         // Avoid floating-point rounding error
140         if (changed) consumed else it
141     }
142 
scrollnull143     override suspend fun scroll(
144         scrollPriority: MutatePriority,
145         block: suspend ScrollScope.() -> Unit
146     ): Unit = scrollableState.scroll(scrollPriority, block)
147 
148     override fun dispatchRawDelta(delta: Float): Float = scrollableState.dispatchRawDelta(delta)
149 
150     override val isScrollInProgress: Boolean
151         get() = scrollableState.isScrollInProgress
152 
153     override val canScrollForward: Boolean by derivedStateOf { value < maxValue }
154 
<lambda>null155     override val canScrollBackward: Boolean by derivedStateOf { value > 0 }
156 
157     @get:Suppress("GetterSetterNames")
158     override val lastScrolledForward: Boolean
159         get() = scrollableState.lastScrolledForward
160 
161     @get:Suppress("GetterSetterNames")
162     override val lastScrolledBackward: Boolean
163         get() = scrollableState.lastScrolledBackward
164 
165     /**
166      * Scroll to position in pixels with animation.
167      *
168      * @param value target value in pixels to smooth scroll to, value will be coerced to
169      *   0..maxPosition
170      * @param animationSpec animation curve for smooth scroll animation
171      */
animateScrollTonull172     suspend fun animateScrollTo(value: Int, animationSpec: AnimationSpec<Float> = SpringSpec()) {
173         this.animateScrollBy((value - this.value).toFloat(), animationSpec)
174     }
175 
176     /**
177      * Instantly jump to the given position in pixels.
178      *
179      * Cancels the currently running scroll, if any, and suspends until the cancellation is
180      * complete.
181      *
182      * @param value number of pixels to scroll by
183      * @return the amount of scroll consumed
184      * @see animateScrollTo for an animated version
185      */
scrollTonull186     suspend fun scrollTo(value: Int): Float = this.scrollBy((value - this.value).toFloat())
187 
188     companion object {
189         /** The default [Saver] implementation for [ScrollState]. */
190         val Saver: Saver<ScrollState, *> = Saver(save = { it.value }, restore = { ScrollState(it) })
191     }
192 }
193 
194 /**
195  * Modify element to allow to scroll vertically when height of the content is bigger than max
196  * constraints allow.
197  *
198  * @sample androidx.compose.foundation.samples.VerticalScrollExample
199  *
200  * In order to use this modifier, you need to create and own [ScrollState]
201  *
202  * See the other overload in order to provide a custom [OverscrollEffect]
203  *
204  * @param state state of the scroll
205  * @param enabled whether or not scrolling via touch input is enabled
206  * @param flingBehavior logic describing fling behavior when drag has finished with velocity. If
207  *   `null`, default from [ScrollableDefaults.flingBehavior] will be used.
208  * @param reverseScrolling reverse the direction of scrolling, when `true`, 0 [ScrollState.value]
209  *   will mean bottom, when `false`, 0 [ScrollState.value] will mean top
210  * @see [rememberScrollState]
211  */
Modifiernull212 fun Modifier.verticalScroll(
213     state: ScrollState,
214     enabled: Boolean = true,
215     flingBehavior: FlingBehavior? = null,
216     reverseScrolling: Boolean = false
217 ) =
218     scroll(
219         state = state,
220         isScrollable = enabled,
221         reverseScrolling = reverseScrolling,
222         flingBehavior = flingBehavior,
223         isVertical = true,
224         useLocalOverscrollFactory = true
225     )
226 
227 /**
228  * Modify element to allow to scroll vertically when height of the content is bigger than max
229  * constraints allow.
230  *
231  * @sample androidx.compose.foundation.samples.VerticalScrollExample
232  *
233  * In order to use this modifier, you need to create and own [ScrollState]
234  *
235  * @param state state of the scroll
236  * @param overscrollEffect the [OverscrollEffect] that will be used to render overscroll for this
237  *   modifier. Note that the [OverscrollEffect.node] will be applied internally as well - you do not
238  *   need to use Modifier.overscroll separately.
239  * @param enabled whether or not scrolling via touch input is enabled
240  * @param flingBehavior logic describing fling behavior when drag has finished with velocity. If
241  *   `null`, default from [ScrollableDefaults.flingBehavior] will be used.
242  * @param reverseScrolling reverse the direction of scrolling, when `true`, 0 [ScrollState.value]
243  *   will mean bottom, when `false`, 0 [ScrollState.value] will mean top
244  * @see [rememberScrollState]
245  */
246 fun Modifier.verticalScroll(
247     state: ScrollState,
248     overscrollEffect: OverscrollEffect?,
249     enabled: Boolean = true,
250     flingBehavior: FlingBehavior? = null,
251     reverseScrolling: Boolean = false
252 ) =
253     scroll(
254         state = state,
255         isScrollable = enabled,
256         reverseScrolling = reverseScrolling,
257         flingBehavior = flingBehavior,
258         isVertical = true,
259         useLocalOverscrollFactory = false,
260         overscrollEffect = overscrollEffect
261     )
262 
263 /**
264  * Modify element to allow to scroll horizontally when width of the content is bigger than max
265  * constraints allow.
266  *
267  * @sample androidx.compose.foundation.samples.HorizontalScrollSample
268  *
269  * In order to use this modifier, you need to create and own [ScrollState]
270  *
271  * See the other overload in order to provide a custom [OverscrollEffect]
272  *
273  * @param state state of the scroll
274  * @param enabled whether or not scrolling via touch input is enabled
275  * @param flingBehavior logic describing fling behavior when drag has finished with velocity. If
276  *   `null`, default from [ScrollableDefaults.flingBehavior] will be used.
277  * @param reverseScrolling reverse the direction of scrolling, when `true`, 0 [ScrollState.value]
278  *   will mean right, when `false`, 0 [ScrollState.value] will mean left
279  * @see [rememberScrollState]
280  */
281 fun Modifier.horizontalScroll(
282     state: ScrollState,
283     enabled: Boolean = true,
284     flingBehavior: FlingBehavior? = null,
285     reverseScrolling: Boolean = false
286 ) =
287     scroll(
288         state = state,
289         isScrollable = enabled,
290         reverseScrolling = reverseScrolling,
291         flingBehavior = flingBehavior,
292         isVertical = false,
293         useLocalOverscrollFactory = true
294     )
295 
296 /**
297  * Modify element to allow to scroll horizontally when width of the content is bigger than max
298  * constraints allow.
299  *
300  * @sample androidx.compose.foundation.samples.HorizontalScrollSample
301  *
302  * In order to use this modifier, you need to create and own [ScrollState]
303  *
304  * @param state state of the scroll
305  * @param overscrollEffect the [OverscrollEffect] that will be used to render overscroll for this
306  *   modifier. Note that the [OverscrollEffect.node] will be applied internally as well - you do not
307  *   need to use Modifier.overscroll separately.
308  * @param enabled whether or not scrolling via touch input is enabled
309  * @param flingBehavior logic describing fling behavior when drag has finished with velocity. If
310  *   `null`, default from [ScrollableDefaults.flingBehavior] will be used.
311  * @param reverseScrolling reverse the direction of scrolling, when `true`, 0 [ScrollState.value]
312  *   will mean right, when `false`, 0 [ScrollState.value] will mean left
313  * @see [rememberScrollState]
314  */
315 fun Modifier.horizontalScroll(
316     state: ScrollState,
317     overscrollEffect: OverscrollEffect?,
318     enabled: Boolean = true,
319     flingBehavior: FlingBehavior? = null,
320     reverseScrolling: Boolean = false
321 ) =
322     scroll(
323         state = state,
324         isScrollable = enabled,
325         reverseScrolling = reverseScrolling,
326         flingBehavior = flingBehavior,
327         isVertical = false,
328         useLocalOverscrollFactory = false,
329         overscrollEffect = overscrollEffect
330     )
331 
332 private fun Modifier.scroll(
333     state: ScrollState,
334     reverseScrolling: Boolean,
335     flingBehavior: FlingBehavior?,
336     isScrollable: Boolean,
337     isVertical: Boolean,
338     useLocalOverscrollFactory: Boolean,
339     overscrollEffect: OverscrollEffect? = null
340 ): Modifier {
341     val orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal
342     return scrollingContainer(
343             state = state,
344             orientation = orientation,
345             enabled = isScrollable,
346             reverseScrolling = reverseScrolling,
347             flingBehavior = flingBehavior,
348             interactionSource = state.internalInteractionSource,
349             useLocalOverscrollFactory = useLocalOverscrollFactory,
350             overscrollEffect = overscrollEffect
351         )
352         .then(ScrollingLayoutElement(state, reverseScrolling, isVertical))
353 }
354 
355 internal class ScrollingLayoutElement(
356     val scrollState: ScrollState,
357     val reverseScrolling: Boolean,
358     val isVertical: Boolean
359 ) : ModifierNodeElement<ScrollNode>() {
createnull360     override fun create(): ScrollNode {
361         return ScrollNode(
362             state = scrollState,
363             reverseScrolling = reverseScrolling,
364             isVertical = isVertical
365         )
366     }
367 
updatenull368     override fun update(node: ScrollNode) {
369         node.state = scrollState
370         node.reverseScrolling = reverseScrolling
371         node.isVertical = isVertical
372     }
373 
hashCodenull374     override fun hashCode(): Int {
375         var result = scrollState.hashCode()
376         result = 31 * result + reverseScrolling.hashCode()
377         result = 31 * result + isVertical.hashCode()
378         return result
379     }
380 
equalsnull381     override fun equals(other: Any?): Boolean {
382         if (other !is ScrollingLayoutElement) return false
383         return scrollState == other.scrollState &&
384             reverseScrolling == other.reverseScrolling &&
385             isVertical == other.isVertical
386     }
387 
inspectablePropertiesnull388     override fun InspectorInfo.inspectableProperties() {
389         name = "scroll"
390         properties["state"] = scrollState
391         properties["reverseScrolling"] = reverseScrolling
392         properties["isVertical"] = isVertical
393     }
394 }
395 
396 internal class ScrollNode(
397     var state: ScrollState,
398     var reverseScrolling: Boolean,
399     var isVertical: Boolean
400 ) : LayoutModifierNode, SemanticsModifierNode, Modifier.Node() {
measurenull401     override fun MeasureScope.measure(
402         measurable: Measurable,
403         constraints: Constraints
404     ): MeasureResult {
405         checkScrollableContainerConstraints(
406             constraints,
407             if (isVertical) Orientation.Vertical else Orientation.Horizontal
408         )
409 
410         val childConstraints =
411             constraints.copy(
412                 maxHeight = if (isVertical) Constraints.Infinity else constraints.maxHeight,
413                 maxWidth = if (isVertical) constraints.maxWidth else Constraints.Infinity
414             )
415         val placeable = measurable.measure(childConstraints)
416         val width = placeable.width.coerceAtMost(constraints.maxWidth)
417         val height = placeable.height.coerceAtMost(constraints.maxHeight)
418         val scrollHeight = placeable.height - height
419         val scrollWidth = placeable.width - width
420         val side = if (isVertical) scrollHeight else scrollWidth
421         // The max value must be updated before returning from the measure block so that any other
422         // chained RemeasurementModifiers that try to perform scrolling based on the new
423         // measurements inside onRemeasured are able to scroll to the new max based on the newly-
424         // measured size.
425         state.maxValue = side
426         state.viewportSize = if (isVertical) height else width
427         return layout(width, height) {
428             val scroll = state.value.fastCoerceIn(0, side)
429             val absScroll = if (reverseScrolling) scroll - side else -scroll
430             val xOffset = if (isVertical) 0 else absScroll
431             val yOffset = if (isVertical) absScroll else 0
432 
433             // Tagging as direct manipulation, such that consumers of this offset can decide whether
434             // to exclude this offset on their coordinates calculation. Such as whether an
435             // `approachLayout` will animate it or directly apply the offset without animation.
436             withMotionFrameOfReferencePlacement {
437                 placeable.placeRelativeWithLayer(xOffset, yOffset)
438             }
439         }
440     }
441 
minIntrinsicWidthnull442     override fun IntrinsicMeasureScope.minIntrinsicWidth(
443         measurable: IntrinsicMeasurable,
444         height: Int
445     ): Int {
446         return measurable.minIntrinsicWidth(if (isVertical) Constraints.Infinity else height)
447     }
448 
minIntrinsicHeightnull449     override fun IntrinsicMeasureScope.minIntrinsicHeight(
450         measurable: IntrinsicMeasurable,
451         width: Int
452     ): Int {
453         return measurable.minIntrinsicHeight(if (isVertical) width else Constraints.Infinity)
454     }
455 
maxIntrinsicWidthnull456     override fun IntrinsicMeasureScope.maxIntrinsicWidth(
457         measurable: IntrinsicMeasurable,
458         height: Int
459     ): Int {
460         return measurable.maxIntrinsicWidth(if (isVertical) Constraints.Infinity else height)
461     }
462 
maxIntrinsicHeightnull463     override fun IntrinsicMeasureScope.maxIntrinsicHeight(
464         measurable: IntrinsicMeasurable,
465         width: Int
466     ): Int {
467         return measurable.maxIntrinsicHeight(if (isVertical) width else Constraints.Infinity)
468     }
469 
applySemanticsnull470     override fun SemanticsPropertyReceiver.applySemantics() {
471         isTraversalGroup = true
472         val accessibilityScrollState =
473             ScrollAxisRange(
474                 value = { state.value.toFloat() },
475                 maxValue = { state.maxValue.toFloat() },
476                 reverseScrolling = reverseScrolling
477             )
478         if (isVertical) {
479             this.verticalScrollAxisRange = accessibilityScrollState
480         } else {
481             this.horizontalScrollAxisRange = accessibilityScrollState
482         }
483     }
484 }
485