diff --git a/pager/api/pager.api b/pager/api/pager.api index eab331935..56c09ec93 100644 --- a/pager/api/pager.api +++ b/pager/api/pager.api @@ -15,15 +15,18 @@ public final class com/google/accompanist/pager/PagerScope$DefaultImpls { public static fun matchParentSize (Lcom/google/accompanist/pager/PagerScope;Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; } -public final class com/google/accompanist/pager/PagerState { +public final class com/google/accompanist/pager/PagerState : androidx/compose/foundation/gestures/ScrollableState { public static final field Companion Lcom/google/accompanist/pager/PagerState$Companion; public fun (IIF)V public synthetic fun (IIFILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun animateScrollToPage (IFFLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun animateScrollToPage$default (Lcom/google/accompanist/pager/PagerState;IFFLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public fun dispatchRawDelta (F)F public final fun getCurrentPage ()I public final fun getCurrentPageOffset ()F public final fun getPageCount ()I + public fun isScrollInProgress ()Z + public fun scroll (Landroidx/compose/foundation/MutatePriority;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun scrollToPage (IFLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun scrollToPage$default (Lcom/google/accompanist/pager/PagerState;IFLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun setPageCount (I)V diff --git a/pager/src/main/java/com/google/accompanist/pager/Pager.kt b/pager/src/main/java/com/google/accompanist/pager/Pager.kt index 84eab2539..af458b5fd 100644 --- a/pager/src/main/java/com/google/accompanist/pager/Pager.kt +++ b/pager/src/main/java/com/google/accompanist/pager/Pager.kt @@ -20,9 +20,8 @@ package com.google.accompanist.pager import android.util.Log import androidx.annotation.IntRange -import androidx.compose.animation.splineBasedDecay import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.runtime.Composable @@ -30,13 +29,12 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.runtime.key import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Measurable import androidx.compose.ui.layout.ParentDataModifier -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.semantics.ScrollAxisRange import androidx.compose.ui.semantics.horizontalScrollAxisRange @@ -46,7 +44,6 @@ import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection -import kotlinx.coroutines.launch import kotlin.math.roundToInt /** @@ -102,46 +99,35 @@ fun HorizontalPager( ) { require(offscreenLimit >= 1) { "offscreenLimit is required to be >= 1" } - val reverseScroll = LocalLayoutDirection.current == LayoutDirection.Rtl - - val density = LocalDensity.current - val decay = remember(density) { splineBasedDecay(density) } - - val coroutineScope = rememberCoroutineScope() + val reverseDirection = LocalLayoutDirection.current == LayoutDirection.Rtl val semantics = Modifier.semantics { if (state.pageCount > 0) { horizontalScrollAxisRange = ScrollAxisRange( value = { (state.currentPage + state.currentPageOffset) * state.pageSize }, maxValue = { state.lastPageIndex.toFloat() * state.pageSize }, - reverseScrolling = reverseScroll ) // Hook up scroll actions to our state scrollBy { x: Float, _ -> - coroutineScope.launch { - state.draggableState.drag { dragBy(x) } - } - true + state.dispatchRawDelta(x) != 0f } // Treat this as a selectable group selectableGroup() } } - val draggable = Modifier.draggable( - state = state.draggableState, - startDragImmediately = true, - onDragStopped = { velocity -> - launch { state.performFling(velocity, decay) } - }, + val scrollable = Modifier.scrollable( orientation = Orientation.Horizontal, - reverseDirection = reverseScroll, + flingBehavior = state.flingBehavior, + reverseDirection = reverseDirection, + state = state ) Layout( modifier = modifier .then(semantics) - .then(draggable), + .then(scrollable) + .clipToBounds(), content = { val firstPage = (state.currentPage - offscreenLimit).coerceAtLeast(0) val lastPage = (state.currentPage + offscreenLimit).coerceAtMost(state.lastPageIndex) diff --git a/pager/src/main/java/com/google/accompanist/pager/PagerState.kt b/pager/src/main/java/com/google/accompanist/pager/PagerState.kt index 8a4c5babe..9fd5cd445 100644 --- a/pager/src/main/java/com/google/accompanist/pager/PagerState.kt +++ b/pager/src/main/java/com/google/accompanist/pager/PagerState.kt @@ -27,8 +27,12 @@ import androidx.compose.animation.core.DecayAnimationSpec import androidx.compose.animation.core.animate import androidx.compose.animation.core.animateDecay import androidx.compose.animation.core.calculateTargetValue +import androidx.compose.animation.core.exponentialDecay import androidx.compose.animation.core.spring -import androidx.compose.foundation.gestures.DraggableState +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.gestures.ScrollableState import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -85,12 +89,21 @@ class PagerState( @IntRange(from = 0) pageCount: Int, @IntRange(from = 0) currentPage: Int = 0, @FloatRange(from = 0.0, to = 1.0) currentPageOffset: Float = 0f, -) { +) : ScrollableState { private var _pageCount by mutableStateOf(pageCount) private var _currentPage by mutableStateOf(currentPage) private val _currentPageOffset = mutableStateOf(currentPageOffset) internal var pageSize by mutableStateOf(0) + private val globalPosition: Float + get() = currentPage + currentPageOffset + + /** + * The ScrollableController instance. We keep it as we need to call stopAnimation on it once + * we reached the end of the list. + */ + private val scrollableState = ScrollableState(::onScroll) + init { require(pageCount >= 0) { "pageCount must be >= 0" } requireCurrentPage(currentPage, "currentPage") @@ -160,9 +173,9 @@ class PagerState( if (page == currentPage) return - // We don't specifically use the DragScope's dragBy, but + // We don't specifically use the ScrollScope's scrollBy, but // we do want to use it's mutex - draggableState.drag { + scroll { animateToPage( page = page.coerceIn(0, lastPageIndex), pageOffset = pageOffset.coerceIn(0f, 1f), @@ -189,9 +202,9 @@ class PagerState( requireCurrentPage(page, "page") requireCurrentPageOffset(pageOffset, "pageOffset") - // We don't specifically use the DragScope's dragBy, but + // We don't specifically use the ScrollScope's scrollBy(), but // we do want to use it's mutex - draggableState.drag { + scroll { currentPage = page currentPageOffset = pageOffset } @@ -212,7 +225,7 @@ class PagerState( initialVelocity: Float = 0f, ) { animate( - initialValue = currentPage + currentPageOffset, + initialValue = globalPosition, targetValue = page + pageOffset, initialVelocity = initialVelocity, animationSpec = animationSpec @@ -224,20 +237,21 @@ class PagerState( } private fun determineSpringBackOffset( - velocity: Float, offset: Float = currentPageOffset, ): Float = when { // If the offset exceeds the scroll threshold (in either direction), we want to // move to the next/previous item offset < ScrollThreshold -> 0f offset > 1 - ScrollThreshold -> 1f - // Otherwise we look at the velocity for scroll direction - velocity < 0 -> 1f + // Otherwise we go back to 0f else -> 0f } - internal val draggableState = DraggableState { delta -> + private fun onScroll(delta: Float): Float { + val current = globalPosition dragByOffset(delta / pageSize.coerceAtLeast(1)) + val new = globalPosition + return (new - current) * pageSize } private fun dragByOffset(deltaOffset: Float) { @@ -277,17 +291,21 @@ class PagerState( } } - /** - * TODO make this public? - */ - internal suspend fun performFling( + internal val flingBehavior: FlingBehavior = object : FlingBehavior { + override suspend fun ScrollScope.performFling(initialVelocity: Float): Float { + return fling(initialVelocity, scrollBy = ::scrollBy) + } + } + + internal suspend fun fling( initialVelocity: Float, - animationSpec: DecayAnimationSpec, - ) = draggableState.drag { + animationSpec: DecayAnimationSpec = exponentialDecay(), + scrollBy: (Float) -> Float, + ): Float { // We calculate the target offset using pixels, rather than using the offset val targetOffset = animationSpec.calculateTargetValue( initialValue = currentPageOffset * pageSize, - initialVelocity = initialVelocity * -1 + initialVelocity = initialVelocity ) / pageSize if (DebugLog) { @@ -298,21 +316,21 @@ class PagerState( ) } + var lastVelocity: Float = initialVelocity + // If the animation can naturally end outside of current page bounds, we will // animate with decay. if (targetOffset.absoluteValue >= 1) { // Animate with the decay animation spec using the fling velocity val targetPage = when { - targetOffset > 0 -> { - (currentPage + 1).coerceAtMost(lastPageIndex) - } + targetOffset > 0 -> (currentPage + 1).coerceAtMost(lastPageIndex) else -> currentPage } AnimationState( initialValue = currentPageOffset * pageSize, - initialVelocity = initialVelocity * -1 + initialVelocity = initialVelocity ).animateDecay(animationSpec) { if (DebugLog) { Log.d( @@ -322,19 +340,21 @@ class PagerState( ) } + // Keep track of velocity + lastVelocity = velocity + val coerced = value.coerceIn(0f, pageSize.toFloat()) - dragBy((currentPageOffset * pageSize) - coerced) + val unconsumed = scrollBy(coerced - (currentPageOffset * pageSize)) - val pastLeftBound = initialVelocity > 0 && + val pastLeftBound = initialVelocity < 0 && (currentPage < targetPage || (currentPage == targetPage && currentPageOffset == 0f)) - val pastRightBound = initialVelocity < 0 && + val pastRightBound = initialVelocity > 0 && (currentPage > targetPage || (currentPage == targetPage && currentPageOffset > 0f)) - if (pastLeftBound || pastRightBound) { + if (unconsumed > 0.5f || pastLeftBound || pastRightBound) { // If we reach the bounds of the allowed offset, cancel the animation cancelAnimation() - currentPage = targetPage currentPageOffset = 0f } @@ -342,19 +362,33 @@ class PagerState( } else { // Otherwise we animate to the next item, or spring-back depending on the offset animate( - initialValue = currentPageOffset * pageSize, - targetValue = pageSize * determineSpringBackOffset( - velocity = initialVelocity * -1, - offset = targetOffset - ), + initialValue = globalPosition * pageSize, + targetValue = (currentPage + determineSpringBackOffset(targetOffset)) * pageSize, initialVelocity = initialVelocity, animationSpec = spring() - ) { value, _ -> - dragBy((currentPageOffset * pageSize) - value) + ) { value, velocity -> + scrollBy(value - (globalPosition * pageSize)) + // Keep track of velocity + lastVelocity = velocity } } snapToNearestPage() + return lastVelocity + } + + override val isScrollInProgress: Boolean + get() = scrollableState.isScrollInProgress + + override fun dispatchRawDelta(delta: Float): Float { + return scrollableState.dispatchRawDelta(delta) + } + + override suspend fun scroll( + scrollPriority: MutatePriority, + block: suspend ScrollScope.() -> Unit + ) { + scrollableState.scroll(scrollPriority, block) } override fun toString(): String = "PagerState(" +