-
Notifications
You must be signed in to change notification settings - Fork 585
/
PagerState.kt
731 lines (657 loc) · 27.7 KB
/
PagerState.kt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:Suppress("MemberVisibilityCanBePrivate")
package com.google.accompanist.pager
import androidx.annotation.FloatRange
import androidx.annotation.IntRange
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.animateDecay
import androidx.compose.animation.core.calculateTargetValue
import androidx.compose.animation.core.exponentialDecay
import androidx.compose.animation.core.spring
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.gestures.ScrollScope
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.github.aakira.napier.DebugAntilog
import io.github.aakira.napier.Napier
import kotlin.math.absoluteValue
import kotlin.math.floor
import kotlin.math.roundToInt
/**
* Creates a [PagerState] that is remembered across compositions.
*
* Changes to the provided values for [initialPage], [initialPageOffset] & [initialOffscreenLimit]
* will **not** result in the state being recreated or changed in any way if it has already
* been created. Changes to [pageCount] will result in the [PagerState] being updated.
*
* @param pageCount the value for [PagerState.pageCount]
* @param initialPage the initial value for [PagerState.currentPage]
* @param initialPageOffset the initial value for [PagerState.currentPageOffset]
* @param initialOffscreenLimit the number of pages that should be retained on either side of the
* current page. This value is required to be `1` or greater.
* @param infiniteLoop Whether to support infinite looping effect.
*/
@ExperimentalPagerApi
@Composable
fun rememberPagerState(
@IntRange(from = 0) pageCount: Int,
@IntRange(from = 0) initialPage: Int = 0,
@FloatRange(from = 0.0, to = 1.0) initialPageOffset: Float = 0f,
@IntRange(from = 1) initialOffscreenLimit: Int = 1,
infiniteLoop: Boolean = false
): PagerState = rememberSaveable(saver = PagerState.Saver) {
PagerState(
pageCount = pageCount,
currentPage = initialPage,
currentPageOffset = initialPageOffset,
offscreenLimit = initialOffscreenLimit,
infiniteLoop = infiniteLoop
)
}.apply {
this.pageCount = pageCount
}
/**
* A state object that can be hoisted to control and observe scrolling for [HorizontalPager].
*
* In most cases, this will be created via [rememberPagerState].
*
* The `offscreenLimit` param defines the number of pages that
* should be retained on either side of the current page. Pages beyond this limit will be
* recreated as needed. This value defaults to `1`, but can be increased to enable pre-loading
* of more content.
*
* @param pageCount the initial value for [PagerState.pageCount]
* @param currentPage the initial value for [PagerState.currentPage]
* @param currentPageOffset the initial value for [PagerState.currentPageOffset]
* @param offscreenLimit the number of pages that should be retained on either side of the
* current page. This value is required to be `1` or greater.
* @param infiniteLoop Whether to support infinite looping effect.
*/
@ExperimentalPagerApi
@Stable
class PagerState(
@IntRange(from = 0) pageCount: Int,
@IntRange(from = 0) currentPage: Int = 0,
@FloatRange(from = 0.0, to = 1.0) currentPageOffset: Float = 0f,
private val offscreenLimit: Int = 1,
private val infiniteLoop: Boolean = false,
) : ScrollableState {
private var _pageCount by mutableStateOf(pageCount)
private var _currentPage by mutableStateOf(currentPage)
private var _currentLayoutPageOffset by mutableStateOf(currentPageOffset)
/**
* When set to true, `page` of [Pager] content can be different in [infiniteLoop] mode.
*/
internal var testing = false
/**
* This is the array of all the pages to be laid out. In effect, this contains the
* 'current' page, plus the `offscreenLimit` on either side. Each PageLayoutInfo holds the page
* index it should be displaying, and it's current layout size. Pager reads these values
* to layout the pages as appropriate.
*
* The 'current layout page' is in the center of the array. The index is available at
* [currentLayoutPageIndex].
*/
internal val layoutPages: Array<PageLayoutInfo> =
Array((offscreenLimit * 2) + 1) { PageLayoutInfo() }
/**
* The index for the 'current layout page' in [layoutPages].
*/
private val currentLayoutPageIndex: Int = (layoutPages.size - 1) / 2
internal var currentLayoutPageOffset: Float
get() = _currentLayoutPageOffset
private set(value) {
_currentLayoutPageOffset = value.coerceIn(
minimumValue = 0f,
maximumValue = if (currentLayoutPage == lastPageIndex) 0f else 1f,
)
}
/**
* The width/height of the current layout page (depending on the layout).
*/
private inline val currentLayoutPageSize: Int
get() = currentLayoutPageInfo.layoutSize
/**
* The page which is currently laid out.
*/
internal inline val currentLayoutPage: Int
get() = currentLayoutPageInfo.page ?: 0
internal inline val currentLayoutPageInfo: PageLayoutInfo
get() = layoutPages[currentLayoutPageIndex]
/**
* The current scroll position, as a float value between `firstPageIndex until lastPageIndex`
*/
private inline val absolutePosition: Float
get() = currentLayoutPage + currentLayoutPageOffset
internal inline val firstPageIndex: Int
get() = if (infiniteLoop) Int.MIN_VALUE else 0
internal inline val lastPageIndex: Int
get() = if (infiniteLoop) Int.MAX_VALUE else (pageCount - 1).coerceAtLeast(0)
/**
* The ScrollableController instance. We keep it as we need to call stopAnimation on it once
* we reached the end of the list.
*/
private val scrollableState = ScrollableState { deltaPixels ->
if (DebugLog) {
Napier.d(message = "ScrollableState onScroll $deltaPixels")
}
// scrollByOffset expects values in an opposite sign to what we're passed, so we need
// to negate the value passed in, and the value returned.
val size = currentLayoutPageSize
require(size > 0) { "Layout size for current item is 0" }
-scrollByOffset(-deltaPixels / size) * size
}
/**
* [InteractionSource] that will be used to dispatch drag events when this
* list is being dragged. If you want to know whether the fling (or animated scroll) is in
* progress, use [isScrollInProgress].
*/
val interactionSource: InteractionSource
get() = internalInteractionSource
internal val internalInteractionSource: MutableInteractionSource = MutableInteractionSource()
init {
require(offscreenLimit >= 1) { "offscreenLimit is required to be >= 1" }
require(pageCount >= 0) { "pageCount must be >= 0" }
requireCurrentPage(currentPage, "currentPage")
requireCurrentPageOffset(currentPageOffset, "currentPageOffset")
updateLayoutPages(currentPage)
}
/**
* The number of pages to display.
*/
@get:IntRange(from = 0)
var pageCount: Int
get() = _pageCount
set(@IntRange(from = 0) value) {
require(value >= 0) { "pageCount must be >= 0" }
if (value != _pageCount) {
_pageCount = value
if (DebugLog) {
Napier.d(message = "Page count changed: $value")
}
currentPage = currentPage.coerceIn(firstPageIndex, lastPageIndex)
updateLayoutPages(currentPage)
}
}
/**
* The index of the currently selected page. This may not be the page which is
* currently displayed on screen.
*
* To update the scroll position, use [scrollToPage] or [animateScrollToPage].
*/
@get:IntRange(from = 0)
var currentPage: Int
get() = _currentPage
private set(value) {
val moddedValue = value.floorMod(pageCount)
if (moddedValue != _currentPage) {
_currentPage = moddedValue
if (DebugLog) {
Napier.d(message = "Current page changed: $_currentPage")
}
// If the current page is changed, update the layout page too
updateLayoutPages(moddedValue)
}
}
/**
* The current offset from the start of [currentPage], as a fraction of the page width.
*
* To update the scroll position, use [scrollToPage] or [animateScrollToPage].
*/
val currentPageOffset: Float
get() = absolutePosition - currentPage
/**
* The target page for any on-going animations.
*/
private var _animationTargetPage: Int? by mutableStateOf(null)
/**
* The target page for any on-going animations or scrolls by the user.
* Returns the current page if a scroll or animation is not currently in progress.
*/
val targetPage: Int
get() = _animationTargetPage ?: when {
// If a scroll isn't in progress, return the current page
!isScrollInProgress -> currentPage
// If the offset is 0f (or very close), return the current page
currentPageOffset.absoluteValue < 0.001f -> currentPage
// If we're offset towards the start, guess the previous page
currentPageOffset < 0 -> (currentPage - 1).coerceAtLeast(0)
// If we're offset towards the end, guess the next page
else -> (currentPage + 1).coerceAtMost(lastPageIndex)
}
/**
* Animate (smooth scroll) to the given page to the middle of the viewport, offset
* by [pageOffset] percentage of page width.
*
* Cancels the currently running scroll, if any, and suspends until the cancellation is
* complete.
*
* @param page the page to animate to. Must be between 0 and [pageCount] (inclusive).
* @param pageOffset the percentage of the page width to offset, from the start of [page].
* Must be in the range 0f..1f.
* @param initialVelocity Initial velocity in pixels per second, or `0f` to not use a start velocity.
* Must be in the range 0f..1f.
* @param skipPages Whether to skip most intermediate pages. This allows the layout to skip
* creating pages which are only displayed for a *very* short amount of time. Visually users
* should see no difference. Pass `false` to animate over all pages between [currentPage]
* and [page]. Defaults to `true`.
*/
suspend fun animateScrollToPage(
@IntRange(from = 0) page: Int,
@FloatRange(from = 0.0, to = 1.0) pageOffset: Float = 0f,
animationSpec: AnimationSpec<Float> = spring(),
initialVelocity: Float = 0f,
skipPages: Boolean = true,
) {
requireCurrentPage(page, "page")
requireCurrentPageOffset(pageOffset, "pageOffset")
if (page == currentPage && pageOffset == currentLayoutPageOffset) return
// We don't specifically use the ScrollScope's scrollBy, but
// we do want to use it's mutex
scroll {
val currentIndex = currentLayoutPage
val target = if (infiniteLoop) {
// In infinite loop mode, allow scrolling to pages out of bounds.
page
} else {
page.floorMod(pageCount)
}
val distance = (target - currentIndex).absoluteValue
/**
* The distance of 4 may seem like a magic number, but it's not.
* It's: current page, current page + 1, target page - 1, target page.
* This provides the illusion of movement, but allows us to lay out as few pages
* as possible. 🧙♂️
*/
if (skipPages && distance > 4) {
animateToPageSkip(target, pageOffset, animationSpec, initialVelocity)
} else {
animateToPageLinear(target, pageOffset, animationSpec, initialVelocity)
}
}
}
/**
* Instantly brings the item at [page] to the middle of the viewport, offset by [pageOffset]
* percentage of page width.
*
* Cancels the currently running scroll, if any, and suspends until the cancellation is
* complete.
*
* @param page the page to snap to. Must be between 0 and [pageCount] (inclusive).
* @param pageOffset the percentage of the page width to offset, from the start of [page].
* Must be in the range 0f..1f.
*/
suspend fun scrollToPage(
@IntRange(from = 0) page: Int,
@FloatRange(from = 0.0, to = 1.0) pageOffset: Float = 0f,
) {
requireCurrentPage(page, "page")
requireCurrentPageOffset(pageOffset, "pageOffset")
if (page == currentPage && pageOffset == currentLayoutPageOffset) return
// We don't specifically use the ScrollScope's scrollBy(), but
// we do want to use it's mutex
scroll {
snapToPage(page, pageOffset)
}
}
/**
* Snap the layout the given [page] and [offset].
*/
private fun snapToPage(page: Int, offset: Float = 0f) {
if (DebugLog) {
Napier.d(
message = "snapToPage. page:$currentLayoutPage, offset:$currentLayoutPageOffset"
)
}
// Snap the layout
updateLayoutPages(page)
currentLayoutPageOffset = offset
// Then update the current page to match
currentPage = page
// Clear the target page
_animationTargetPage = null
}
private fun snapToNearestPage() {
snapToPage(currentLayoutPage + currentLayoutPageOffset.roundToInt())
}
/**
* Animates to the given [page] and [pageOffset] linearly, by animating through all pages
* in-between [currentPage] and [page]. As an example, if we're currently displaying item 0,
* and we want to animate to page 9, this function will lay out and animate over:
* [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]. This is different to [animateToPageSkip] which skips the
* intermediate pages.
*/
private suspend fun animateToPageLinear(
page: Int,
pageOffset: Float,
animationSpec: AnimationSpec<Float>,
initialVelocity: Float,
) {
// Set our target page
_animationTargetPage = page
animate(
initialValue = absolutePosition,
targetValue = page + pageOffset,
initialVelocity = initialVelocity,
animationSpec = animationSpec
) { value, _ ->
updateLayoutForScrollPosition(value)
}
// At the end of the animate, snap to the page + offset. This isn't strictly necessary,
// but ensures that all our state to consistent.
snapToPage(page = page, offset = pageOffset)
}
/**
* Animates to the given [page] and [pageOffset], but unlike [animateToPageLinear]
* it skips intermediate pages. As an example, if we're currently displaying item 0, and we
* want to animate to page 9, this function will only lay out and animate over: [0, 1, 8, 9].
*/
private suspend fun animateToPageSkip(
page: Int,
pageOffset: Float,
animationSpec: AnimationSpec<Float>,
initialVelocity: Float,
) {
// Set our target page
_animationTargetPage = page
val initialIndex = currentLayoutPage
val direction = if (page > initialIndex) 1 else -1
// These are the pages which we'll iterate through to display the 'effect' of scrolling.
val pages: IntArray = when {
page > initialIndex -> intArrayOf(initialIndex, initialIndex + 1, page - 1, page)
else -> intArrayOf(initialIndex, initialIndex - 1, page + 1, page)
}
// We animate over the length of the `pages` array (including the offset). Pages includes
// the current page (to allow us to animate over the offset) so we need to minus 1
animate(
initialValue = currentPageOffset,
targetValue = (pages.size - 1) * direction + pageOffset,
initialVelocity = initialVelocity * direction,
animationSpec = animationSpec
) { value, _ ->
// Value here is the [index of page in pages] + offset. We floor the value to get
// the pages index
val flooredIndex = floor(value).toInt()
// We then go through each layout page and set it to the correct page from [pages]
layoutPages.forEachIndexed { index, layoutInfo ->
layoutInfo.page = pages.getOrNull(
flooredIndex * direction + (index - currentLayoutPageIndex)
)
}
if (DebugLog) {
Napier.d(message = "animateToPageSkip: $layoutPages")
}
// Then derive the remaining offset from the index
currentLayoutPageOffset = value - flooredIndex
}
// At the end of the animate, snap to the page + offset. This isn't strictly necessary,
// but ensures that all our state to consistent.
snapToPage(page = page, offset = pageOffset)
}
private fun determineSpringBackOffset(
velocity: Float,
offset: Float = currentLayoutPageOffset,
): Int = when {
// If the velocity is greater than 1 page per second (velocity is px/s), spring
// in the relevant direction
velocity >= currentLayoutPageSize -> 1
velocity <= -currentLayoutPageSize -> 0
// If the offset exceeds the scroll threshold (in either direction), we want to
// move to the next/previous item
offset < 0.5f -> 0
else -> 1
}
/**
* Updates the [layoutPages] so that for the given [position].
*/
private fun updateLayoutForScrollPosition(position: Float) {
val newIndex = floor(position).toInt().coerceIn(firstPageIndex, lastPageIndex)
updateLayoutPages(newIndex)
currentLayoutPageOffset = (position - newIndex).coerceIn(0f, 1f)
}
/**
* Updates the [layoutPages] so that [page] is the current laid out page.
*/
private fun updateLayoutPages(page: Int) {
requireCurrentPage(page, "page")
if (DebugLog) Napier.d(message = "updateLayoutPages(page = $page)")
layoutPages.forEachIndexed { index, layoutPage ->
val pg = page + index - offscreenLimit
layoutPage.page = when {
pageCount == 0 || pg < firstPageIndex || pg > lastPageIndex -> null
else -> pg
}
}
}
/**
* Scroll by the pager with the given [deltaOffset].
*
* @param deltaOffset delta in offset values (0f..1f). Values > 0 signify scrolls
* towards the end of the pager, and values < 0 towards the start.
* @return the amount of [deltaOffset] consumed
*/
private fun scrollByOffset(deltaOffset: Float): Float {
val current = absolutePosition
val target = (current + deltaOffset).coerceIn(
minimumValue = firstPageIndex.toFloat(),
maximumValue = lastPageIndex.toFloat()
)
if (DebugLog) {
Napier.d(
message = "scrollByOffset [before]. delta:%.4f, current:%.4f, target:%.4f"
.format(deltaOffset, current, target),
)
}
updateLayoutForScrollPosition(target)
if (DebugLog) {
Napier.d(
message = "scrollByOffset [after]. delta:%.4f, new-page:%d, new-offset:%.4f"
.format(deltaOffset, currentLayoutPage, currentLayoutPageOffset),
)
}
return target - current
}
/**
* Fling the pager with the given [initialVelocity]. [scrollBy] will called whenever a
* scroll change is required by the fling.
*
* @param initialVelocity velocity in pixels per second. Values > 0 signify flings
* towards the end of the pager, and values < 0 sign flings towards the start.
* @param decayAnimationSpec The decay animation spec to use for decayed flings.
* @param snapAnimationSpec The animation spec to use when snapping.
* @param scrollBy block which is called when a scroll is required. Positive values passed in
* signify scrolls towards the end of the pager, and values < 0 towards the start.
* @return any remaining velocity after the scroll has finished.
*/
internal suspend fun fling(
initialVelocity: Float,
decayAnimationSpec: DecayAnimationSpec<Float> = exponentialDecay(),
snapAnimationSpec: AnimationSpec<Float> = spring(),
scrollBy: (Float) -> Float,
): Float {
// We calculate the target offset using pixels, rather than using the offset
val targetOffset = decayAnimationSpec.calculateTargetValue(
initialValue = currentLayoutPageOffset * currentLayoutPageSize,
initialVelocity = initialVelocity
) / currentLayoutPageSize
if (DebugLog) {
Napier.d(
message = "fling. velocity:%.4f, page: %d, offset:%.4f, targetOffset:%.4f"
.format(
initialVelocity,
currentLayoutPage,
currentLayoutPageOffset,
targetOffset
)
)
}
var lastVelocity: Float = initialVelocity
// If the animation can naturally end outside of current page bounds, we will
// animate with decay.
if (targetOffset.absoluteValue >= 1) {
// Animate with the decay animation spec using the fling velocity
val target = when {
targetOffset > 0 -> (currentLayoutPage + 1).coerceAtMost(lastPageIndex)
else -> currentLayoutPage
}
// Update the external state too
_animationTargetPage = target
AnimationState(
initialValue = currentLayoutPageOffset * currentLayoutPageSize,
initialVelocity = initialVelocity
).animateDecay(decayAnimationSpec) {
if (DebugLog) {
Napier.d(
message = "fling. decay. value:%.4f, page: %d, offset:%.4f"
.format(value, currentPage, currentPageOffset)
)
}
// Keep track of velocity
lastVelocity = velocity
// Now scroll..
val coerced = value.coerceIn(0f, currentLayoutPageSize.toFloat())
scrollBy(coerced - (currentLayoutPageOffset * currentLayoutPageSize))
// If we've scroll our target page (or beyond it), cancel the animation
if ((initialVelocity < 0 && absolutePosition <= target) ||
(initialVelocity > 0 && absolutePosition >= target)
) {
// If we reach the bounds of the allowed offset, cancel the animation
cancelAnimation()
}
}
snapToPage(target)
} else {
// Otherwise we animate to the next item, or spring-back depending on the offset
val target = currentLayoutPage + determineSpringBackOffset(
velocity = initialVelocity,
offset = targetOffset
)
// Update the external state too
_animationTargetPage = target
animate(
initialValue = absolutePosition * currentLayoutPageSize,
targetValue = target.toFloat() * currentLayoutPageSize,
initialVelocity = initialVelocity,
animationSpec = snapAnimationSpec,
) { value, velocity ->
scrollBy(value - (absolutePosition * currentLayoutPageSize))
// Keep track of velocity
lastVelocity = velocity
}
snapToNearestPage()
}
return lastVelocity
}
override val isScrollInProgress: Boolean
get() = scrollableState.isScrollInProgress
override fun dispatchRawDelta(delta: Float): Float {
return scrollableState.dispatchRawDelta(delta)
}
override suspend fun scroll(
scrollPriority: MutatePriority,
block: suspend ScrollScope.() -> Unit
) {
scrollableState.scroll(scrollPriority, block)
}
override fun toString(): String = "PagerState(" +
"pageCount=$pageCount, " +
"currentPage=$currentPage, " +
"currentPageOffset=$currentPageOffset" +
")"
private fun requireCurrentPage(value: Int, name: String) {
if (pageCount == 0) {
require(value == 0) { "$name must be 0 when pageCount is 0" }
} else {
require(value in firstPageIndex..lastPageIndex) {
"$name[$value] must be >= firstPageIndex[$firstPageIndex] and <= lastPageIndex[$lastPageIndex]"
}
}
}
private fun requireCurrentPageOffset(value: Float, name: String) {
if (pageCount == 0) {
require(value == 0f) { "$name must be 0f when pageCount is 0" }
} else {
require(value in 0f..1f) { "$name must be >= 0 and <= 1" }
}
}
/**
* Considering infinite loop, returns page between 0 until [pageCount].
*/
internal fun pageOf(rawPage: Int): Int {
if (testing) {
return rawPage
}
return rawPage.floorMod(pageCount)
}
companion object {
/**
* The default [Saver] implementation for [PagerState].
*/
val Saver: Saver<PagerState, *> = listSaver(
save = {
listOf<Any>(
it.pageCount,
it.currentPage,
it.offscreenLimit,
it.infiniteLoop,
)
},
restore = {
PagerState(
pageCount = it[0] as Int,
currentPage = it[1] as Int,
offscreenLimit = it[2] as Int,
infiniteLoop = it[3] as Boolean,
)
}
)
init {
if (DebugLog) {
Napier.base(DebugAntilog(defaultTag = "Pager"))
}
}
/**
* Calculates the floor modulus in the range of -abs([other]) < r < +abs([other]).
*/
private fun Int.floorMod(other: Int): Int {
return when (other) {
0 -> this
else -> this - this.floorDiv(other) * other
}
}
}
}
@Stable
internal class PageLayoutInfo {
var page: Int? by mutableStateOf(null)
var layoutSize: Int by mutableStateOf(0)
override fun toString(): String {
return "PageLayoutInfo(page = $page, layoutSize=$layoutSize)"
}
}