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