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