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:
Jordan Demeulenaere
2022-09-20 11:02:38 +02:00
parent d5d35fb282
commit 3d389de356
3 changed files with 974 additions and 0 deletions

View File

@@ -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
}

View File

@@ -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,
)
}
)
}
}

View File

@@ -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)
}
}