1 /*
<lambda>null2 * Copyright 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 androidx.compose.foundation.pager
18
19 import androidx.annotation.FloatRange
20 import androidx.annotation.IntRange as AndroidXIntRange
21 import androidx.compose.animation.core.AnimationSpec
22 import androidx.compose.animation.core.animate
23 import androidx.compose.animation.core.spring
24 import androidx.compose.foundation.ExperimentalFoundationApi
25 import androidx.compose.foundation.MutatePriority
26 import androidx.compose.foundation.gestures.Orientation
27 import androidx.compose.foundation.gestures.ScrollScope
28 import androidx.compose.foundation.gestures.ScrollableState
29 import androidx.compose.foundation.gestures.snapping.SnapPosition
30 import androidx.compose.foundation.gestures.stopScroll
31 import androidx.compose.foundation.interaction.InteractionSource
32 import androidx.compose.foundation.interaction.MutableInteractionSource
33 import androidx.compose.foundation.internal.requirePrecondition
34 import androidx.compose.foundation.lazy.layout.AwaitFirstLayoutModifier
35 import androidx.compose.foundation.lazy.layout.LazyLayoutBeyondBoundsInfo
36 import androidx.compose.foundation.lazy.layout.LazyLayoutPinnedItemList
37 import androidx.compose.foundation.lazy.layout.LazyLayoutPrefetchState
38 import androidx.compose.foundation.lazy.layout.LazyLayoutScrollScope
39 import androidx.compose.foundation.lazy.layout.ObservableScopeInvalidator
40 import androidx.compose.foundation.lazy.layout.PrefetchScheduler
41 import androidx.compose.runtime.Composable
42 import androidx.compose.runtime.Stable
43 import androidx.compose.runtime.derivedStateOf
44 import androidx.compose.runtime.getValue
45 import androidx.compose.runtime.mutableIntStateOf
46 import androidx.compose.runtime.mutableStateOf
47 import androidx.compose.runtime.neverEqualPolicy
48 import androidx.compose.runtime.saveable.Saver
49 import androidx.compose.runtime.saveable.listSaver
50 import androidx.compose.runtime.saveable.rememberSaveable
51 import androidx.compose.runtime.setValue
52 import androidx.compose.runtime.snapshots.Snapshot
53 import androidx.compose.runtime.structuralEqualityPolicy
54 import androidx.compose.ui.geometry.Offset
55 import androidx.compose.ui.layout.AlignmentLine
56 import androidx.compose.ui.layout.MeasureResult
57 import androidx.compose.ui.layout.Remeasurement
58 import androidx.compose.ui.layout.RemeasurementModifier
59 import androidx.compose.ui.unit.Constraints
60 import androidx.compose.ui.unit.Density
61 import androidx.compose.ui.unit.dp
62 import kotlin.coroutines.EmptyCoroutineContext
63 import kotlin.math.abs
64 import kotlin.math.absoluteValue
65 import kotlin.math.roundToLong
66 import kotlin.math.sign
67 import kotlin.ranges.IntRange
68 import kotlinx.coroutines.CoroutineScope
69 import kotlinx.coroutines.launch
70
71 /**
72 * Creates and remember a [PagerState] to be used with a [Pager]
73 *
74 * Please refer to the sample to learn how to use this API.
75 *
76 * @sample androidx.compose.foundation.samples.PagerWithStateSample
77 * @param initialPage The pager that should be shown first.
78 * @param initialPageOffsetFraction The offset of the initial page as a fraction of the page size.
79 * This should vary between -0.5 and 0.5 and indicates how to offset the initial page from the
80 * snapped position.
81 * @param pageCount The amount of pages this Pager will have.
82 */
83 @Composable
84 fun rememberPagerState(
85 initialPage: Int = 0,
86 @FloatRange(from = -0.5, to = 0.5) initialPageOffsetFraction: Float = 0f,
87 pageCount: () -> Int
88 ): PagerState {
89 return rememberSaveable(saver = DefaultPagerState.Saver) {
90 DefaultPagerState(initialPage, initialPageOffsetFraction, pageCount)
91 }
92 .apply { pageCountState.value = pageCount }
93 }
94
95 /**
96 * Creates a default [PagerState] to be used with a [Pager]
97 *
98 * Please refer to the sample to learn how to use this API.
99 *
100 * @sample androidx.compose.foundation.samples.PagerWithStateSample
101 * @param currentPage The pager that should be shown first.
102 * @param currentPageOffsetFraction The offset of the initial page as a fraction of the page size.
103 * This should vary between -0.5 and 0.5 and indicates how to offset the initial page from the
104 * snapped position.
105 * @param pageCount The amount of pages this Pager will have.
106 */
PagerStatenull107 fun PagerState(
108 currentPage: Int = 0,
109 @FloatRange(from = -0.5, to = 0.5) currentPageOffsetFraction: Float = 0f,
110 pageCount: () -> Int
111 ): PagerState = DefaultPagerState(currentPage, currentPageOffsetFraction, pageCount)
112
113 private class DefaultPagerState(
114 currentPage: Int,
115 currentPageOffsetFraction: Float,
116 updatedPageCount: () -> Int
117 ) : PagerState(currentPage, currentPageOffsetFraction) {
118
119 var pageCountState = mutableStateOf(updatedPageCount)
120 override val pageCount: Int
121 get() = pageCountState.value.invoke()
122
123 companion object {
124 /** To keep current page and current page offset saved */
125 val Saver: Saver<DefaultPagerState, *> =
126 listSaver(
127 save = {
128 listOf(
129 it.currentPage,
130 (it.currentPageOffsetFraction).coerceIn(MinPageOffset, MaxPageOffset),
131 it.pageCount
132 )
133 },
134 restore = {
135 DefaultPagerState(
136 currentPage = it[0] as Int,
137 currentPageOffsetFraction = it[1] as Float,
138 updatedPageCount = { it[2] as Int }
139 )
140 }
141 )
142 }
143 }
144
145 /** The state that can be used to control [VerticalPager] and [HorizontalPager] */
146 @OptIn(ExperimentalFoundationApi::class)
147 @Stable
148 abstract class PagerState
149 internal constructor(
150 currentPage: Int = 0,
151 @FloatRange(from = -0.5, to = 0.5) currentPageOffsetFraction: Float = 0f,
152 prefetchScheduler: PrefetchScheduler? = null
153 ) : ScrollableState {
154
155 /**
156 * @param currentPage The initial page to be displayed
157 * @param currentPageOffsetFraction The offset of the initial page with respect to the start of
158 * the layout.
159 */
160 constructor(
161 currentPage: Int = 0,
162 @FloatRange(from = -0.5, to = 0.5) currentPageOffsetFraction: Float = 0f
163 ) : this(currentPage, currentPageOffsetFraction, null)
164
165 internal var hasLookaheadOccurred: Boolean = false
166 private set
167
168 internal var approachLayoutInfo: PagerMeasureResult? = null
169 private set
170
171 /**
172 * The total amount of pages present in this pager. The source of this data should be
173 * observable.
174 */
175 abstract val pageCount: Int
176
177 init {
<lambda>null178 requirePrecondition(currentPageOffsetFraction in -0.5..0.5) {
179 "currentPageOffsetFraction $currentPageOffsetFraction is " +
180 "not within the range -0.5 to 0.5"
181 }
182 }
183
184 /** Difference between the last up and last down events of a scroll event. */
185 internal var upDownDifference: Offset by mutableStateOf(Offset.Zero)
186
187 private val scrollPosition = PagerScrollPosition(currentPage, currentPageOffsetFraction, this)
188
189 internal var firstVisiblePage = currentPage
190 private set
191
192 internal var firstVisiblePageOffset = 0
193 private set
194
195 internal var maxScrollOffset: Long = Long.MAX_VALUE
196
197 internal var minScrollOffset: Long = 0L
198
199 private var accumulator: Float = 0.0f
200
201 /**
202 * The prefetch will act after the measure pass has finished and it needs to know the magnitude
203 * and direction of the scroll that triggered the measure pass
204 */
205 private var previousPassDelta = 0f
206
207 /**
208 * The ScrollableController instance. We keep it as we need to call stopAnimation on it once we
209 * reached the end of the list.
210 */
<lambda>null211 private val scrollableState = ScrollableState { performScroll(it) }
212
213 /**
214 * Within the scrolling context we can use absolute positions to determine scroll deltas and max
215 * min scrolling.
216 */
performScrollnull217 private fun performScroll(delta: Float): Float {
218 val currentScrollPosition = currentAbsoluteScrollOffset()
219 debugLog {
220 "\nDelta=$delta " +
221 "\ncurrentScrollPosition=$currentScrollPosition " +
222 "\naccumulator=$accumulator" +
223 "\nmaxScrollOffset=$maxScrollOffset"
224 }
225
226 val decimalAccumulation = (delta + accumulator)
227 val decimalAccumulationInt = decimalAccumulation.roundToLong()
228 accumulator = decimalAccumulation - decimalAccumulationInt
229
230 // nothing to scroll
231 if (delta.absoluteValue < 1e-4f) return delta
232
233 /**
234 * The updated scroll position is the current position with the integer part of the delta
235 * and accumulator applied.
236 */
237 val updatedScrollPosition = (currentScrollPosition + decimalAccumulationInt)
238
239 /** Check if the scroll position may be larger than the maximum possible scroll. */
240 val coercedScrollPosition = updatedScrollPosition.coerceIn(minScrollOffset, maxScrollOffset)
241
242 /** Check if we actually coerced. */
243 val changed = updatedScrollPosition != coercedScrollPosition
244
245 /** Calculated the actual scroll delta to be applied */
246 val scrollDelta = coercedScrollPosition - currentScrollPosition
247
248 previousPassDelta = scrollDelta.toFloat()
249
250 if (scrollDelta.absoluteValue != 0L) {
251 isLastScrollForwardState.value = scrollDelta > 0.0f
252 isLastScrollBackwardState.value = scrollDelta < 0.0f
253 }
254
255 /** Apply the scroll delta */
256 var scrolledLayoutInfo =
257 pagerLayoutInfoState.value.copyWithScrollDeltaWithoutRemeasure(
258 delta = -scrollDelta.toInt()
259 )
260 if (scrolledLayoutInfo != null && this.approachLayoutInfo != null) {
261 // if we were able to scroll the lookahead layout info without remeasure, lets
262 // try to do the same for post lookahead layout info (sometimes they diverge).
263 val scrolledApproachLayoutInfo =
264 approachLayoutInfo?.copyWithScrollDeltaWithoutRemeasure(
265 delta = -scrollDelta.toInt(),
266 )
267 if (scrolledApproachLayoutInfo != null) {
268 // we can apply scroll delta for both phases without remeasure
269 approachLayoutInfo = scrolledApproachLayoutInfo
270 } else {
271 // we can't apply scroll delta for post lookahead, so we have to remeasure
272 scrolledLayoutInfo = null
273 }
274 }
275 if (scrolledLayoutInfo != null) {
276 debugLog { "Will Apply Without Remeasure" }
277 applyMeasureResult(
278 result = scrolledLayoutInfo,
279 isLookingAhead = hasLookaheadOccurred,
280 visibleItemsStayedTheSame = true
281 )
282 // we don't need to remeasure, so we only trigger re-placement:
283 placementScopeInvalidator.invalidateScope()
284 layoutWithoutMeasurement++
285 } else {
286 debugLog { "Will Apply With Remeasure" }
287 scrollPosition.applyScrollDelta(scrollDelta.toInt())
288 remeasurement?.forceRemeasure()
289 layoutWithMeasurement++
290 }
291
292 // Return the consumed value.
293 return (if (changed) scrollDelta else delta).toFloat()
294 }
295
296 /** Only used for testing to confirm that we're not making too many measure passes */
297 internal val numMeasurePasses: Int
298 get() = layoutWithMeasurement + layoutWithoutMeasurement
299
300 internal var layoutWithMeasurement: Int = 0
301 private set
302
303 private var layoutWithoutMeasurement: Int = 0
304
305 /** Only used for testing to disable prefetching when needed to test the main logic. */
306 internal var prefetchingEnabled: Boolean = true
307
308 /**
309 * The index scheduled to be prefetched (or the last prefetched index if the prefetch is done).
310 */
311 private var indexToPrefetch = -1
312
313 /** The handle associated with the current index from [indexToPrefetch]. */
314 private var currentPrefetchHandle: LazyLayoutPrefetchState.PrefetchHandle? = null
315
316 /**
317 * Keeps the scrolling direction during the previous calculation in order to be able to detect
318 * the scrolling direction change.
319 */
320 private var wasPrefetchingForward = false
321
322 /** Backing state for PagerLayoutInfo */
323 private var pagerLayoutInfoState = mutableStateOf(EmptyLayoutInfo, neverEqualPolicy())
324
325 /**
326 * A [PagerLayoutInfo] that contains useful information about the Pager's last layout pass. For
327 * instance, you can query which pages are currently visible in the layout.
328 *
329 * This property is observable and is updated after every scroll or remeasure. If you use it in
330 * the composable function it will be recomposed on every change causing potential performance
331 * issues including infinity recomposition loop. Therefore, avoid using it in the composition.
332 *
333 * If you want to run some side effects like sending an analytics event or updating a state
334 * based on this value consider using "snapshotFlow":
335 *
336 * @sample androidx.compose.foundation.samples.UsingPagerLayoutInfoForSideEffectSample
337 */
338 val layoutInfo: PagerLayoutInfo
339 get() = pagerLayoutInfoState.value
340
341 internal val pageSpacing: Int
342 get() = pagerLayoutInfoState.value.pageSpacing
343
344 internal val pageSize: Int
345 get() = pagerLayoutInfoState.value.pageSize
346
347 internal var density: Density = UnitDensity
348
349 internal val pageSizeWithSpacing: Int
350 get() = pageSize + pageSpacing
351
352 /**
353 * How far the current page needs to scroll so the target page is considered to be the next
354 * page.
355 */
356 internal val positionThresholdFraction: Float
357 get() =
<lambda>null358 with(density) {
359 val minThreshold = minOf(DefaultPositionThreshold.toPx(), pageSize / 2f)
360 minThreshold / pageSize.toFloat()
361 }
362
363 internal val internalInteractionSource: MutableInteractionSource = MutableInteractionSource()
364
365 /**
366 * [InteractionSource] that will be used to dispatch drag events when this list is being
367 * dragged. If you want to know whether the fling (or animated scroll) is in progress, use
368 * [isScrollInProgress].
369 */
370 val interactionSource: InteractionSource
371 get() = internalInteractionSource
372
373 /**
374 * The page that sits closest to the snapped position. This is an observable value and will
375 * change as the pager scrolls either by gesture or animation.
376 *
377 * Please refer to the sample to learn how to use this API.
378 *
379 * @sample androidx.compose.foundation.samples.ObservingStateChangesInPagerStateSample
380 */
381 val currentPage: Int
382 get() = scrollPosition.currentPage
383
384 private var programmaticScrollTargetPage by mutableIntStateOf(-1)
385
386 private var settledPageState by mutableIntStateOf(currentPage)
387
388 /**
389 * The page that is currently "settled". This is an animation/gesture unaware page in the sense
390 * that it will not be updated while the pages are being scrolled, but rather when the
391 * animation/scroll settles.
392 *
393 * Please refer to the sample to learn how to use this API.
394 *
395 * @sample androidx.compose.foundation.samples.ObservingStateChangesInPagerStateSample
396 */
397 val settledPage by
<lambda>null398 derivedStateOf(structuralEqualityPolicy()) {
399 if (isScrollInProgress) {
400 settledPageState
401 } else {
402 this.currentPage
403 }
404 }
405
406 /**
407 * The page this [Pager] intends to settle to. During fling or animated scroll (from
408 * [animateScrollToPage] this will represent the page this pager intends to settle to. When no
409 * scroll is ongoing, this will be equal to [currentPage].
410 *
411 * Please refer to the sample to learn how to use this API.
412 *
413 * @sample androidx.compose.foundation.samples.ObservingStateChangesInPagerStateSample
414 */
415 val targetPage: Int by
<lambda>null416 derivedStateOf(structuralEqualityPolicy()) {
417 val finalPage =
418 if (!isScrollInProgress) {
419 this.currentPage
420 } else if (programmaticScrollTargetPage != -1) {
421 programmaticScrollTargetPage
422 } else {
423 // act on scroll only
424 if (abs(this.currentPageOffsetFraction) >= abs(positionThresholdFraction)) {
425 if (lastScrolledForward) {
426 firstVisiblePage + 1
427 } else {
428 firstVisiblePage
429 }
430 } else {
431 this.currentPage
432 }
433 }
434 finalPage.coerceInPageRange()
435 }
436
437 /**
438 * Indicates how far the current page is to the snapped position, this will vary from -0.5 (page
439 * is offset towards the start of the layout) to 0.5 (page is offset towards the end of the
440 * layout). This is 0.0 if the [currentPage] is in the snapped position. The value will flip
441 * once the current page changes.
442 *
443 * This property is observable and shouldn't be used as is in a composable function due to
444 * potential performance issues. To use it in the composition, please consider using a derived
445 * state (e.g [derivedStateOf]) to only have recompositions when the derived value changes.
446 *
447 * Please refer to the sample to learn how to use this API.
448 *
449 * @sample androidx.compose.foundation.samples.ObservingStateChangesInPagerStateSample
450 */
451 val currentPageOffsetFraction: Float
452 get() = scrollPosition.currentPageOffsetFraction
453
454 internal val prefetchState =
<lambda>null455 LazyLayoutPrefetchState(prefetchScheduler) {
456 Snapshot.withoutReadObservation { schedulePrecomposition(firstVisiblePage) }
457 }
458
459 internal val beyondBoundsInfo = LazyLayoutBeyondBoundsInfo()
460
461 /**
462 * Provides a modifier which allows to delay some interactions (e.g. scroll) until layout is
463 * ready.
464 */
465 internal val awaitLayoutModifier = AwaitFirstLayoutModifier()
466
467 /**
468 * The [Remeasurement] object associated with our layout. It allows us to remeasure
469 * synchronously during scroll.
470 */
471 internal var remeasurement: Remeasurement? by mutableStateOf(null)
472 private set
473
474 /** The modifier which provides [remeasurement]. */
475 internal val remeasurementModifier =
476 object : RemeasurementModifier {
onRemeasurementAvailablenull477 override fun onRemeasurementAvailable(remeasurement: Remeasurement) {
478 this@PagerState.remeasurement = remeasurement
479 }
480 }
481
482 /** Constraints passed to the prefetcher for premeasuring the prefetched items. */
483 internal var premeasureConstraints = Constraints()
484
485 /** Stores currently pinned pages which are always composed, used by for beyond bound pages. */
486 internal val pinnedPages = LazyLayoutPinnedItemList()
487
488 internal val nearestRange: IntRange by scrollPosition.nearestRangeState
489
490 internal val placementScopeInvalidator = ObservableScopeInvalidator()
491
492 /**
493 * Scroll (jump immediately) to a given [page].
494 *
495 * Please refer to the sample to learn how to use this API.
496 *
497 * @sample androidx.compose.foundation.samples.ScrollToPageSample
498 * @param page The destination page to scroll to
499 * @param pageOffsetFraction A fraction of the page size that indicates the offset the
500 * destination page will be offset from its snapped position.
501 */
scrollToPagenull502 suspend fun scrollToPage(
503 page: Int,
504 @FloatRange(from = -0.5, to = 0.5) pageOffsetFraction: Float = 0f
505 ) = scroll {
506 debugLog { "Scroll from page=$currentPage to page=$page" }
507 awaitScrollDependencies()
508 requirePrecondition(pageOffsetFraction in -0.5..0.5) {
509 "pageOffsetFraction $pageOffsetFraction is not within the range -0.5 to 0.5"
510 }
511 val targetPage = page.coerceInPageRange()
512 snapToItem(targetPage, pageOffsetFraction, forceRemeasure = true)
513 }
514
515 /**
516 * Jump immediately to a given [page] with a given [pageOffsetFraction] inside a [ScrollScope].
517 * Use this method to create custom animated scrolling experiences. This will update the value
518 * of [currentPage] and [currentPageOffsetFraction] immediately, but can only be used inside a
519 * [ScrollScope], use [scroll] to gain access to a [ScrollScope].
520 *
521 * Please refer to the sample to learn how to use this API.
522 *
523 * @sample androidx.compose.foundation.samples.PagerCustomAnimateScrollToPage
524 * @param page The destination page to scroll to
525 * @param pageOffsetFraction A fraction of the page size that indicates the offset the
526 * destination page will be offset from its snapped position.
527 */
ScrollScopenull528 fun ScrollScope.updateCurrentPage(
529 page: Int,
530 @FloatRange(from = -0.5, to = 0.5) pageOffsetFraction: Float = 0.0f
531 ) {
532 snapToItem(page, pageOffsetFraction, forceRemeasure = true)
533 }
534
535 /**
536 * Used to update [targetPage] during a programmatic scroll operation. This can only be called
537 * inside a [ScrollScope] and should be called anytime a custom scroll (through [scroll]) is
538 * executed in order to correctly update [targetPage]. This will not move the pages and it's
539 * still the responsibility of the caller to call [ScrollScope.scrollBy] in order to actually
540 * get to [targetPage]. By the end of the [scroll] block, when the [Pager] is no longer
541 * scrolling [targetPage] will assume the value of [currentPage].
542 *
543 * Please refer to the sample to learn how to use this API.
544 *
545 * @sample androidx.compose.foundation.samples.PagerCustomAnimateScrollToPage
546 */
ScrollScopenull547 fun ScrollScope.updateTargetPage(targetPage: Int) {
548 programmaticScrollTargetPage = targetPage.coerceInPageRange()
549 }
550
snapToItemnull551 internal fun snapToItem(page: Int, offsetFraction: Float, forceRemeasure: Boolean) {
552 scrollPosition.requestPositionAndForgetLastKnownKey(page, offsetFraction)
553 if (forceRemeasure) {
554 remeasurement?.forceRemeasure()
555 } else {
556 measurementScopeInvalidator.invalidateScope()
557 }
558 }
559
560 internal val measurementScopeInvalidator = ObservableScopeInvalidator()
561
562 /**
563 * Requests the [page] to be at the snapped position during the next remeasure, offset by
564 * [pageOffsetFraction], and schedules a remeasure.
565 *
566 * The scroll position will be updated to the requested position rather than maintain the index
567 * based on the current page key (when a data set change will also be applied during the next
568 * remeasure), but *only* for the next remeasure.
569 *
570 * Any scroll in progress will be cancelled.
571 *
572 * @param page the index to which to scroll. Must be non-negative.
573 * @param pageOffsetFraction the offset fraction that the page should end up after the scroll.
574 */
requestScrollToPagenull575 fun requestScrollToPage(
576 @AndroidXIntRange(from = 0) page: Int,
577 @FloatRange(from = -0.5, to = 0.5) pageOffsetFraction: Float = 0.0f
578 ) {
579 // Cancel any scroll in progress.
580 if (isScrollInProgress) {
581 pagerLayoutInfoState.value.coroutineScope.launch { stopScroll() }
582 }
583
584 snapToItem(page, pageOffsetFraction, forceRemeasure = false)
585 }
586
587 /**
588 * Scroll animate to a given [page]. If the [page] is too far away from [currentPage] we will
589 * not compose all pages in the way. We will pre-jump to a nearer page, compose and animate the
590 * rest of the pages until [page].
591 *
592 * Please refer to the sample to learn how to use this API.
593 *
594 * @sample androidx.compose.foundation.samples.AnimateScrollPageSample
595 * @param page The destination page to scroll to
596 * @param pageOffsetFraction A fraction of the page size that indicates the offset the
597 * destination page will be offset from its snapped position.
598 * @param animationSpec An [AnimationSpec] to move between pages. We'll use a [spring] as the
599 * default animation.
600 */
animateScrollToPagenull601 suspend fun animateScrollToPage(
602 page: Int,
603 @FloatRange(from = -0.5, to = 0.5) pageOffsetFraction: Float = 0f,
604 animationSpec: AnimationSpec<Float> = spring()
605 ) {
606 if (
607 page == currentPage && currentPageOffsetFraction == pageOffsetFraction || pageCount == 0
608 )
609 return
610 awaitScrollDependencies()
611 requirePrecondition(pageOffsetFraction in -0.5..0.5) {
612 "pageOffsetFraction $pageOffsetFraction is not within the range -0.5 to 0.5"
613 }
614 val targetPage = page.coerceInPageRange()
615 val targetPageOffsetToSnappedPosition = (pageOffsetFraction * pageSizeWithSpacing)
616
617 scroll {
618 LazyLayoutScrollScope(this@PagerState, this)
619 .animateScrollToPage(
620 targetPage,
621 targetPageOffsetToSnappedPosition,
622 animationSpec,
623 updateTargetPage = { updateTargetPage(it) }
624 )
625 }
626 }
627
awaitScrollDependenciesnull628 private suspend fun awaitScrollDependencies() {
629 awaitLayoutModifier.waitForFirstLayout()
630 }
631
scrollnull632 override suspend fun scroll(
633 scrollPriority: MutatePriority,
634 block: suspend ScrollScope.() -> Unit
635 ) {
636 awaitScrollDependencies()
637 // will scroll and it's not scrolling already update settled page
638 if (!isScrollInProgress) {
639 settledPageState = currentPage
640 }
641 scrollableState.scroll(scrollPriority, block)
642 programmaticScrollTargetPage = -1 // reset animated scroll target page indicator
643 }
644
dispatchRawDeltanull645 override fun dispatchRawDelta(delta: Float): Float {
646 return scrollableState.dispatchRawDelta(delta)
647 }
648
649 override val isScrollInProgress: Boolean
650 get() = scrollableState.isScrollInProgress
651
652 final override var canScrollForward: Boolean by mutableStateOf(false)
653 private set
654
655 final override var canScrollBackward: Boolean by mutableStateOf(false)
656 private set
657
658 private val isLastScrollForwardState = mutableStateOf(false)
659 private val isLastScrollBackwardState = mutableStateOf(false)
660
661 @get:Suppress("GetterSetterNames")
662 override val lastScrolledForward: Boolean
663 get() = isLastScrollForwardState.value
664
665 @get:Suppress("GetterSetterNames")
666 override val lastScrolledBackward: Boolean
667 get() = isLastScrollBackwardState.value
668
669 /** Updates the state with the new calculated scroll position and consumed scroll. */
applyMeasureResultnull670 internal fun applyMeasureResult(
671 result: PagerMeasureResult,
672 isLookingAhead: Boolean,
673 visibleItemsStayedTheSame: Boolean = false
674 ) {
675 // update the prefetch state with the number of nested prefetch items this layout
676 // should use.
677 prefetchState.idealNestedPrefetchCount = result.visiblePagesInfo.size
678
679 if (!isLookingAhead && hasLookaheadOccurred) {
680 debugLog { "Applying Approach Measure Result" }
681 // If there was already a lookahead pass, record this result as Approach result
682 approachLayoutInfo = result
683 } else {
684 debugLog { "Applying Measure Result" }
685 if (isLookingAhead) {
686 hasLookaheadOccurred = true
687 }
688 if (visibleItemsStayedTheSame) {
689 scrollPosition.updateCurrentPageOffsetFraction(result.currentPageOffsetFraction)
690 } else {
691 scrollPosition.updateFromMeasureResult(result)
692 cancelPrefetchIfVisibleItemsChanged(result)
693 }
694 pagerLayoutInfoState.value = result
695 canScrollForward = result.canScrollForward
696 canScrollBackward = result.canScrollBackward
697 result.firstVisiblePage?.let { firstVisiblePage = it.index }
698 firstVisiblePageOffset = result.firstVisiblePageScrollOffset
699 tryRunPrefetch(result)
700 maxScrollOffset = result.calculateNewMaxScrollOffset(pageCount)
701 minScrollOffset = result.calculateNewMinScrollOffset(pageCount)
702 debugLog {
703 "Finished Applying Measure Result" + "\nNew maxScrollOffset=$maxScrollOffset"
704 }
705 }
706 }
707
tryRunPrefetchnull708 private fun tryRunPrefetch(result: PagerMeasureResult) =
709 Snapshot.withoutReadObservation {
710 if (abs(previousPassDelta) > 0.5f) {
711 if (prefetchingEnabled && isGestureActionMatchesScroll(previousPassDelta)) {
712 notifyPrefetch(previousPassDelta, result)
713 }
714 }
715 }
716
coerceInPageRangenull717 private fun Int.coerceInPageRange() =
718 if (pageCount > 0) {
719 coerceIn(0, pageCount - 1)
720 } else {
721 0
722 }
723
724 // check if the scrolling will be a result of a fling operation. That is, if the scrolling
725 // direction is in the opposite direction of the gesture movement. Also, return true if there
726 // is no applied gesture that causes the scrolling
isGestureActionMatchesScrollnull727 private fun isGestureActionMatchesScroll(scrollDelta: Float): Boolean =
728 if (layoutInfo.orientation == Orientation.Vertical) {
729 sign(scrollDelta) == sign(-upDownDifference.y)
730 } else {
731 sign(scrollDelta) == sign(-upDownDifference.x)
732 } || isNotGestureAction()
733
isNotGestureActionnull734 internal fun isNotGestureAction(): Boolean =
735 upDownDifference.x.toInt() == 0 && upDownDifference.y.toInt() == 0
736
737 private fun notifyPrefetch(delta: Float, info: PagerLayoutInfo) {
738 if (!prefetchingEnabled) {
739 return
740 }
741
742 if (info.visiblePagesInfo.isNotEmpty()) {
743 val isPrefetchingForward = delta > 0
744 val indexToPrefetch =
745 if (isPrefetchingForward) {
746 info.visiblePagesInfo.last().index +
747 info.beyondViewportPageCount +
748 PagesToPrefetch
749 } else {
750 info.visiblePagesInfo.first().index -
751 info.beyondViewportPageCount -
752 PagesToPrefetch
753 }
754 if (indexToPrefetch in 0 until pageCount) {
755 if (indexToPrefetch != this.indexToPrefetch) {
756 if (wasPrefetchingForward != isPrefetchingForward) {
757 // the scrolling direction has been changed which means the last prefetched
758 // is not going to be reached anytime soon so it is safer to dispose it.
759 // if this item is already visible it is safe to call the method anyway
760 // as it will be no-op
761 currentPrefetchHandle?.cancel()
762 }
763 this.wasPrefetchingForward = isPrefetchingForward
764 this.indexToPrefetch = indexToPrefetch
765 currentPrefetchHandle =
766 prefetchState.schedulePrecompositionAndPremeasure(
767 indexToPrefetch,
768 premeasureConstraints
769 )
770 }
771 if (isPrefetchingForward) {
772 val lastItem = info.visiblePagesInfo.last()
773 val pageSize = info.pageSize + info.pageSpacing
774 val distanceToReachNextItem =
775 lastItem.offset + pageSize - info.viewportEndOffset
776 // if in the next frame we will get the same delta will we reach the item?
777 if (distanceToReachNextItem < delta) {
778 currentPrefetchHandle?.markAsUrgent()
779 }
780 } else {
781 val firstItem = info.visiblePagesInfo.first()
782 val distanceToReachNextItem = info.viewportStartOffset - firstItem.offset
783 // if in the next frame we will get the same delta will we reach the item?
784 if (distanceToReachNextItem < -delta) {
785 currentPrefetchHandle?.markAsUrgent()
786 }
787 }
788 }
789 }
790 }
791
cancelPrefetchIfVisibleItemsChangednull792 private fun cancelPrefetchIfVisibleItemsChanged(info: PagerLayoutInfo) {
793 if (indexToPrefetch != -1 && info.visiblePagesInfo.isNotEmpty()) {
794 val expectedPrefetchIndex =
795 if (wasPrefetchingForward) {
796 info.visiblePagesInfo.last().index +
797 info.beyondViewportPageCount +
798 PagesToPrefetch
799 } else {
800 info.visiblePagesInfo.first().index -
801 info.beyondViewportPageCount -
802 PagesToPrefetch
803 }
804 if (indexToPrefetch != expectedPrefetchIndex) {
805 indexToPrefetch = -1
806 currentPrefetchHandle?.cancel()
807 currentPrefetchHandle = null
808 }
809 }
810 }
811
812 /**
813 * An utility function to help to calculate a given page's offset. This is an offset that
814 * represents how far [page] is from the settled position (represented by [currentPage] offset).
815 * The difference here is that [currentPageOffsetFraction] is a value between -0.5 and 0.5 and
816 * the value calculated by this function can be larger than these numbers if [page] is different
817 * than [currentPage].
818 *
819 * For instance, if currentPage=0 and we call [getOffsetDistanceInPages] for page 3, the result
820 * will be 3, meaning the given page is 3 pages away from the current page (the sign represent
821 * the direction of the offset, positive is forward, negative is backwards). Another example is
822 * if currentPage=3 and we call [getOffsetDistanceInPages] for page 1, the result would be -2,
823 * meaning we're 2 pages away (moving backwards) to the current page.
824 *
825 * This offset also works in conjunction with [currentPageOffsetFraction], so if [currentPage]
826 * is out of its snapped position (i.e. currentPageOffsetFraction!=0) then the calculated value
827 * will still represent the offset in number of pages (in this case, not whole pages). For
828 * instance, if currentPage=1 and we're slightly offset, currentPageOffsetFraction=0.2, if we
829 * call this to page 2, the result would be 0.8, that is 0.8 page away from current page (moving
830 * forward).
831 *
832 * @param page The page to calculate the offset from. This should be between 0 and [pageCount].
833 * @return The offset of [page] with respect to [currentPage].
834 */
getOffsetDistanceInPagesnull835 fun getOffsetDistanceInPages(page: Int): Float {
836 requirePrecondition(page in 0..pageCount) {
837 "page $page is not within the range 0 to $pageCount"
838 }
839 return page - currentPage - currentPageOffsetFraction
840 }
841
842 /**
843 * When the user provided custom keys for the pages we can try to detect when there were pages
844 * added or removed before our current page and keep this page as the current one given that its
845 * index has been changed.
846 */
matchScrollPositionWithKeynull847 internal fun matchScrollPositionWithKey(
848 itemProvider: PagerLazyLayoutItemProvider,
849 currentPage: Int = Snapshot.withoutReadObservation { scrollPosition.currentPage }
850 ): Int = scrollPosition.matchPageWithKey(itemProvider, currentPage)
851 }
852
animateToNextPagenull853 internal suspend fun PagerState.animateToNextPage() {
854 if (currentPage + 1 < pageCount) animateScrollToPage(currentPage + 1)
855 }
856
animateToPreviousPagenull857 internal suspend fun PagerState.animateToPreviousPage() {
858 if (currentPage - 1 >= 0) animateScrollToPage(currentPage - 1)
859 }
860
861 internal val DefaultPositionThreshold = 56.dp
862 private const val MaxPagesForAnimateScroll = 3
863 internal const val PagesToPrefetch = 1
864
865 internal val EmptyLayoutInfo =
866 PagerMeasureResult(
867 visiblePagesInfo = emptyList(),
868 pageSize = 0,
869 pageSpacing = 0,
870 afterContentPadding = 0,
871 orientation = Orientation.Horizontal,
872 viewportStartOffset = 0,
873 viewportEndOffset = 0,
874 reverseLayout = false,
875 beyondViewportPageCount = 0,
876 firstVisiblePage = null,
877 firstVisiblePageScrollOffset = 0,
878 currentPage = null,
879 currentPageOffsetFraction = 0.0f,
880 canScrollForward = false,
881 snapPosition = SnapPosition.Start,
882 measureResult =
883 object : MeasureResult {
884 override val width: Int = 0
885
886 override val height: Int = 0
887
888 @Suppress("PrimitiveInCollection")
889 override val alignmentLines: Map<AlignmentLine, Int> = mapOf()
890
placeChildrennull891 override fun placeChildren() {}
892 },
893 remeasureNeeded = false,
894 coroutineScope = CoroutineScope(EmptyCoroutineContext)
895 )
896
897 private val UnitDensity =
898 object : Density {
899 override val density: Float = 1f
900 override val fontScale: Float = 1f
901 }
902
debugLognull903 private inline fun debugLog(generateMsg: () -> String) {
904 if (PagerDebugConfig.PagerState) {
905 println("PagerState: ${generateMsg()}")
906 }
907 }
908
calculateNewMaxScrollOffsetnull909 internal fun PagerLayoutInfo.calculateNewMaxScrollOffset(pageCount: Int): Long {
910 val pageSizeWithSpacing = pageSpacing + pageSize
911 val maxScrollPossible =
912 (pageCount.toLong()) * pageSizeWithSpacing + beforeContentPadding + afterContentPadding -
913 pageSpacing
914 val layoutSize =
915 if (orientation == Orientation.Horizontal) viewportSize.width else viewportSize.height
916
917 /**
918 * We need to take into consideration the snap position for max scroll position. For instance,
919 * if SnapPosition.Start, the max scroll position is pageCount * pageSize - viewport. Now if
920 * SnapPosition.End, it should be pageCount * pageSize. Therefore, the snap position discount
921 * varies between 0 and viewport.
922 */
923 val snapPositionDiscount =
924 layoutSize -
925 (snapPosition.position(
926 layoutSize = layoutSize,
927 itemSize = pageSize,
928 itemIndex = pageCount - 1,
929 beforeContentPadding = beforeContentPadding,
930 afterContentPadding = afterContentPadding,
931 itemCount = pageCount
932 ))
933 .coerceIn(0, layoutSize)
934
935 debugLog {
936 "maxScrollPossible=$maxScrollPossible" +
937 "\nsnapPositionDiscount=$snapPositionDiscount" +
938 "\nlayoutSize=$layoutSize"
939 }
940 return (maxScrollPossible - snapPositionDiscount).coerceAtLeast(0L)
941 }
942
PagerMeasureResultnull943 private fun PagerMeasureResult.calculateNewMinScrollOffset(pageCount: Int): Long {
944 val layoutSize =
945 if (orientation == Orientation.Horizontal) viewportSize.width else viewportSize.height
946
947 return snapPosition
948 .position(
949 layoutSize = layoutSize,
950 itemSize = pageSize,
951 itemIndex = 0,
952 beforeContentPadding = beforeContentPadding,
953 afterContentPadding = afterContentPadding,
954 itemCount = pageCount
955 )
956 .coerceIn(0, layoutSize)
957 .toLong()
958 }
959
animateScrollToPagenull960 private suspend fun LazyLayoutScrollScope.animateScrollToPage(
961 targetPage: Int,
962 targetPageOffsetToSnappedPosition: Float,
963 animationSpec: AnimationSpec<Float>,
964 updateTargetPage: ScrollScope.(Int) -> Unit
965 ) {
966 updateTargetPage(targetPage)
967 val forward = targetPage > firstVisibleItemIndex
968 val visiblePages = lastVisibleItemIndex - firstVisibleItemIndex + 1
969 if (
970 ((forward && targetPage > lastVisibleItemIndex) ||
971 (!forward && targetPage < firstVisibleItemIndex)) &&
972 abs(targetPage - firstVisibleItemIndex) >= MaxPagesForAnimateScroll
973 ) {
974 val preJumpPosition =
975 if (forward) {
976 (targetPage - visiblePages).coerceAtLeast(firstVisibleItemIndex)
977 } else {
978 (targetPage + visiblePages).coerceAtMost(firstVisibleItemIndex)
979 }
980
981 debugLog { "animateScrollToPage with pre-jump to position=$preJumpPosition" }
982
983 // Pre-jump to 1 viewport away from destination page, if possible
984 snapToItem(preJumpPosition, 0)
985 }
986
987 // The final delta displacement will be the difference between the pages offsets
988 // discounting whatever offset the original page had scrolled plus the offset
989 // fraction requested by the user.
990 val displacement = calculateDistanceTo(targetPage) + targetPageOffsetToSnappedPosition
991
992 debugLog { "animateScrollToPage $displacement pixels" }
993 var previousValue = 0f
994 animate(0f, displacement, animationSpec = animationSpec) { currentValue, _ ->
995 val delta = currentValue - previousValue
996 val consumed = scrollBy(delta)
997 debugLog { "Dispatched Delta=$delta Consumed=$consumed" }
998 previousValue += consumed
999 }
1000 }
1001