diff --git a/packages/SystemUI/compose/core/src/com/android/systemui/compose/layout/pager/Pager.kt b/packages/SystemUI/compose/core/src/com/android/systemui/compose/layout/pager/Pager.kt new file mode 100644 index 0000000000000..19624e605be8d --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/systemui/compose/layout/pager/Pager.kt @@ -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 = rememberSplineBasedDecay(), + snapAnimationSpec: AnimationSpec = 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 = rememberSplineBasedDecay(), + snapAnimationSpec: AnimationSpec = 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 +} diff --git a/packages/SystemUI/compose/core/src/com/android/systemui/compose/layout/pager/PagerState.kt b/packages/SystemUI/compose/core/src/com/android/systemui/compose/layout/pager/PagerState.kt new file mode 100644 index 0000000000000..288c26eb11996 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/systemui/compose/layout/pager/PagerState.kt @@ -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 = 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 = + listSaver( + save = { + listOf( + it.currentPage, + ) + }, + restore = { + PagerState( + currentPage = it[0] as Int, + ) + } + ) + } +} diff --git a/packages/SystemUI/compose/core/src/com/android/systemui/compose/layout/pager/SnappingFlingBehavior.kt b/packages/SystemUI/compose/core/src/com/android/systemui/compose/layout/pager/SnappingFlingBehavior.kt new file mode 100644 index 0000000000000..0b53f5324a7d9 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/systemui/compose/layout/pager/SnappingFlingBehavior.kt @@ -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 = 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 = rememberSplineBasedDecay(), + snapAnimationSpec: AnimationSpec = 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, + private val snapAnimationSpec: AnimationSpec, +) : 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.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) + } +}