commonMain.com.google.accompanist.pager.PagerState.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of ktx-compose-accompanist Show documentation
Show all versions of ktx-compose-accompanist Show documentation
Extensions for the Kotlin standard library and third-party libraries.
The newest version!
/*
* 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.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.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import io.github.aakira.napier.DebugAntilog
import io.github.aakira.napier.Napier
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
/**
* Creates a [PagerState] that is remembered across compositions.
*
* Changes to the provided values for [initialPage] will **not** result in the state being
* recreated or changed in any way if it has already
* been created.
*
* @param initialPage the initial value for [PagerState.currentPage]
*/
@ExperimentalPagerApi
@Composable
fun rememberPagerState(
/*@IntRange(from = 0)*/
initialPage: Int = 0,
): PagerState = rememberSaveable(saver = PagerState.Saver) {
PagerState(
currentPage = initialPage,
)
}
/**
* A state object that can be hoisted to control and observe scrolling for [HorizontalPager].
*
* In most cases, this will be created via [rememberPagerState].
*
* @param currentPage the initial value for [PagerState.currentPage]
*/
@ExperimentalPagerApi
@Stable
class PagerState(
/*@IntRange(from = 0)*/
currentPage: Int = 0,
) : ScrollableState {
// Should this be public?
internal val lazyListState = LazyListState(firstVisibleItemIndex = currentPage)
private var _currentPage by mutableStateOf(currentPage)
internal val currentLayoutPageInfo: LazyListItemInfo?
get() = lazyListState.layoutInfo.visibleItemsInfo.lastOrNull { it.offset <= 0 }
private val currentLayoutPageOffset: Float
get() = currentLayoutPageInfo?.let { current ->
// We coerce since itemSpacing can make the offset > 1f.
// We don't want to count spacing in the offset so cap it to 1f
(-current.offset / current.size.toFloat()).coerceIn(0f, 1f)
} ?: 0f
/**
* [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() = lazyListState.interactionSource
/**
* The number of pages to display.
*/
/*@get:IntRange(from = 0)*/
val pageCount: Int by derivedStateOf {
lazyListState.layoutInfo.totalItemsCount
}
/**
* 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
internal set(value) {
if (value != _currentPage) {
_currentPage = value
if (DebugLog) {
Napier.d(message = "Current page changed: $_currentPage")
}
}
}
/**
* The current offset from the start of [currentPage], as a ratio of the page width.
*
* To update the scroll position, use [scrollToPage] or [animateScrollToPage].
*/
val currentPageOffset: Float by derivedStateOf {
currentLayoutPageInfo?.let {
// The current page offset is the current layout page delta from `currentPage`
// (which is only updated after a scroll/animation).
// We calculate this by looking at the current layout page + it's offset,
// then subtracting the 'current page'.
it.index + currentLayoutPageOffset - _currentPage
} ?: 0f
}
/**
* The target page for any on-going animations.
*/
private var animationTargetPage: Int? by mutableStateOf(null)
internal var flingAnimationTarget: (() -> 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
?: flingAnimationTarget?.invoke()
?: 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 < 0f -> (currentPage - 1).coerceAtLeast(0)
// If we're offset towards the end, guess the next page
else -> (currentPage + 1).coerceAtMost(pageCount - 1)
}
@Deprecated(
"Replaced with animateScrollToPage(page, pageOffset)",
ReplaceWith("animateScrollToPage(page = page, pageOffset = pageOffset)")
)
@Suppress("UNUSED_PARAMETER")
suspend fun animateScrollToPage(
/*@IntRange(from = 0)*/
page: Int,
/*@FloatRange(from = 0.0, to = 1.0)*/
pageOffset: Float = 0f,
animationSpec: AnimationSpec = spring(),
initialVelocity: Float = 0f,
skipPages: Boolean = true,
) {
animateScrollToPage(page = page, pageOffset = pageOffset)
}
/**
* Animate (smooth scroll) to the given page to the middle of the viewport.
*
* 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.
*/
suspend fun animateScrollToPage(
/*@IntRange(from = 0)*/
page: Int,
/*@FloatRange(from = 0.0, to = 1.0)*/
pageOffset: Float = 0f,
) {
requireCurrentPage(page, "page")
requireCurrentPageOffset(pageOffset, "pageOffset")
try {
animationTargetPage = page
if (pageOffset <= 0.005f) {
// If the offset is (close to) zero, just call animateScrollToItem and we're done
lazyListState.animateScrollToItem(index = page)
} else {
// Else we need to figure out what the offset is in pixels...
var target = lazyListState.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == page }
if (target != null) {
// If we have access to the target page layout, we can calculate the pixel
// offset from the size
lazyListState.animateScrollToItem(
index = page,
scrollOffset = (target.size * pageOffset).roundToInt()
)
} else {
// If we don't, we use the current page size as a guide
val currentSize = currentLayoutPageInfo!!.size
lazyListState.animateScrollToItem(
index = page,
scrollOffset = (currentSize * pageOffset).roundToInt()
)
// The target should be visible now
target = lazyListState.layoutInfo.visibleItemsInfo.first { it.index == page }
if (target.size != currentSize) {
// If the size we used for calculating the offset differs from the actual
// target page size, we need to scroll again. This doesn't look great,
// but there's not much else we can do.
lazyListState.animateScrollToItem(
index = page,
scrollOffset = (target.size * pageOffset).roundToInt()
)
}
}
}
} finally {
// We need to manually call this, as the `animateScrollToItem` call above will happen
// in 1 frame, which is usually too fast for the LaunchedEffect in Pager to detect
// the change. This is especially true when running unit tests.
onScrollFinished()
}
}
/**
* Instantly brings the item at [page] to the middle of the viewport.
*
* 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).
*/
suspend fun scrollToPage(
/*@IntRange(from = 0)*/
page: Int,
/*@FloatRange(from = 0.0, to = 1.0)*/
pageOffset: Float = 0f,
) {
requireCurrentPage(page, "page")
requireCurrentPageOffset(pageOffset, "pageOffset")
try {
animationTargetPage = page
// First scroll to the given page. It will now be laid out at offset 0
lazyListState.scrollToItem(index = page)
// If we have a start spacing, we need to offset (scroll) by that too
if (pageOffset > 0.0001f) {
scroll {
currentLayoutPageInfo?.let {
scrollBy(it.size * pageOffset)
}
}
}
} finally {
// We need to manually call this, as the `scroll` call above will happen in 1 frame,
// which is usually too fast for the LaunchedEffect in Pager to detect the change.
// This is especially true when running unit tests.
onScrollFinished()
}
}
internal fun updateCurrentPageBasedOnLazyListState() {
// Then update the current page to our layout page
currentPage = currentLayoutPageInfo?.index ?: 0
}
internal fun onScrollFinished() {
updateCurrentPageBasedOnLazyListState()
// Clear the animation target page
animationTargetPage = null
}
override suspend fun scroll(
scrollPriority: MutatePriority,
block: suspend ScrollScope.() -> Unit
) = lazyListState.scroll(scrollPriority, block)
override fun dispatchRawDelta(delta: Float): Float {
return lazyListState.dispatchRawDelta(delta)
}
override val isScrollInProgress: Boolean
get() = lazyListState.isScrollInProgress
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 0 until pageCount) {
"$name[$value] must be >= 0 and < pageCount"
}
}
}
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" }
}
}
companion object {
/**
* The default [Saver] implementation for [PagerState].
*/
val Saver: Saver = listSaver(
save = {
listOf(
it.currentPage,
)
},
restore = {
PagerState(
currentPage = it[0] as Int,
)
}
)
init {
if (DebugLog) {
Napier.base(DebugAntilog(defaultTag = "Pager"))
}
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy