Fork Pager from Accompanist
This CL forks the Pager API from Accompanist. Note that I took the
version 0.20.0 of Accompanist [1], which is the version right before the
Pager API starts depending on dev.chrisbanes.snapper [1], which I'm not
sure is ok to fork in the platform.
[1] https://github.com/google/accompanist/releases/tag/v0.20.0
[2] 498301e4d4
Bug: 247473910
Test: Manual
Change-Id: I57947a196be84af9c34365377588e50b8f249af0
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
/*
|
||||
* Copyright (C) 2022 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
|
||||
*
|
||||
* http://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.
|
||||
*/
|
||||
|
||||
package com.android.systemui.compose.layout.pager
|
||||
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.DecayAnimationSpec
|
||||
import androidx.compose.animation.rememberSplineBasedDecay
|
||||
import androidx.compose.foundation.gestures.FlingBehavior
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.filter
|
||||
|
||||
/** Library-wide switch to turn on debug logging. */
|
||||
internal const val DebugLog = false
|
||||
|
||||
@RequiresOptIn(message = "Accompanist Pager is experimental. The API may be changed in the future.")
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class ExperimentalPagerApi
|
||||
|
||||
/** Contains the default values used by [HorizontalPager] and [VerticalPager]. */
|
||||
@ExperimentalPagerApi
|
||||
object PagerDefaults {
|
||||
/**
|
||||
* Remember the default [FlingBehavior] that represents the scroll curve.
|
||||
*
|
||||
* @param state The [PagerState] to update.
|
||||
* @param decayAnimationSpec The decay animation spec to use for decayed flings.
|
||||
* @param snapAnimationSpec The animation spec to use when snapping.
|
||||
*/
|
||||
@Composable
|
||||
fun flingBehavior(
|
||||
state: PagerState,
|
||||
decayAnimationSpec: DecayAnimationSpec<Float> = rememberSplineBasedDecay(),
|
||||
snapAnimationSpec: AnimationSpec<Float> = SnappingFlingBehaviorDefaults.snapAnimationSpec,
|
||||
): FlingBehavior =
|
||||
rememberSnappingFlingBehavior(
|
||||
lazyListState = state.lazyListState,
|
||||
decayAnimationSpec = decayAnimationSpec,
|
||||
snapAnimationSpec = snapAnimationSpec,
|
||||
)
|
||||
|
||||
@Deprecated(
|
||||
"Replaced with PagerDefaults.flingBehavior()",
|
||||
ReplaceWith("PagerDefaults.flingBehavior(state, decayAnimationSpec, snapAnimationSpec)")
|
||||
)
|
||||
@Composable
|
||||
fun rememberPagerFlingConfig(
|
||||
state: PagerState,
|
||||
decayAnimationSpec: DecayAnimationSpec<Float> = rememberSplineBasedDecay(),
|
||||
snapAnimationSpec: AnimationSpec<Float> = SnappingFlingBehaviorDefaults.snapAnimationSpec,
|
||||
): FlingBehavior = flingBehavior(state, decayAnimationSpec, snapAnimationSpec)
|
||||
}
|
||||
|
||||
/**
|
||||
* A horizontally scrolling layout that allows users to flip between items to the left and right.
|
||||
*
|
||||
* @sample com.google.accompanist.sample.pager.HorizontalPagerSample
|
||||
*
|
||||
* @param count the number of pages.
|
||||
* @param modifier the modifier to apply to this layout.
|
||||
* @param state the state object to be used to control or observe the pager's state.
|
||||
* @param reverseLayout reverse the direction of scrolling and layout, when `true` items will be
|
||||
* composed from the end to the start and [PagerState.currentPage] == 0 will mean the first item is
|
||||
* located at the end.
|
||||
* @param itemSpacing horizontal spacing to add between items.
|
||||
* @param flingBehavior logic describing fling behavior.
|
||||
* @param key the scroll position will be maintained based on the key, which means if you add/remove
|
||||
* items before the current visible item the item with the given key will be kept as the first
|
||||
* visible one.
|
||||
* @param content a block which describes the content. Inside this block you can reference
|
||||
* [PagerScope.currentPage] and other properties in [PagerScope].
|
||||
*/
|
||||
@ExperimentalPagerApi
|
||||
@Composable
|
||||
fun HorizontalPager(
|
||||
count: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
state: PagerState = rememberPagerState(),
|
||||
reverseLayout: Boolean = false,
|
||||
itemSpacing: Dp = 0.dp,
|
||||
flingBehavior: FlingBehavior = PagerDefaults.flingBehavior(state),
|
||||
verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
|
||||
key: ((page: Int) -> Any)? = null,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
content: @Composable PagerScope.(page: Int) -> Unit,
|
||||
) {
|
||||
Pager(
|
||||
count = count,
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
isVertical = false,
|
||||
reverseLayout = reverseLayout,
|
||||
itemSpacing = itemSpacing,
|
||||
verticalAlignment = verticalAlignment,
|
||||
flingBehavior = flingBehavior,
|
||||
key = key,
|
||||
contentPadding = contentPadding,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A vertically scrolling layout that allows users to flip between items to the top and bottom.
|
||||
*
|
||||
* @sample com.google.accompanist.sample.pager.VerticalPagerSample
|
||||
*
|
||||
* @param count the number of pages.
|
||||
* @param modifier the modifier to apply to this layout.
|
||||
* @param state the state object to be used to control or observe the pager's state.
|
||||
* @param reverseLayout reverse the direction of scrolling and layout, when `true` items will be
|
||||
* composed from the bottom to the top and [PagerState.currentPage] == 0 will mean the first item is
|
||||
* located at the bottom.
|
||||
* @param itemSpacing vertical spacing to add between items.
|
||||
* @param flingBehavior logic describing fling behavior.
|
||||
* @param key the scroll position will be maintained based on the key, which means if you add/remove
|
||||
* items before the current visible item the item with the given key will be kept as the first
|
||||
* visible one.
|
||||
* @param content a block which describes the content. Inside this block you can reference
|
||||
* [PagerScope.currentPage] and other properties in [PagerScope].
|
||||
*/
|
||||
@ExperimentalPagerApi
|
||||
@Composable
|
||||
fun VerticalPager(
|
||||
count: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
state: PagerState = rememberPagerState(),
|
||||
reverseLayout: Boolean = false,
|
||||
itemSpacing: Dp = 0.dp,
|
||||
flingBehavior: FlingBehavior = PagerDefaults.flingBehavior(state),
|
||||
horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
|
||||
key: ((page: Int) -> Any)? = null,
|
||||
contentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
content: @Composable PagerScope.(page: Int) -> Unit,
|
||||
) {
|
||||
Pager(
|
||||
count = count,
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
isVertical = true,
|
||||
reverseLayout = reverseLayout,
|
||||
itemSpacing = itemSpacing,
|
||||
horizontalAlignment = horizontalAlignment,
|
||||
flingBehavior = flingBehavior,
|
||||
key = key,
|
||||
contentPadding = contentPadding,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalPagerApi
|
||||
@Composable
|
||||
internal fun Pager(
|
||||
count: Int,
|
||||
modifier: Modifier,
|
||||
state: PagerState,
|
||||
reverseLayout: Boolean,
|
||||
itemSpacing: Dp,
|
||||
isVertical: Boolean,
|
||||
flingBehavior: FlingBehavior,
|
||||
key: ((page: Int) -> Any)?,
|
||||
contentPadding: PaddingValues,
|
||||
verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
|
||||
horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally,
|
||||
content: @Composable PagerScope.(page: Int) -> Unit,
|
||||
) {
|
||||
require(count >= 0) { "pageCount must be >= 0" }
|
||||
|
||||
// Provide our PagerState with access to the SnappingFlingBehavior animation target
|
||||
// TODO: can this be done in a better way?
|
||||
state.flingAnimationTarget = { (flingBehavior as? SnappingFlingBehavior)?.animationTarget }
|
||||
|
||||
LaunchedEffect(count) {
|
||||
state.currentPage = minOf(count - 1, state.currentPage).coerceAtLeast(0)
|
||||
}
|
||||
|
||||
// Once a fling (scroll) has finished, notify the state
|
||||
LaunchedEffect(state) {
|
||||
// When a 'scroll' has finished, notify the state
|
||||
snapshotFlow { state.isScrollInProgress }
|
||||
.filter { !it }
|
||||
.collect { state.onScrollFinished() }
|
||||
}
|
||||
|
||||
val pagerScope = remember(state) { PagerScopeImpl(state) }
|
||||
|
||||
// We only consume nested flings in the main-axis, allowing cross-axis flings to propagate
|
||||
// as normal
|
||||
val consumeFlingNestedScrollConnection =
|
||||
ConsumeFlingNestedScrollConnection(
|
||||
consumeHorizontal = !isVertical,
|
||||
consumeVertical = isVertical,
|
||||
)
|
||||
|
||||
if (isVertical) {
|
||||
LazyColumn(
|
||||
state = state.lazyListState,
|
||||
verticalArrangement = Arrangement.spacedBy(itemSpacing, verticalAlignment),
|
||||
horizontalAlignment = horizontalAlignment,
|
||||
flingBehavior = flingBehavior,
|
||||
reverseLayout = reverseLayout,
|
||||
contentPadding = contentPadding,
|
||||
modifier = modifier,
|
||||
) {
|
||||
items(
|
||||
count = count,
|
||||
key = key,
|
||||
) { page ->
|
||||
Box(
|
||||
Modifier
|
||||
// We don't any nested flings to continue in the pager, so we add a
|
||||
// connection which consumes them.
|
||||
// See: https://github.com/google/accompanist/issues/347
|
||||
.nestedScroll(connection = consumeFlingNestedScrollConnection)
|
||||
// Constraint the content to be <= than the size of the pager.
|
||||
.fillParentMaxHeight()
|
||||
.wrapContentSize()
|
||||
) { pagerScope.content(page) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyRow(
|
||||
state = state.lazyListState,
|
||||
verticalAlignment = verticalAlignment,
|
||||
horizontalArrangement = Arrangement.spacedBy(itemSpacing, horizontalAlignment),
|
||||
flingBehavior = flingBehavior,
|
||||
reverseLayout = reverseLayout,
|
||||
contentPadding = contentPadding,
|
||||
modifier = modifier,
|
||||
) {
|
||||
items(
|
||||
count = count,
|
||||
key = key,
|
||||
) { page ->
|
||||
Box(
|
||||
Modifier
|
||||
// We don't any nested flings to continue in the pager, so we add a
|
||||
// connection which consumes them.
|
||||
// See: https://github.com/google/accompanist/issues/347
|
||||
.nestedScroll(connection = consumeFlingNestedScrollConnection)
|
||||
// Constraint the content to be <= than the size of the pager.
|
||||
.fillParentMaxWidth()
|
||||
.wrapContentSize()
|
||||
) { pagerScope.content(page) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ConsumeFlingNestedScrollConnection(
|
||||
private val consumeHorizontal: Boolean,
|
||||
private val consumeVertical: Boolean,
|
||||
) : NestedScrollConnection {
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset =
|
||||
when (source) {
|
||||
// We can consume all resting fling scrolls so that they don't propagate up to the
|
||||
// Pager
|
||||
NestedScrollSource.Fling -> available.consume(consumeHorizontal, consumeVertical)
|
||||
else -> Offset.Zero
|
||||
}
|
||||
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
// We can consume all post fling velocity on the main-axis
|
||||
// so that it doesn't propagate up to the Pager
|
||||
return available.consume(consumeHorizontal, consumeVertical)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Offset.consume(
|
||||
consumeHorizontal: Boolean,
|
||||
consumeVertical: Boolean,
|
||||
): Offset =
|
||||
Offset(
|
||||
x = if (consumeHorizontal) this.x else 0f,
|
||||
y = if (consumeVertical) this.y else 0f,
|
||||
)
|
||||
|
||||
private fun Velocity.consume(
|
||||
consumeHorizontal: Boolean,
|
||||
consumeVertical: Boolean,
|
||||
): Velocity =
|
||||
Velocity(
|
||||
x = if (consumeHorizontal) this.x else 0f,
|
||||
y = if (consumeVertical) this.y else 0f,
|
||||
)
|
||||
|
||||
/** Scope for [HorizontalPager] content. */
|
||||
@ExperimentalPagerApi
|
||||
@Stable
|
||||
interface PagerScope {
|
||||
/** Returns the current selected page */
|
||||
val currentPage: Int
|
||||
|
||||
/** The current offset from the start of [currentPage], as a ratio of the page width. */
|
||||
val currentPageOffset: Float
|
||||
}
|
||||
|
||||
@ExperimentalPagerApi
|
||||
private class PagerScopeImpl(
|
||||
private val state: PagerState,
|
||||
) : PagerScope {
|
||||
override val currentPage: Int
|
||||
get() = state.currentPage
|
||||
override val currentPageOffset: Float
|
||||
get() = state.currentPageOffset
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the offset for the given [page] from the current scroll position. This is useful when
|
||||
* using the scroll position to apply effects or animations to items.
|
||||
*
|
||||
* The returned offset can positive or negative, depending on whether which direction the [page] is
|
||||
* compared to the current scroll position.
|
||||
*
|
||||
* @sample com.google.accompanist.sample.pager.HorizontalPagerWithOffsetTransition
|
||||
*/
|
||||
@ExperimentalPagerApi
|
||||
fun PagerScope.calculateCurrentOffsetForPage(page: Int): Float {
|
||||
return (currentPage + currentPageOffset) - page
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
/*
|
||||
* Copyright (C) 2022 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
|
||||
*
|
||||
* http://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.
|
||||
*/
|
||||
|
||||
package com.android.systemui.compose.layout.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.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Deprecated(
|
||||
"Replaced with rememberPagerState(initialPage) and count parameter on Pager composables",
|
||||
ReplaceWith("rememberPagerState(initialPage)"),
|
||||
level = DeprecationLevel.ERROR,
|
||||
)
|
||||
@Suppress("UNUSED_PARAMETER", "NOTHING_TO_INLINE")
|
||||
@ExperimentalPagerApi
|
||||
@Composable
|
||||
inline fun rememberPagerState(
|
||||
@IntRange(from = 0) pageCount: Int,
|
||||
@IntRange(from = 0) initialPage: Int = 0,
|
||||
@FloatRange(from = 0.0, to = 1.0) initialPageOffset: Float = 0f,
|
||||
@IntRange(from = 1) initialOffscreenLimit: Int = 1,
|
||||
infiniteLoop: Boolean = false
|
||||
): PagerState {
|
||||
return rememberPagerState(initialPage = initialPage)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
private val currentLayoutPageInfo: LazyListItemInfo?
|
||||
get() =
|
||||
lazyListState.layoutInfo.visibleItemsInfo
|
||||
.asSequence()
|
||||
.filter { it.offset <= 0 && it.offset + it.size > 0 }
|
||||
.lastOrNull()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 < -0.5f -> (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<Float> = 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 onScrollFinished() {
|
||||
// Then update the current page to our layout page
|
||||
currentPage = currentLayoutPageInfo?.index ?: 0
|
||||
// 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<PagerState, *> =
|
||||
listSaver(
|
||||
save = {
|
||||
listOf<Any>(
|
||||
it.currentPage,
|
||||
)
|
||||
},
|
||||
restore = {
|
||||
PagerState(
|
||||
currentPage = it[0] as Int,
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/*
|
||||
* Copyright (C) 2022 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
|
||||
*
|
||||
* http://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.
|
||||
*/
|
||||
|
||||
package com.android.systemui.compose.layout.pager
|
||||
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.AnimationState
|
||||
import androidx.compose.animation.core.DecayAnimationSpec
|
||||
import androidx.compose.animation.core.animateDecay
|
||||
import androidx.compose.animation.core.animateTo
|
||||
import androidx.compose.animation.core.calculateTargetValue
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.rememberSplineBasedDecay
|
||||
import androidx.compose.foundation.gestures.FlingBehavior
|
||||
import androidx.compose.foundation.gestures.ScrollScope
|
||||
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import kotlin.math.abs
|
||||
|
||||
/** Default values used for [SnappingFlingBehavior] & [rememberSnappingFlingBehavior]. */
|
||||
internal object SnappingFlingBehaviorDefaults {
|
||||
/** TODO */
|
||||
val snapAnimationSpec: AnimationSpec<Float> = spring(stiffness = 600f)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and remember a snapping [FlingBehavior] to be used with [LazyListState].
|
||||
*
|
||||
* TODO: move this to a new module and make it public
|
||||
*
|
||||
* @param lazyListState The [LazyListState] to update.
|
||||
* @param decayAnimationSpec The decay animation spec to use for decayed flings.
|
||||
* @param snapAnimationSpec The animation spec to use when snapping.
|
||||
*/
|
||||
@Composable
|
||||
internal fun rememberSnappingFlingBehavior(
|
||||
lazyListState: LazyListState,
|
||||
decayAnimationSpec: DecayAnimationSpec<Float> = rememberSplineBasedDecay(),
|
||||
snapAnimationSpec: AnimationSpec<Float> = SnappingFlingBehaviorDefaults.snapAnimationSpec,
|
||||
): SnappingFlingBehavior =
|
||||
remember(lazyListState, decayAnimationSpec, snapAnimationSpec) {
|
||||
SnappingFlingBehavior(
|
||||
lazyListState = lazyListState,
|
||||
decayAnimationSpec = decayAnimationSpec,
|
||||
snapAnimationSpec = snapAnimationSpec,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A snapping [FlingBehavior] for [LazyListState]. Typically this would be created via
|
||||
* [rememberSnappingFlingBehavior].
|
||||
*
|
||||
* @param lazyListState The [LazyListState] to update.
|
||||
* @param decayAnimationSpec The decay animation spec to use for decayed flings.
|
||||
* @param snapAnimationSpec The animation spec to use when snapping.
|
||||
*/
|
||||
internal class SnappingFlingBehavior(
|
||||
private val lazyListState: LazyListState,
|
||||
private val decayAnimationSpec: DecayAnimationSpec<Float>,
|
||||
private val snapAnimationSpec: AnimationSpec<Float>,
|
||||
) : FlingBehavior {
|
||||
/** The target item index for any on-going animations. */
|
||||
var animationTarget: Int? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
|
||||
val itemInfo = currentItemInfo ?: return initialVelocity
|
||||
|
||||
// If the decay fling can scroll past the current item, fling with decay
|
||||
return if (decayAnimationSpec.canFlingPastCurrentItem(itemInfo, initialVelocity)) {
|
||||
performDecayFling(initialVelocity, itemInfo)
|
||||
} else {
|
||||
// Otherwise we 'spring' to current/next item
|
||||
performSpringFling(
|
||||
index =
|
||||
when {
|
||||
// If the velocity is greater than 1 item per second (velocity is px/s),
|
||||
// spring
|
||||
// in the relevant direction
|
||||
initialVelocity > itemInfo.size -> {
|
||||
(itemInfo.index + 1).coerceAtMost(
|
||||
lazyListState.layoutInfo.totalItemsCount - 1
|
||||
)
|
||||
}
|
||||
initialVelocity < -itemInfo.size -> itemInfo.index
|
||||
// If the velocity is 0 (or less than the size of the item), spring to
|
||||
// whichever item is closest to the snap point
|
||||
itemInfo.offset < -itemInfo.size / 2 -> itemInfo.index + 1
|
||||
else -> itemInfo.index
|
||||
},
|
||||
initialVelocity = initialVelocity,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ScrollScope.performDecayFling(
|
||||
initialVelocity: Float,
|
||||
startItem: LazyListItemInfo,
|
||||
): Float {
|
||||
val index =
|
||||
when {
|
||||
initialVelocity > 0 -> startItem.index + 1
|
||||
else -> startItem.index
|
||||
}
|
||||
val forward = index > (currentItemInfo?.index ?: return initialVelocity)
|
||||
|
||||
// Update the animationTarget
|
||||
animationTarget = index
|
||||
|
||||
var velocityLeft = initialVelocity
|
||||
var lastValue = 0f
|
||||
AnimationState(
|
||||
initialValue = 0f,
|
||||
initialVelocity = initialVelocity,
|
||||
)
|
||||
.animateDecay(decayAnimationSpec) {
|
||||
val delta = value - lastValue
|
||||
val consumed = scrollBy(delta)
|
||||
lastValue = value
|
||||
velocityLeft = this.velocity
|
||||
|
||||
val current = currentItemInfo
|
||||
if (current == null) {
|
||||
cancelAnimation()
|
||||
return@animateDecay
|
||||
}
|
||||
|
||||
if (
|
||||
!forward &&
|
||||
(current.index < index || current.index == index && current.offset >= 0)
|
||||
) {
|
||||
// 'snap back' to the item as we may have scrolled past it
|
||||
scrollBy(lazyListState.calculateScrollOffsetToItem(index).toFloat())
|
||||
cancelAnimation()
|
||||
} else if (
|
||||
forward &&
|
||||
(current.index > index || current.index == index && current.offset <= 0)
|
||||
) {
|
||||
// 'snap back' to the item as we may have scrolled past it
|
||||
scrollBy(lazyListState.calculateScrollOffsetToItem(index).toFloat())
|
||||
cancelAnimation()
|
||||
} else if (abs(delta - consumed) > 0.5f) {
|
||||
// avoid rounding errors and stop if anything is unconsumed
|
||||
cancelAnimation()
|
||||
}
|
||||
}
|
||||
animationTarget = null
|
||||
return velocityLeft
|
||||
}
|
||||
|
||||
private suspend fun ScrollScope.performSpringFling(
|
||||
index: Int,
|
||||
scrollOffset: Int = 0,
|
||||
initialVelocity: Float = 0f,
|
||||
): Float {
|
||||
// If we don't have a current layout, we can't snap
|
||||
val initialItem = currentItemInfo ?: return initialVelocity
|
||||
|
||||
val forward = index > initialItem.index
|
||||
// We add 10% on to the size of the current item, to compensate for any item spacing, etc
|
||||
val target = (if (forward) initialItem.size else -initialItem.size) * 1.1f
|
||||
|
||||
// Update the animationTarget
|
||||
animationTarget = index
|
||||
|
||||
var velocityLeft = initialVelocity
|
||||
var lastValue = 0f
|
||||
AnimationState(
|
||||
initialValue = 0f,
|
||||
initialVelocity = initialVelocity,
|
||||
)
|
||||
.animateTo(
|
||||
targetValue = target,
|
||||
animationSpec = snapAnimationSpec,
|
||||
) {
|
||||
// Springs can overshoot their target, clamp to the desired range
|
||||
val coercedValue =
|
||||
if (forward) {
|
||||
value.coerceAtMost(target)
|
||||
} else {
|
||||
value.coerceAtLeast(target)
|
||||
}
|
||||
val delta = coercedValue - lastValue
|
||||
val consumed = scrollBy(delta)
|
||||
lastValue = coercedValue
|
||||
velocityLeft = this.velocity
|
||||
|
||||
val current = currentItemInfo
|
||||
if (current == null) {
|
||||
cancelAnimation()
|
||||
return@animateTo
|
||||
}
|
||||
|
||||
if (scrolledPastItem(initialVelocity, current, index, scrollOffset)) {
|
||||
// If we've scrolled to/past the item, stop the animation. We may also need to
|
||||
// 'snap back' to the item as we may have scrolled past it
|
||||
scrollBy(lazyListState.calculateScrollOffsetToItem(index).toFloat())
|
||||
cancelAnimation()
|
||||
} else if (abs(delta - consumed) > 0.5f) {
|
||||
// avoid rounding errors and stop if anything is unconsumed
|
||||
cancelAnimation()
|
||||
}
|
||||
}
|
||||
animationTarget = null
|
||||
return velocityLeft
|
||||
}
|
||||
|
||||
private fun LazyListState.calculateScrollOffsetToItem(index: Int): Int {
|
||||
return layoutInfo.visibleItemsInfo.firstOrNull { it.index == index }?.offset ?: 0
|
||||
}
|
||||
|
||||
private val currentItemInfo: LazyListItemInfo?
|
||||
get() =
|
||||
lazyListState.layoutInfo.visibleItemsInfo
|
||||
.asSequence()
|
||||
.filter { it.offset <= 0 && it.offset + it.size > 0 }
|
||||
.lastOrNull()
|
||||
}
|
||||
|
||||
private fun scrolledPastItem(
|
||||
initialVelocity: Float,
|
||||
currentItem: LazyListItemInfo,
|
||||
targetIndex: Int,
|
||||
targetScrollOffset: Int = 0,
|
||||
): Boolean {
|
||||
return if (initialVelocity > 0) {
|
||||
// forward
|
||||
currentItem.index > targetIndex ||
|
||||
(currentItem.index == targetIndex && currentItem.offset <= targetScrollOffset)
|
||||
} else {
|
||||
// backwards
|
||||
currentItem.index < targetIndex ||
|
||||
(currentItem.index == targetIndex && currentItem.offset >= targetScrollOffset)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DecayAnimationSpec<Float>.canFlingPastCurrentItem(
|
||||
currentItem: LazyListItemInfo,
|
||||
initialVelocity: Float,
|
||||
): Boolean {
|
||||
val targetValue =
|
||||
calculateTargetValue(
|
||||
initialValue = currentItem.offset.toFloat(),
|
||||
initialVelocity = initialVelocity,
|
||||
)
|
||||
return when {
|
||||
// forward. We add 10% onto the size to cater for any item spacing
|
||||
initialVelocity > 0 -> targetValue <= -(currentItem.size * 1.1f)
|
||||
// backwards. We add 10% onto the size to cater for any item spacing
|
||||
else -> targetValue >= (currentItem.size * 0.1f)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user