• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2022 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 com.android.compose.pager
18 
19 import androidx.annotation.FloatRange
20 import androidx.annotation.IntRange
21 import androidx.compose.animation.core.AnimationSpec
22 import androidx.compose.animation.core.spring
23 import androidx.compose.foundation.MutatePriority
24 import androidx.compose.foundation.gestures.ScrollScope
25 import androidx.compose.foundation.gestures.ScrollableState
26 import androidx.compose.foundation.interaction.InteractionSource
27 import androidx.compose.foundation.lazy.LazyListItemInfo
28 import androidx.compose.foundation.lazy.LazyListState
29 import androidx.compose.runtime.Composable
30 import androidx.compose.runtime.Stable
31 import androidx.compose.runtime.derivedStateOf
32 import androidx.compose.runtime.getValue
33 import androidx.compose.runtime.mutableStateOf
34 import androidx.compose.runtime.saveable.Saver
35 import androidx.compose.runtime.saveable.listSaver
36 import androidx.compose.runtime.saveable.rememberSaveable
37 import androidx.compose.runtime.setValue
38 import kotlin.math.absoluteValue
39 import kotlin.math.roundToInt
40 
41 @Deprecated(
42     "Replaced with rememberPagerState(initialPage) and count parameter on Pager composables",
43     ReplaceWith("rememberPagerState(initialPage)"),
44     level = DeprecationLevel.ERROR,
45 )
46 @Suppress("UNUSED_PARAMETER", "NOTHING_TO_INLINE")
47 @ExperimentalPagerApi
48 @Composable
49 inline fun rememberPagerState(
50     @IntRange(from = 0) pageCount: Int,
51     @IntRange(from = 0) initialPage: Int = 0,
52     @FloatRange(from = 0.0, to = 1.0) initialPageOffset: Float = 0f,
53     @IntRange(from = 1) initialOffscreenLimit: Int = 1,
54     infiniteLoop: Boolean = false
55 ): PagerState {
56     return rememberPagerState(initialPage = initialPage)
57 }
58 
59 /**
60  * Creates a [PagerState] that is remembered across compositions.
61  *
62  * Changes to the provided values for [initialPage] will **not** result in the state being recreated
63  * or changed in any way if it has already been created.
64  *
65  * @param initialPage the initial value for [PagerState.currentPage]
66  */
67 @ExperimentalPagerApi
68 @Composable
rememberPagerStatenull69 fun rememberPagerState(
70     @IntRange(from = 0) initialPage: Int = 0,
71 ): PagerState =
72     rememberSaveable(saver = PagerState.Saver) {
73         PagerState(
74             currentPage = initialPage,
75         )
76     }
77 
78 /**
79  * A state object that can be hoisted to control and observe scrolling for [HorizontalPager].
80  *
81  * In most cases, this will be created via [rememberPagerState].
82  *
83  * @param currentPage the initial value for [PagerState.currentPage]
84  */
85 @ExperimentalPagerApi
86 @Stable
87 class PagerState(
88     @IntRange(from = 0) currentPage: Int = 0,
89 ) : ScrollableState {
90     // Should this be public?
91     internal val lazyListState = LazyListState(firstVisibleItemIndex = currentPage)
92 
93     private var _currentPage by mutableStateOf(currentPage)
94 
95     private val currentLayoutPageInfo: LazyListItemInfo?
96         get() =
97             lazyListState.layoutInfo.visibleItemsInfo
98                 .asSequence()
<lambda>null99                 .filter { it.offset <= 0 && it.offset + it.size > 0 }
100                 .lastOrNull()
101 
102     private val currentLayoutPageOffset: Float
103         get() =
currentnull104             currentLayoutPageInfo?.let { current ->
105                 // We coerce since itemSpacing can make the offset > 1f.
106                 // We don't want to count spacing in the offset so cap it to 1f
107                 (-current.offset / current.size.toFloat()).coerceIn(0f, 1f)
108             }
109                 ?: 0f
110 
111     /**
112      * [InteractionSource] that will be used to dispatch drag events when this list is being
113      * dragged. If you want to know whether the fling (or animated scroll) is in progress, use
114      * [isScrollInProgress].
115      */
116     val interactionSource: InteractionSource
117         get() = lazyListState.interactionSource
118 
119     /** The number of pages to display. */
120     @get:IntRange(from = 0)
<lambda>null121     val pageCount: Int by derivedStateOf { lazyListState.layoutInfo.totalItemsCount }
122 
123     /**
124      * The index of the currently selected page. This may not be the page which is currently
125      * displayed on screen.
126      *
127      * To update the scroll position, use [scrollToPage] or [animateScrollToPage].
128      */
129     @get:IntRange(from = 0)
130     var currentPage: Int
131         get() = _currentPage
132         internal set(value) {
133             if (value != _currentPage) {
134                 _currentPage = value
135             }
136         }
137 
138     /**
139      * The current offset from the start of [currentPage], as a ratio of the page width.
140      *
141      * To update the scroll position, use [scrollToPage] or [animateScrollToPage].
142      */
<lambda>null143     val currentPageOffset: Float by derivedStateOf {
144         currentLayoutPageInfo?.let {
145             // The current page offset is the current layout page delta from `currentPage`
146             // (which is only updated after a scroll/animation).
147             // We calculate this by looking at the current layout page + it's offset,
148             // then subtracting the 'current page'.
149             it.index + currentLayoutPageOffset - _currentPage
150         }
151             ?: 0f
152     }
153 
154     /** The target page for any on-going animations. */
155     private var animationTargetPage: Int? by mutableStateOf(null)
156 
157     internal var flingAnimationTarget: (() -> Int?)? by mutableStateOf(null)
158 
159     /**
160      * The target page for any on-going animations or scrolls by the user. Returns the current page
161      * if a scroll or animation is not currently in progress.
162      */
163     val targetPage: Int
164         get() =
165             animationTargetPage
166                 ?: flingAnimationTarget?.invoke()
167                     ?: when {
168                     // If a scroll isn't in progress, return the current page
169                     !isScrollInProgress -> currentPage
170                     // If the offset is 0f (or very close), return the current page
171                     currentPageOffset.absoluteValue < 0.001f -> currentPage
172                     // If we're offset towards the start, guess the previous page
173                     currentPageOffset < -0.5f -> (currentPage - 1).coerceAtLeast(0)
174                     // If we're offset towards the end, guess the next page
175                     else -> (currentPage + 1).coerceAtMost(pageCount - 1)
176                 }
177 
178     @Deprecated(
179         "Replaced with animateScrollToPage(page, pageOffset)",
180         ReplaceWith("animateScrollToPage(page = page, pageOffset = pageOffset)")
181     )
182     @Suppress("UNUSED_PARAMETER")
animateScrollToPagenull183     suspend fun animateScrollToPage(
184         @IntRange(from = 0) page: Int,
185         @FloatRange(from = 0.0, to = 1.0) pageOffset: Float = 0f,
186         animationSpec: AnimationSpec<Float> = spring(),
187         initialVelocity: Float = 0f,
188         skipPages: Boolean = true,
189     ) {
190         animateScrollToPage(page = page, pageOffset = pageOffset)
191     }
192 
193     /**
194      * Animate (smooth scroll) to the given page to the middle of the viewport.
195      *
196      * Cancels the currently running scroll, if any, and suspends until the cancellation is
197      * complete.
198      *
199      * @param page the page to animate to. Must be between 0 and [pageCount] (inclusive).
200      * @param pageOffset the percentage of the page width to offset, from the start of [page]. Must
201      *   be in the range 0f..1f.
202      */
animateScrollToPagenull203     suspend fun animateScrollToPage(
204         @IntRange(from = 0) page: Int,
205         @FloatRange(from = 0.0, to = 1.0) pageOffset: Float = 0f,
206     ) {
207         requireCurrentPage(page, "page")
208         requireCurrentPageOffset(pageOffset, "pageOffset")
209         try {
210             animationTargetPage = page
211 
212             if (pageOffset <= 0.005f) {
213                 // If the offset is (close to) zero, just call animateScrollToItem and we're done
214                 lazyListState.animateScrollToItem(index = page)
215             } else {
216                 // Else we need to figure out what the offset is in pixels...
217 
218                 var target =
219                     lazyListState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == page }
220 
221                 if (target != null) {
222                     // If we have access to the target page layout, we can calculate the pixel
223                     // offset from the size
224                     lazyListState.animateScrollToItem(
225                         index = page,
226                         scrollOffset = (target.size * pageOffset).roundToInt()
227                     )
228                 } else {
229                     // If we don't, we use the current page size as a guide
230                     val currentSize = currentLayoutPageInfo!!.size
231                     lazyListState.animateScrollToItem(
232                         index = page,
233                         scrollOffset = (currentSize * pageOffset).roundToInt()
234                     )
235 
236                     // The target should be visible now
237                     target = lazyListState.layoutInfo.visibleItemsInfo.first { it.index == page }
238 
239                     if (target.size != currentSize) {
240                         // If the size we used for calculating the offset differs from the actual
241                         // target page size, we need to scroll again. This doesn't look great,
242                         // but there's not much else we can do.
243                         lazyListState.animateScrollToItem(
244                             index = page,
245                             scrollOffset = (target.size * pageOffset).roundToInt()
246                         )
247                     }
248                 }
249             }
250         } finally {
251             // We need to manually call this, as the `animateScrollToItem` call above will happen
252             // in 1 frame, which is usually too fast for the LaunchedEffect in Pager to detect
253             // the change. This is especially true when running unit tests.
254             onScrollFinished()
255         }
256     }
257 
258     /**
259      * Instantly brings the item at [page] to the middle of the viewport.
260      *
261      * Cancels the currently running scroll, if any, and suspends until the cancellation is
262      * complete.
263      *
264      * @param page the page to snap to. Must be between 0 and [pageCount] (inclusive).
265      */
scrollToPagenull266     suspend fun scrollToPage(
267         @IntRange(from = 0) page: Int,
268         @FloatRange(from = 0.0, to = 1.0) pageOffset: Float = 0f,
269     ) {
270         requireCurrentPage(page, "page")
271         requireCurrentPageOffset(pageOffset, "pageOffset")
272         try {
273             animationTargetPage = page
274 
275             // First scroll to the given page. It will now be laid out at offset 0
276             lazyListState.scrollToItem(index = page)
277 
278             // If we have a start spacing, we need to offset (scroll) by that too
279             if (pageOffset > 0.0001f) {
280                 scroll { currentLayoutPageInfo?.let { scrollBy(it.size * pageOffset) } }
281             }
282         } finally {
283             // We need to manually call this, as the `scroll` call above will happen in 1 frame,
284             // which is usually too fast for the LaunchedEffect in Pager to detect the change.
285             // This is especially true when running unit tests.
286             onScrollFinished()
287         }
288     }
289 
onScrollFinishednull290     internal fun onScrollFinished() {
291         // Then update the current page to our layout page
292         currentPage = currentLayoutPageInfo?.index ?: 0
293         // Clear the animation target page
294         animationTargetPage = null
295     }
296 
scrollnull297     override suspend fun scroll(
298         scrollPriority: MutatePriority,
299         block: suspend ScrollScope.() -> Unit
300     ) = lazyListState.scroll(scrollPriority, block)
301 
302     override fun dispatchRawDelta(delta: Float): Float {
303         return lazyListState.dispatchRawDelta(delta)
304     }
305 
306     override val isScrollInProgress: Boolean
307         get() = lazyListState.isScrollInProgress
308 
toStringnull309     override fun toString(): String =
310         "PagerState(" +
311             "pageCount=$pageCount, " +
312             "currentPage=$currentPage, " +
313             "currentPageOffset=$currentPageOffset" +
314             ")"
315 
316     private fun requireCurrentPage(value: Int, name: String) {
317         if (pageCount == 0) {
318             require(value == 0) { "$name must be 0 when pageCount is 0" }
319         } else {
320             require(value in 0 until pageCount) { "$name[$value] must be >= 0 and < pageCount" }
321         }
322     }
323 
requireCurrentPageOffsetnull324     private fun requireCurrentPageOffset(value: Float, name: String) {
325         if (pageCount == 0) {
326             require(value == 0f) { "$name must be 0f when pageCount is 0" }
327         } else {
328             require(value in 0f..1f) { "$name must be >= 0 and <= 1" }
329         }
330     }
331 
332     companion object {
333         /** The default [Saver] implementation for [PagerState]. */
334         val Saver: Saver<PagerState, *> =
335             listSaver(
<lambda>null336                 save = {
337                     listOf<Any>(
338                         it.currentPage,
339                     )
340                 },
<lambda>null341                 restore = {
342                     PagerState(
343                         currentPage = it[0] as Int,
344                     )
345                 }
346             )
347     }
348 }
349