xref: /aosp_15_r20/external/accompanist/pager/src/main/java/com/google/accompanist/pager/PagerState.kt (revision fa44fe6ae8e729aa3cfe5c03eedbbf98fb44e2c6)
1 /*
2  * Copyright 2021 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  *      https://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 @file:Suppress("DEPRECATION", "MemberVisibilityCanBePrivate")
18 
19 package com.google.accompanist.pager
20 
21 import androidx.annotation.FloatRange
22 import androidx.annotation.IntRange
23 import androidx.compose.animation.core.AnimationSpec
24 import androidx.compose.animation.core.spring
25 import androidx.compose.foundation.MutatePriority
26 import androidx.compose.foundation.gestures.ScrollScope
27 import androidx.compose.foundation.gestures.ScrollableState
28 import androidx.compose.foundation.interaction.InteractionSource
29 import androidx.compose.foundation.lazy.LazyListItemInfo
30 import androidx.compose.foundation.lazy.LazyListState
31 import androidx.compose.runtime.Composable
32 import androidx.compose.runtime.Stable
33 import androidx.compose.runtime.derivedStateOf
34 import androidx.compose.runtime.getValue
35 import androidx.compose.runtime.mutableStateOf
36 import androidx.compose.runtime.saveable.Saver
37 import androidx.compose.runtime.saveable.listSaver
38 import androidx.compose.runtime.saveable.rememberSaveable
39 import androidx.compose.runtime.setValue
40 import kotlin.math.abs
41 import kotlin.math.absoluteValue
42 import kotlin.math.roundToInt
43 
44 /**
45  * Creates a [PagerState] that is remembered across compositions.
46  *
47  * Changes to the provided values for [initialPage] will **not** result in the state being
48  * recreated or changed in any way if it has already
49  * been created.
50  *
51  * @param initialPage the initial value for [PagerState.currentPage]
52  */
53 @Deprecated(
54     """
55 accompanist/pager is deprecated.
56 The androidx.compose equivalent of rememberPagerState is androidx.compose.foundation.pager.rememberPagerState().
57 For more migration information, please visit https://google.github.io/accompanist/pager/#migration
58 """,
59     replaceWith = ReplaceWith(
60         "androidx.compose.foundation.pager.rememberPagerState(initialPage = initialPage)",
61         "androidx.compose.foundation.pager.rememberPagerState"
62     )
63 )
64 @Composable
rememberPagerStatenull65 public fun rememberPagerState(
66     @IntRange(from = 0) initialPage: Int = 0,
67 ): PagerState = rememberSaveable(saver = PagerState.Saver) {
68     PagerState(
69         currentPage = initialPage,
70     )
71 }
72 
73 /**
74  * A state object that can be hoisted to control and observe scrolling for [HorizontalPager].
75  *
76  * In most cases, this will be created via [rememberPagerState].
77  *
78  * @param currentPage the initial value for [PagerState.currentPage]
79  */
80 @Deprecated(
81     """
82 accompanist/pager is deprecated.
83 The androidx.compose equivalent of Insets is Pager.
84 For more migration information, please visit https://google.github.io/accompanist/pager/#migration
85 """,
86     replaceWith = ReplaceWith(
87         "PagerState(currentPage = currentPage)",
88         "androidx.compose.foundation.pager.PagerState"
89     )
90 )
91 @Stable
92 public class PagerState(
93     @IntRange(from = 0) currentPage: Int = 0,
94 ) : ScrollableState {
95     // Should this be public?
96     internal val lazyListState = LazyListState(firstVisibleItemIndex = currentPage)
97 
98     private var _currentPage by mutableStateOf(currentPage)
99 
100     // finds the page which has larger visible area within the viewport not including paddings
101     internal val mostVisiblePageLayoutInfo: LazyListItemInfo?
102         get() {
103             val layoutInfo = lazyListState.layoutInfo
<lambda>null104             return layoutInfo.visibleItemsInfo.maxByOrNull {
105                 val start = maxOf(it.offset, 0)
106                 val end = minOf(
107                     it.offset + it.size,
108                     layoutInfo.viewportEndOffset - layoutInfo.afterContentPadding
109                 )
110                 end - start
111             }
112         }
113 
114     internal var itemSpacing by mutableStateOf(0)
115 
116     private val currentPageLayoutInfo: LazyListItemInfo?
<lambda>null117         get() = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull {
118             it.index == currentPage
119         }
120 
121     /**
122      * [InteractionSource] that will be used to dispatch drag events when this
123      * list is being dragged. If you want to know whether the fling (or animated scroll) is in
124      * progress, use [isScrollInProgress].
125      */
126     public val interactionSource: InteractionSource
127         get() = lazyListState.interactionSource
128 
129     /**
130      * The number of pages to display.
131      */
132     @get:IntRange(from = 0)
133     @Deprecated("pageCount is deprecated, use androidx.compose.foundation.pager.PagerState.canScrollForward or androidx.compose.foundation.pager.PagerState.canScrollBackward")
<lambda>null134     public val pageCount: Int by derivedStateOf {
135         lazyListState.layoutInfo.totalItemsCount
136     }
137 
138     /**
139      * The index of the currently selected page. This may not be the page which is
140      * currently displayed on screen.
141      *
142      * To update the scroll position, use [scrollToPage] or [animateScrollToPage].
143      */
144     @get:IntRange(from = 0)
145     public var currentPage: Int
146         get() = _currentPage
147         internal set(value) {
148             if (value != _currentPage) {
149                 _currentPage = value
150             }
151         }
152 
153     /**
154      * The current offset from the start of [currentPage], as a ratio of the page width.
155      *
156      * To update the scroll position, use [scrollToPage] or [animateScrollToPage].
157      */
<lambda>null158     public val currentPageOffset: Float by derivedStateOf {
159         currentPageLayoutInfo?.let {
160             (-it.offset / (it.size + itemSpacing).toFloat()).coerceIn(-0.5f, 0.5f)
161         } ?: 0f
162     }
163 
164     /**
165      * The target page for any on-going animations.
166      */
167     private var animationTargetPage: Int? by mutableStateOf(null)
168 
169     internal var flingAnimationTarget: (() -> Int?)? by mutableStateOf(null)
170 
171     /**
172      * The target page for any on-going animations or scrolls by the user.
173      * Returns the current page if a scroll or animation is not currently in progress.
174      */
175     @Deprecated(
176         "targetPage is deprecated in favor of currentPage as currentPage property is" +
177             "now being updated right after we over scrolled the half of the previous current page." +
178             "If you still think that you need targetPage, not currentPage please file a bug as " +
179             "we are planning to remove this property in future.",
180         ReplaceWith("currentPage")
181     )
182     public val targetPage: Int
183         get() = animationTargetPage
184             ?: flingAnimationTarget?.invoke()
185             ?: when {
186                 // If a scroll isn't in progress, return the current page
187                 !isScrollInProgress -> currentPage
188                 // If the offset is 0f (or very close), return the current page
189                 currentPageOffset.absoluteValue < 0.001f -> currentPage
190                 // If we're offset towards the start, guess the previous page
191                 currentPageOffset < 0f -> (currentPage - 1).coerceAtLeast(0)
192                 // If we're offset towards the end, guess the next page
193                 else -> (currentPage + 1).coerceAtMost(pageCount - 1)
194             }
195 
196     @Deprecated(
197         "Replaced with animateScrollToPage(page, pageOffset)",
198         ReplaceWith("animateScrollToPage(page = page, pageOffset = pageOffset)")
199     )
200     @Suppress("UNUSED_PARAMETER")
animateScrollToPagenull201     public suspend fun animateScrollToPage(
202         @IntRange(from = 0) page: Int,
203         @FloatRange(from = 0.0, to = 1.0) pageOffset: Float = 0f,
204         animationSpec: AnimationSpec<Float> = spring(),
205         initialVelocity: Float = 0f,
206         skipPages: Boolean = true,
207     ) {
208         animateScrollToPage(page = page, pageOffset = pageOffset)
209     }
210 
211     /**
212      * Animate (smooth scroll) to the given page to the middle of the viewport.
213      *
214      * Cancels the currently running scroll, if any, and suspends until the cancellation is
215      * complete.
216      *
217      * @param page the page to animate to. Must be >= 0.
218      * @param pageOffset the percentage of the page size to offset, from the start of [page].
219      * Must be in the range -1f..1f.
220      */
animateScrollToPagenull221     public suspend fun animateScrollToPage(
222         @IntRange(from = 0) page: Int,
223         @FloatRange(from = -1.0, to = 1.0) pageOffset: Float = 0f,
224     ) {
225         requireCurrentPage(page, "page")
226         requireCurrentPageOffset(pageOffset, "pageOffset")
227         try {
228             animationTargetPage = page
229 
230             // pre-jump to nearby item for long jumps as an optimization
231             // the same trick is done in ViewPager2
232             val oldPage = lazyListState.firstVisibleItemIndex
233             if (abs(page - oldPage) > 3) {
234                 lazyListState.scrollToItem(if (page > oldPage) page - 3 else page + 3)
235             }
236 
237             if (pageOffset.absoluteValue <= 0.005f) {
238                 // If the offset is (close to) zero, just call animateScrollToItem and we're done
239                 lazyListState.animateScrollToItem(index = page)
240             } else {
241                 // Else we need to figure out what the offset is in pixels...
242                 lazyListState.scroll { } // this will await for the first layout.
243                 val layoutInfo = lazyListState.layoutInfo
244                 var target = layoutInfo.visibleItemsInfo
245                     .firstOrNull { it.index == page }
246 
247                 if (target != null) {
248                     // If we have access to the target page layout, we can calculate the pixel
249                     // offset from the size
250                     lazyListState.animateScrollToItem(
251                         index = page,
252                         scrollOffset = ((target.size + itemSpacing) * pageOffset).roundToInt()
253                     )
254                 } else if (layoutInfo.visibleItemsInfo.isNotEmpty()) {
255                     // If we don't, we use the current page size as a guide
256                     val currentSize = layoutInfo.visibleItemsInfo.first().size + itemSpacing
257                     lazyListState.animateScrollToItem(
258                         index = page,
259                         scrollOffset = (currentSize * pageOffset).roundToInt()
260                     )
261 
262                     // The target should be visible now
263                     target = lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == page }
264 
265                     if (target != null && target.size + itemSpacing != currentSize) {
266                         // If the size we used for calculating the offset differs from the actual
267                         // target page size, we need to scroll again. This doesn't look great,
268                         // but there's not much else we can do.
269                         lazyListState.animateScrollToItem(
270                             index = page,
271                             scrollOffset = ((target.size + itemSpacing) * pageOffset).roundToInt()
272                         )
273                     }
274                 }
275             }
276         } finally {
277             // We need to manually call this, as the `animateScrollToItem` call above will happen
278             // in 1 frame, which is usually too fast for the LaunchedEffect in Pager to detect
279             // the change. This is especially true when running unit tests.
280             onScrollFinished()
281         }
282     }
283 
284     /**
285      * Instantly brings the item at [page] to the middle of the viewport.
286      *
287      * Cancels the currently running scroll, if any, and suspends until the cancellation is
288      * complete.
289      *
290      * @param page the page to snap to. Must be >= 0.
291      * @param pageOffset the percentage of the page size to offset, from the start of [page].
292      * Must be in the range -1f..1f.
293      */
scrollToPagenull294     public suspend fun scrollToPage(
295         @IntRange(from = 0) page: Int,
296         @FloatRange(from = -1.0, to = 1.0) pageOffset: Float = 0f,
297     ) {
298         requireCurrentPage(page, "page")
299         requireCurrentPageOffset(pageOffset, "pageOffset")
300         try {
301             animationTargetPage = page
302 
303             // First scroll to the given page. It will now be laid out at offset 0
304             lazyListState.scrollToItem(index = page)
305             updateCurrentPageBasedOnLazyListState()
306 
307             // If we have a start spacing, we need to offset (scroll) by that too
308             if (pageOffset.absoluteValue > 0.0001f) {
309                 currentPageLayoutInfo?.let {
310                     scroll {
311                         scrollBy((it.size + itemSpacing) * pageOffset)
312                     }
313                 }
314             }
315         } finally {
316             // We need to manually call this, as the `scroll` call above will happen in 1 frame,
317             // which is usually too fast for the LaunchedEffect in Pager to detect the change.
318             // This is especially true when running unit tests.
319             onScrollFinished()
320         }
321     }
322 
updateCurrentPageBasedOnLazyListStatenull323     internal fun updateCurrentPageBasedOnLazyListState() {
324         // Then update the current page to our layout page
325         mostVisiblePageLayoutInfo?.let {
326             currentPage = it.index
327         }
328     }
329 
onScrollFinishednull330     internal fun onScrollFinished() {
331         // Clear the animation target page
332         animationTargetPage = null
333     }
334 
scrollnull335     override suspend fun scroll(
336         scrollPriority: MutatePriority,
337         block: suspend ScrollScope.() -> Unit
338     ): Unit = lazyListState.scroll(scrollPriority, block)
339 
340     override fun dispatchRawDelta(delta: Float): Float {
341         return lazyListState.dispatchRawDelta(delta)
342     }
343 
344     override val isScrollInProgress: Boolean
345         get() = lazyListState.isScrollInProgress
346 
toStringnull347     override fun toString(): String = "PagerState(" +
348         "pageCount=$pageCount, " +
349         "currentPage=$currentPage, " +
350         "currentPageOffset=$currentPageOffset" +
351         ")"
352 
353     private fun requireCurrentPage(value: Int, name: String) {
354         require(value >= 0) { "$name[$value] must be >= 0" }
355     }
356 
requireCurrentPageOffsetnull357     private fun requireCurrentPageOffset(value: Float, name: String) {
358         require(value in -1f..1f) { "$name must be >= -1 and <= 1" }
359     }
360 
361     public companion object {
362         /**
363          * The default [Saver] implementation for [PagerState].
364          */
365         public val Saver: Saver<PagerState, *> = listSaver(
<lambda>null366             save = {
367                 listOf<Any>(
368                     it.currentPage,
369                 )
370             },
<lambda>null371             restore = {
372                 PagerState(
373                     currentPage = it[0] as Int,
374                 )
375             }
376         )
377     }
378 }
379