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