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