From 4d27add4b2ce3e7ba6eceebb47084f43343d1cb3 Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Fri, 11 Feb 2022 13:34:47 +0000 Subject: [PATCH] Add `snapIndex` lambda parameter (#15) * Add `snapIndex` lambda parameter This block which returns the index which the apps wishes to snap to. The block is provided with the SnapperLayoutInfo and the index which Snapper has determined is the correct target index. Callers can override this value as they see fit. A common use case could be rounding up/down to achieve groupings of items, which was the intention of #12 * Add test for snapIndex * Improve kdoc * Expand snapIndex test * Add snapIndex scenario with opposite directions * Make snapIndex non-nullable * Split out maximumFlingDistance to a deprecated overload Had to remove the default value to avoid overload ambiguity. * Re-add default value for maximumFlingDistance This maintains binary compatibility. * Remove extra comment * Add `startIndex` to SnapIndex Also added some docs for the new parameter --- docs/usage.md | 53 ++++-- lib/api/current.api | 21 ++- .../InstrumentedSnapperFlingLazyColumnTest.kt | 19 +-- .../InstrumentedSnapperFlingLazyRowTest.kt | 19 +-- .../kotlin/dev/chrisbanes/snapper/LazyList.kt | 87 +++++++++- .../snapper/SnapperFlingBehavior.kt | 159 ++++++++++++++++-- .../snapper/BaseSnapperFlingLazyColumnTest.kt | 4 +- .../snapper/BaseSnapperFlingLazyRowTest.kt | 4 +- .../snapper/SnapperFlingBehaviorTest.kt | 68 +++++++- .../RobolectricSnapperFlingLazyColumnTest.kt | 21 +-- .../RobolectricSnapperFlingLazyRowTest.kt | 21 +-- 11 files changed, 373 insertions(+), 103 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 681805d..65c3c27 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -102,27 +102,30 @@ LazyColumn( } ``` +## Customization of the target index -## Controlling the maximum fling distance +The `snapIndex` parameter allows customization of the index which Snapper which fling to +after a user has started a fling. -The `maximumFlingDistance` parameter allows customization of the maximum distance that a user can -fling (and snap to). +The block is given the [SnapperLayoutInfo][snapperlayoutinfo], the index where the fling started, and +with the index which Snapper has determined is the correct index to fling, without the layout limits. +The block should return the index which Snapper should fling and snap to. -Apps can provide a block which will be called once a fling has been started. The block is given -the [SnapperLayoutInfo][snapperlayoutinfo], if it needs to use the layout to determine -the distance. +The following are some examples of what you can achieve with `snapIndex`. -The following example sets the maximum fling distance to 3x the container width. +### Controlling the maximum fling distance + +The following example sets the `snapIndex` so that the user can only fling up a maximum of 3 items: ``` kotlin +val MaxItemFling = 3 + LazyRow( state = lazyListState, flingBehavior = rememberSnapperFlingBehavior( lazyListState = lazyListState, - maximumFlingDistance = { layoutInfo -> - val scrollLength = layoutInfo.endScrollOffset - layoutInfo.startScrollOffset - // Allow the user to scroll 3x the LazyRow 'width' - scrollLength * 3f + snapIndex = { layoutInfo, startIndex, targetIndex -> + targetIndex.coerceIn(startIndex - MaxItemFling, startIndex + MaxItemFling) } ), ) { @@ -130,6 +133,34 @@ LazyRow( } ``` +### Snapping groups + +The `snapIndex` parameter can also be used to achieve snapping to 'groups' of items. + +The following example provide a `snapIndex` block which snaps flings to groups of 3 items: + +``` kotlin +val GroupSize = 3 + +LazyRow( + state = lazyListState, + flingBehavior = rememberSnapperFlingBehavior( + lazyListState = lazyListState, + snapIndex = { _, _, targetIndex -> + val mod = targetIndex % GroupSize + if (mod > (GroupSize / 2)) { + // Round up towards infinity + GroupSize + targetIndex - mod + } else { + // Round down towards zero + targetIndex - mod + } + ), +) { + // content +} +``` + ## Animation specs SnapperFlingBehavior allows setting of two different animation specs: `decayAnimationSpec` and `springAnimationSpec`. diff --git a/lib/api/current.api b/lib/api/current.api index 3168358..dc0100e 100644 --- a/lib/api/current.api +++ b/lib/api/current.api @@ -6,7 +6,9 @@ package dev.chrisbanes.snapper { public final class LazyListKt { method @androidx.compose.runtime.Composable @dev.chrisbanes.snapper.ExperimentalSnapperApi public static dev.chrisbanes.snapper.LazyListSnapperLayoutInfo rememberLazyListSnapperLayoutInfo(androidx.compose.foundation.lazy.LazyListState lazyListState, optional kotlin.jvm.functions.Function2 snapOffsetForItem, optional float endContentPadding); - method @androidx.compose.runtime.Composable @dev.chrisbanes.snapper.ExperimentalSnapperApi public static dev.chrisbanes.snapper.SnapperFlingBehavior rememberSnapperFlingBehavior(androidx.compose.foundation.lazy.LazyListState lazyListState, optional kotlin.jvm.functions.Function2 snapOffsetForItem, optional float endContentPadding, optional androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec, optional kotlin.jvm.functions.Function1 maximumFlingDistance); + method @androidx.compose.runtime.Composable @dev.chrisbanes.snapper.ExperimentalSnapperApi public static dev.chrisbanes.snapper.SnapperFlingBehavior rememberSnapperFlingBehavior(androidx.compose.foundation.lazy.LazyListState lazyListState, optional kotlin.jvm.functions.Function2 snapOffsetForItem, optional float endContentPadding, optional androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec, kotlin.jvm.functions.Function3 snapIndex); + method @androidx.compose.runtime.Composable @dev.chrisbanes.snapper.ExperimentalSnapperApi public static dev.chrisbanes.snapper.SnapperFlingBehavior rememberSnapperFlingBehavior(androidx.compose.foundation.lazy.LazyListState lazyListState, optional kotlin.jvm.functions.Function2 snapOffsetForItem, optional float endContentPadding, optional androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec); + method @Deprecated @androidx.compose.runtime.Composable @dev.chrisbanes.snapper.ExperimentalSnapperApi public static dev.chrisbanes.snapper.SnapperFlingBehavior rememberSnapperFlingBehavior(androidx.compose.foundation.lazy.LazyListState lazyListState, optional kotlin.jvm.functions.Function2 snapOffsetForItem, optional float endContentPadding, optional androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec, optional kotlin.jvm.functions.Function1 maximumFlingDistance); } @dev.chrisbanes.snapper.ExperimentalSnapperApi public final class LazyListSnapperLayoutInfo extends dev.chrisbanes.snapper.SnapperLayoutInfo { @@ -18,10 +20,12 @@ package dev.chrisbanes.snapper { method public dev.chrisbanes.snapper.SnapperLayoutItemInfo? getCurrentItem(); method public int getEndScrollOffset(); method public int getStartScrollOffset(); + method public int getTotalItemsCount(); method public kotlin.sequences.Sequence getVisibleItems(); property public dev.chrisbanes.snapper.SnapperLayoutItemInfo? currentItem; property public int endScrollOffset; property public int startScrollOffset; + property public int totalItemsCount; property public kotlin.sequences.Sequence visibleItems; } @@ -36,22 +40,27 @@ package dev.chrisbanes.snapper { } @dev.chrisbanes.snapper.ExperimentalSnapperApi public final class SnapperFlingBehavior implements androidx.compose.foundation.gestures.FlingBehavior { - ctor public SnapperFlingBehavior(dev.chrisbanes.snapper.SnapperLayoutInfo layoutInfo, optional kotlin.jvm.functions.Function1 maximumFlingDistance, androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec); + ctor public SnapperFlingBehavior(dev.chrisbanes.snapper.SnapperLayoutInfo layoutInfo, androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec, optional kotlin.jvm.functions.Function3 snapIndex); + ctor @Deprecated public SnapperFlingBehavior(dev.chrisbanes.snapper.SnapperLayoutInfo layoutInfo, androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec, optional kotlin.jvm.functions.Function1 maximumFlingDistance); method public Integer? getAnimationTarget(); method public suspend Object? performFling(androidx.compose.foundation.gestures.ScrollScope, float initialVelocity, kotlin.coroutines.Continuation p); property public final Integer? animationTarget; } @dev.chrisbanes.snapper.ExperimentalSnapperApi public final class SnapperFlingBehaviorDefaults { - method public kotlin.jvm.functions.Function1 getMaximumFlingDistance(); + method @Deprecated public kotlin.jvm.functions.Function1 getMaximumFlingDistance(); + method public kotlin.jvm.functions.Function3 getSnapIndex(); method public androidx.compose.animation.core.AnimationSpec getSpringAnimationSpec(); - property public final kotlin.jvm.functions.Function1 MaximumFlingDistance; + property @Deprecated public final kotlin.jvm.functions.Function1 MaximumFlingDistance; + property public final kotlin.jvm.functions.Function3 SnapIndex; property public final androidx.compose.animation.core.AnimationSpec SpringAnimationSpec; field public static final dev.chrisbanes.snapper.SnapperFlingBehaviorDefaults INSTANCE; } public final class SnapperFlingBehaviorKt { - method @androidx.compose.runtime.Composable @dev.chrisbanes.snapper.ExperimentalSnapperApi public static dev.chrisbanes.snapper.SnapperFlingBehavior rememberSnapperFlingBehavior(dev.chrisbanes.snapper.SnapperLayoutInfo layoutInfo, optional androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec, optional kotlin.jvm.functions.Function1 maximumFlingDistance); + method @androidx.compose.runtime.Composable @dev.chrisbanes.snapper.ExperimentalSnapperApi public static dev.chrisbanes.snapper.SnapperFlingBehavior rememberSnapperFlingBehavior(dev.chrisbanes.snapper.SnapperLayoutInfo layoutInfo, optional androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec, kotlin.jvm.functions.Function3 snapIndex); + method @androidx.compose.runtime.Composable @dev.chrisbanes.snapper.ExperimentalSnapperApi public static inline dev.chrisbanes.snapper.SnapperFlingBehavior rememberSnapperFlingBehavior(dev.chrisbanes.snapper.SnapperLayoutInfo layoutInfo, optional androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec); + method @Deprecated @androidx.compose.runtime.Composable @dev.chrisbanes.snapper.ExperimentalSnapperApi public static dev.chrisbanes.snapper.SnapperFlingBehavior rememberSnapperFlingBehavior(dev.chrisbanes.snapper.SnapperLayoutInfo layoutInfo, optional androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec, optional kotlin.jvm.functions.Function1 maximumFlingDistance); } @dev.chrisbanes.snapper.ExperimentalSnapperApi public abstract class SnapperLayoutInfo { @@ -63,10 +72,12 @@ package dev.chrisbanes.snapper { method public abstract dev.chrisbanes.snapper.SnapperLayoutItemInfo? getCurrentItem(); method public abstract int getEndScrollOffset(); method public abstract int getStartScrollOffset(); + method public abstract int getTotalItemsCount(); method public abstract kotlin.sequences.Sequence getVisibleItems(); property public abstract dev.chrisbanes.snapper.SnapperLayoutItemInfo? currentItem; property public abstract int endScrollOffset; property public abstract int startScrollOffset; + property public abstract int totalItemsCount; property public abstract kotlin.sequences.Sequence visibleItems; } diff --git a/lib/src/androidTest/kotlin/dev/chrisbanes/snapper/InstrumentedSnapperFlingLazyColumnTest.kt b/lib/src/androidTest/kotlin/dev/chrisbanes/snapper/InstrumentedSnapperFlingLazyColumnTest.kt index 697f5b6..eef5078 100644 --- a/lib/src/androidTest/kotlin/dev/chrisbanes/snapper/InstrumentedSnapperFlingLazyColumnTest.kt +++ b/lib/src/androidTest/kotlin/dev/chrisbanes/snapper/InstrumentedSnapperFlingLazyColumnTest.kt @@ -28,12 +28,12 @@ import org.junit.runners.Parameterized */ @RunWith(Parameterized::class) class InstrumentedSnapperFlingLazyColumnTest( - maxScrollDistanceDp: Float, + snapIndexDelta: Int, contentPadding: PaddingValues, itemSpacingDp: Int, reverseLayout: Boolean, ) : BaseSnapperFlingLazyColumnTest( - maxScrollDistanceDp, + snapIndexDelta, contentPadding, itemSpacingDp, reverseLayout, @@ -44,23 +44,14 @@ class InstrumentedSnapperFlingLazyColumnTest( */ @JvmStatic @Parameterized.Parameters( - name = "maxScrollDistanceDp={0}," + + name = "snapIndexDelta={0}," + "contentPadding={1}," + "itemSpacing={2}," + "reverseLayout={3}" ) fun data() = parameterizedParams() - // maxScrollDistanceDp - .combineWithParameters( - // We add 4dp on to cater for item spacing - 1 * (ItemSize.value + 4), - 4 * (ItemSize.value + 4), - // We also test without adding the item spacing. Key use cases like - // Accompanist Pager do not add the item spacing so we need to ensure things - // work as expected without it. - 1 * ItemSize.value, - 4 * ItemSize.value, - ) + // snapIndexDelta + .combineWithParameters(1, 4) // contentPadding .combineWithParameters( PaddingValues(bottom = 32.dp), // Alignment.Top diff --git a/lib/src/androidTest/kotlin/dev/chrisbanes/snapper/InstrumentedSnapperFlingLazyRowTest.kt b/lib/src/androidTest/kotlin/dev/chrisbanes/snapper/InstrumentedSnapperFlingLazyRowTest.kt index 1e20bc3..bdf8564 100644 --- a/lib/src/androidTest/kotlin/dev/chrisbanes/snapper/InstrumentedSnapperFlingLazyRowTest.kt +++ b/lib/src/androidTest/kotlin/dev/chrisbanes/snapper/InstrumentedSnapperFlingLazyRowTest.kt @@ -29,13 +29,13 @@ import org.junit.runners.Parameterized */ @RunWith(Parameterized::class) class InstrumentedSnapperFlingLazyRowTest( - maxScrollDistanceDp: Float, + snapIndexDelta: Int, contentPadding: PaddingValues, itemSpacingDp: Int, layoutDirection: LayoutDirection, reverseLayout: Boolean, ) : BaseSnapperFlingLazyRowTest( - maxScrollDistanceDp, + snapIndexDelta, contentPadding, itemSpacingDp, layoutDirection, @@ -44,24 +44,15 @@ class InstrumentedSnapperFlingLazyRowTest( companion object { @JvmStatic @Parameterized.Parameters( - name = "maxScrollDistanceDp={0}," + + name = "snapIndexDelta={0}," + "contentPadding={1}," + "itemSpacing={2}," + "layoutDirection={3}," + "reverseLayout={4}" ) fun data() = parameterizedParams() - // maxScrollDistanceDp - .combineWithParameters( - // We add 4dp on to cater for item spacing - 1 * (ItemSize.value + 4), - 4 * (ItemSize.value + 4), - // We also test without adding the item spacing. Key use cases like - // Accompanist Pager do not add the item spacing so we need to ensure things - // work as expected without it. - 1 * ItemSize.value, - 4 * ItemSize.value, - ) + // snapIndexDelta + .combineWithParameters(1, 4) // contentPadding .combineWithParameters( PaddingValues(end = 32.dp), // Alignment.Start diff --git a/lib/src/main/kotlin/dev/chrisbanes/snapper/LazyList.kt b/lib/src/main/kotlin/dev/chrisbanes/snapper/LazyList.kt index 0b5e2ae..ecbfc20 100644 --- a/lib/src/main/kotlin/dev/chrisbanes/snapper/LazyList.kt +++ b/lib/src/main/kotlin/dev/chrisbanes/snapper/LazyList.kt @@ -37,6 +37,84 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt +/** + * Create and remember a snapping [FlingBehavior] to be used with [LazyListState]. + * + * This is a convenience function for using [rememberLazyListSnapperLayoutInfo] and + * [rememberSnapperFlingBehavior]. If you require access to the layout info, you can safely use + * those APIs directly. + * + * @param lazyListState The [LazyListState] to update. + * @param snapOffsetForItem Block which returns which offset the given item should 'snap' to. + * See [SnapOffsets] for provided values. + * @param endContentPadding The amount of content padding on the end edge of the lazy list + * in dps (end/bottom depending on the scrolling direction). + * @param decayAnimationSpec The decay animation spec to use for decayed flings. + * @param springAnimationSpec The animation spec to use when snapping. + * @param snapIndex Block which returns the index to snap to. The block is provided with the + * [SnapperLayoutInfo], the index where the fling started, and the index which Snapper has + * determined is the correct target index. Callers can override this value to any valid index + * for the layout. Some common use cases include limiting the fling distance, and rounding up/down + * to achieve snapping to groups of items. + */ +@ExperimentalSnapperApi +@Composable +public fun rememberSnapperFlingBehavior( + lazyListState: LazyListState, + snapOffsetForItem: (layoutInfo: SnapperLayoutInfo, item: SnapperLayoutItemInfo) -> Int = SnapOffsets.Center, + endContentPadding: Dp = 0.dp, + decayAnimationSpec: DecayAnimationSpec = rememberSplineBasedDecay(), + springAnimationSpec: AnimationSpec = SnapperFlingBehaviorDefaults.SpringAnimationSpec, + snapIndex: (SnapperLayoutInfo, startIndex: Int, targetIndex: Int) -> Int, +): SnapperFlingBehavior = rememberSnapperFlingBehavior( + layoutInfo = rememberLazyListSnapperLayoutInfo( + lazyListState = lazyListState, + snapOffsetForItem = snapOffsetForItem, + endContentPadding = endContentPadding + ), + decayAnimationSpec = decayAnimationSpec, + springAnimationSpec = springAnimationSpec, + snapIndex = snapIndex, +) + +/** + * Create and remember a snapping [FlingBehavior] to be used with [LazyListState]. + * + * This is a convenience function for using [rememberLazyListSnapperLayoutInfo] and + * [rememberSnapperFlingBehavior]. If you require access to the layout info, you can safely use + * those APIs directly. + * + * @param lazyListState The [LazyListState] to update. + * @param snapOffsetForItem Block which returns which offset the given item should 'snap' to. + * See [SnapOffsets] for provided values. + * @param endContentPadding The amount of content padding on the end edge of the lazy list + * in dps (end/bottom depending on the scrolling direction). + * @param decayAnimationSpec The decay animation spec to use for decayed flings. + * @param springAnimationSpec The animation spec to use when snapping. + */ +@ExperimentalSnapperApi +@Composable +public fun rememberSnapperFlingBehavior( + lazyListState: LazyListState, + snapOffsetForItem: (layoutInfo: SnapperLayoutInfo, item: SnapperLayoutItemInfo) -> Int = SnapOffsets.Center, + endContentPadding: Dp = 0.dp, + decayAnimationSpec: DecayAnimationSpec = rememberSplineBasedDecay(), + springAnimationSpec: AnimationSpec = SnapperFlingBehaviorDefaults.SpringAnimationSpec, +): SnapperFlingBehavior { + // You might be wondering this is function exists rather than a default value for snapIndex + // above. It was done to remove overload ambiguity with the maximumFlingDistance overload + // below. When that function is removed, we also remove this function and move to a default + // param value. + return rememberSnapperFlingBehavior( + lazyListState = lazyListState, + snapOffsetForItem = snapOffsetForItem, + endContentPadding = endContentPadding, + decayAnimationSpec = decayAnimationSpec, + springAnimationSpec = springAnimationSpec, + snapIndex = SnapperFlingBehaviorDefaults.SnapIndex + ) +} + /** * Create and remember a snapping [FlingBehavior] to be used with [LazyListState]. * @@ -54,8 +132,10 @@ import kotlin.math.roundToInt * @param maximumFlingDistance Block which returns the maximum fling distance in pixels. * The returned value should be > 0. */ -@ExperimentalSnapperApi @Composable +@Deprecated("The maximumFlingDistance parameter has been replaced with snapIndex") +@Suppress("DEPRECATION") +@ExperimentalSnapperApi public fun rememberSnapperFlingBehavior( lazyListState: LazyListState, snapOffsetForItem: (layoutInfo: SnapperLayoutInfo, item: SnapperLayoutItemInfo) -> Int = SnapOffsets.Center, @@ -71,7 +151,7 @@ public fun rememberSnapperFlingBehavior( ), decayAnimationSpec = decayAnimationSpec, springAnimationSpec = springAnimationSpec, - maximumFlingDistance = maximumFlingDistance + maximumFlingDistance = maximumFlingDistance, ) /** @@ -123,6 +203,9 @@ public class LazyListSnapperLayoutInfo( private val itemCount: Int get() = lazyListState.layoutInfo.totalItemsCount + override val totalItemsCount: Int + get() = lazyListState.layoutInfo.totalItemsCount + override val currentItem: SnapperLayoutItemInfo? get() = visibleItems.lastOrNull { it.offset <= snapOffsetForItem(this, it) } diff --git a/lib/src/main/kotlin/dev/chrisbanes/snapper/SnapperFlingBehavior.kt b/lib/src/main/kotlin/dev/chrisbanes/snapper/SnapperFlingBehavior.kt index a534394..83075de 100644 --- a/lib/src/main/kotlin/dev/chrisbanes/snapper/SnapperFlingBehavior.kt +++ b/lib/src/main/kotlin/dev/chrisbanes/snapper/SnapperFlingBehavior.kt @@ -59,7 +59,75 @@ public object SnapperFlingBehaviorDefaults { * [rememberSnapperFlingBehavior] and [SnapperFlingBehavior], which does not limit * the fling distance. */ + @Deprecated("The maximumFlingDistance parameter has been deprecated.") public val MaximumFlingDistance: (SnapperLayoutInfo) -> Float = { Float.MAX_VALUE } + + /** + * The default implementation for the `snapIndex` parameter of + * [rememberSnapperFlingBehavior] and [SnapperFlingBehavior]. + */ + public val SnapIndex: (SnapperLayoutInfo, startIndex: Int, targetIndex: Int) -> Int = { _, _, targetIndex -> targetIndex } +} + +/** + * Create and remember a snapping [FlingBehavior] to be used with the given [layoutInfo]. + * + * @param layoutInfo The [SnapperLayoutInfo] to use. For lazy layouts, + * you can use [rememberLazyListSnapperLayoutInfo]. + * @param decayAnimationSpec The decay animation spec to use for decayed flings. + * @param springAnimationSpec The animation spec to use when snapping. + * @param snapIndex Block which returns the index to snap to. The block is provided with the + * [SnapperLayoutInfo], the index where the fling started, and the index which Snapper has + * determined is the correct target index. Callers can override this value to any valid index + * for the layout. Some common use cases include limiting the fling distance, and rounding up/down + * to achieve snapping to groups of items. + */ +@ExperimentalSnapperApi +@Composable +public fun rememberSnapperFlingBehavior( + layoutInfo: SnapperLayoutInfo, + decayAnimationSpec: DecayAnimationSpec = rememberSplineBasedDecay(), + springAnimationSpec: AnimationSpec = SnapperFlingBehaviorDefaults.SpringAnimationSpec, + snapIndex: (SnapperLayoutInfo, startIndex: Int, targetIndex: Int) -> Int, +): SnapperFlingBehavior = remember( + layoutInfo, + decayAnimationSpec, + springAnimationSpec, + snapIndex, +) { + SnapperFlingBehavior( + layoutInfo = layoutInfo, + decayAnimationSpec = decayAnimationSpec, + springAnimationSpec = springAnimationSpec, + snapIndex = snapIndex, + ) +} + +/** + * Create and remember a snapping [FlingBehavior] to be used with the given [layoutInfo]. + * + * @param layoutInfo The [SnapperLayoutInfo] to use. For lazy layouts, + * you can use [rememberLazyListSnapperLayoutInfo]. + * @param decayAnimationSpec The decay animation spec to use for decayed flings. + * @param springAnimationSpec The animation spec to use when snapping. + */ +@ExperimentalSnapperApi +@Composable +public inline fun rememberSnapperFlingBehavior( + layoutInfo: SnapperLayoutInfo, + decayAnimationSpec: DecayAnimationSpec = rememberSplineBasedDecay(), + springAnimationSpec: AnimationSpec = SnapperFlingBehaviorDefaults.SpringAnimationSpec, +): SnapperFlingBehavior { + // You might be wondering this is function exists rather than a default value for snapIndex + // above. It was done to remove overload ambiguity with the maximumFlingDistance overload + // below. When that function is removed, we also remove this function and move to a default + // param value. + return rememberSnapperFlingBehavior( + layoutInfo = layoutInfo, + decayAnimationSpec = decayAnimationSpec, + springAnimationSpec = springAnimationSpec, + snapIndex = SnapperFlingBehaviorDefaults.SnapIndex + ) } /** @@ -72,7 +140,9 @@ public object SnapperFlingBehaviorDefaults { * @param maximumFlingDistance Block which returns the maximum fling distance in pixels. * The returned value should be > 0. */ +@Suppress("DEPRECATION") @ExperimentalSnapperApi +@Deprecated("The maximumFlingDistance parameter has been replaced with snapIndex") @Composable public fun rememberSnapperFlingBehavior( layoutInfo: SnapperLayoutInfo, @@ -124,6 +194,11 @@ public abstract class SnapperLayoutInfo { */ public abstract val currentItem: SnapperLayoutItemInfo? + /** + * The total count of items attached to the layout. + */ + public abstract val totalItemsCount: Int + /** * Calculate the desired target which should be scrolled to for the given [velocity]. * @@ -209,20 +284,61 @@ public object SnapOffsets { * * Note: the default parameter value for [decayAnimationSpec] is different to the value used in * [rememberSnapperFlingBehavior], due to not being able to access composable functions. - * - * @param layoutInfo The [SnapperLayoutInfo] to use. - * @param decayAnimationSpec The decay animation spec to use for decayed flings. - * @param springAnimationSpec The animation spec to use when snapping. - * @param maximumFlingDistance Block which returns the maximum fling distance in pixels. - * The returned value should be > 0. */ @ExperimentalSnapperApi -public class SnapperFlingBehavior( +public class SnapperFlingBehavior private constructor( private val layoutInfo: SnapperLayoutInfo, - private val maximumFlingDistance: (SnapperLayoutInfo) -> Float = SnapperFlingBehaviorDefaults.MaximumFlingDistance, private val decayAnimationSpec: DecayAnimationSpec, - private val springAnimationSpec: AnimationSpec = SnapperFlingBehaviorDefaults.SpringAnimationSpec, + private val springAnimationSpec: AnimationSpec, + private val snapIndex: (SnapperLayoutInfo, startIndex: Int, targetIndex: Int) -> Int, + private val maximumFlingDistance: (SnapperLayoutInfo) -> Float, ) : FlingBehavior { + /** + * @param layoutInfo The [SnapperLayoutInfo] to use. + * @param decayAnimationSpec The decay animation spec to use for decayed flings. + * @param springAnimationSpec The animation spec to use when snapping. + * @param snapIndex Block which returns the index to snap to. The block is provided with the + * [SnapperLayoutInfo], the index where the fling started, and the index which Snapper has + * determined is the correct target index. Callers can override this value to any valid index + * for the layout. Some common use cases include limiting the fling distance, and rounding + * up/down to achieve snapping to groups of items. + */ + public constructor( + layoutInfo: SnapperLayoutInfo, + decayAnimationSpec: DecayAnimationSpec, + springAnimationSpec: AnimationSpec = SnapperFlingBehaviorDefaults.SpringAnimationSpec, + snapIndex: (SnapperLayoutInfo, startIndex: Int, targetIndex: Int) -> Int = SnapperFlingBehaviorDefaults.SnapIndex, + ) : this( + layoutInfo = layoutInfo, + decayAnimationSpec = decayAnimationSpec, + springAnimationSpec = springAnimationSpec, + snapIndex = snapIndex, + // We still need to pass in a maximumFlingDistance value + maximumFlingDistance = @Suppress("DEPRECATION") SnapperFlingBehaviorDefaults.MaximumFlingDistance, + ) + + /** + * @param layoutInfo The [SnapperLayoutInfo] to use. + * @param decayAnimationSpec The decay animation spec to use for decayed flings. + * @param springAnimationSpec The animation spec to use when snapping. + * @param maximumFlingDistance Block which returns the maximum fling distance in pixels. + * The returned value should be > 0. + */ + @Deprecated("The maximumFlingDistance parameter has been replaced with snapIndex") + @Suppress("DEPRECATION") + public constructor( + layoutInfo: SnapperLayoutInfo, + decayAnimationSpec: DecayAnimationSpec, + springAnimationSpec: AnimationSpec = SnapperFlingBehaviorDefaults.SpringAnimationSpec, + maximumFlingDistance: (SnapperLayoutInfo) -> Float = SnapperFlingBehaviorDefaults.MaximumFlingDistance, + ) : this( + layoutInfo = layoutInfo, + decayAnimationSpec = decayAnimationSpec, + springAnimationSpec = springAnimationSpec, + maximumFlingDistance = maximumFlingDistance, + snapIndex = SnapperFlingBehaviorDefaults.SnapIndex, + ) + /** * The target item index for any on-going animations. */ @@ -245,14 +361,23 @@ public class SnapperFlingBehavior( "Distance returned by maximumFlingDistance should be greater than 0" } - return flingToIndex( - index = layoutInfo.determineTargetIndex( - velocity = initialVelocity, - decayAnimationSpec = decayAnimationSpec, - maximumFlingDistance = maxFlingDistance, - ), - initialVelocity = initialVelocity, - ) + val targetIndex = layoutInfo.determineTargetIndex( + velocity = initialVelocity, + decayAnimationSpec = decayAnimationSpec, + maximumFlingDistance = maxFlingDistance, + ).let { target -> + // Let the snapIndex block transform the value + val start = layoutInfo.currentItem!!.index.let { index -> + // If the user is flinging towards the index 0, we assume that the start item is + // actually the next item (towards infinity). + if (initialVelocity < 0) index + 1 else index + } + snapIndex(layoutInfo, start, target) + }.also { + require(it in 0 until layoutInfo.totalItemsCount) + } + + return flingToIndex(index = targetIndex, initialVelocity = initialVelocity) } private suspend fun ScrollScope.flingToIndex( diff --git a/lib/src/sharedTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyColumnTest.kt b/lib/src/sharedTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyColumnTest.kt index 930f670..d81981a 100644 --- a/lib/src/sharedTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyColumnTest.kt +++ b/lib/src/sharedTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyColumnTest.kt @@ -42,12 +42,12 @@ import dev.chrisbanes.internal.swipeAcrossCenterWithVelocity */ @OptIn(ExperimentalSnapperApi::class) // SnapFlingBehavior is currently experimental abstract class BaseSnapperFlingLazyColumnTest( - maxScrollDistanceDp: Float, + snapIndexDelta: Int, private val contentPadding: PaddingValues, // We don't use the Dp type due to https://youtrack.jetbrains.com/issue/KT-35523 private val itemSpacingDp: Int, private val reverseLayout: Boolean, -) : SnapperFlingBehaviorTest(maxScrollDistanceDp) { +) : SnapperFlingBehaviorTest(snapIndexDelta) { override val endContentPadding: Int get() = with(rule.density) { contentPadding.calculateBottomPadding().roundToPx() } diff --git a/lib/src/sharedTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyRowTest.kt b/lib/src/sharedTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyRowTest.kt index 696507b..1821752 100644 --- a/lib/src/sharedTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyRowTest.kt +++ b/lib/src/sharedTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyRowTest.kt @@ -46,13 +46,13 @@ import dev.chrisbanes.internal.swipeAcrossCenterWithVelocity */ @OptIn(ExperimentalSnapperApi::class) // SnapFlingBehavior is currently experimental abstract class BaseSnapperFlingLazyRowTest( - maxScrollDistanceDp: Float, + snapIndexDelta: Int, private val contentPadding: PaddingValues, // We don't use the Dp type due to https://youtrack.jetbrains.com/issue/KT-35523 private val itemSpacingDp: Int, private val layoutDirection: LayoutDirection, private val reverseLayout: Boolean, -) : SnapperFlingBehaviorTest(maxScrollDistanceDp) { +) : SnapperFlingBehaviorTest(snapIndexDelta) { override val endContentPadding: Int get() = with(rule.density) { diff --git a/lib/src/sharedTest/kotlin/dev/chrisbanes/snapper/SnapperFlingBehaviorTest.kt b/lib/src/sharedTest/kotlin/dev/chrisbanes/snapper/SnapperFlingBehaviorTest.kt index 930b2d1..30a6694 100644 --- a/lib/src/sharedTest/kotlin/dev/chrisbanes/snapper/SnapperFlingBehaviorTest.kt +++ b/lib/src/sharedTest/kotlin/dev/chrisbanes/snapper/SnapperFlingBehaviorTest.kt @@ -19,6 +19,7 @@ package dev.chrisbanes.snapper import androidx.compose.animation.core.exponentialDecay import androidx.compose.foundation.lazy.LazyListItemInfo import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.ui.node.Ref import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag @@ -40,7 +41,7 @@ internal val ItemSize = 200.dp @OptIn(ExperimentalSnapperApi::class) // Pager is currently experimental abstract class SnapperFlingBehaviorTest( - private val maxScrollDistanceDp: Float, + private val snapIndexDelta: Int, ) { @get:Rule val rule = createComposeRule() @@ -201,14 +202,14 @@ abstract class SnapperFlingBehaviorTest( ) assertThat(lazyListState.isScrollInProgress).isTrue() - assertThat(snappingFlingBehavior.animationTarget).isEqualTo(1) + assertThat(snappingFlingBehavior.animationTarget).isNotNull() // Now re-enable the clock advancement and let the snap animation run rule.mainClock.autoAdvance = true rule.waitForIdle() // ...and assert that we now laid out from page 1 - lazyListState.assertCurrentItem(index = 1, offset = 0) + lazyListState.assertCurrentItem(minIndex = 1, offset = 0) } @Test @@ -279,6 +280,60 @@ abstract class SnapperFlingBehaviorTest( lazyListState.assertCurrentItem(index = 0, offset = 0) } + @Test + fun snapIndex() { + val lazyListState = LazyListState() + val snappedIndex = Ref() + var snapIndex = 0 + val snappingFlingBehavior = createSnapFlingBehavior( + lazyListState = lazyListState, + snapIndex = { _, _, _ -> + // We increase the calculated index by 3 + snapIndex.also { snappedIndex.value = it } + } + ) + setTestContent( + flingBehavior = snappingFlingBehavior, + lazyListState = lazyListState, + count = 10, + ) + + // Forward fling + snapIndex = 5 + rule.onNodeWithTag("layout").swipeAcrossCenter(-MediumSwipeDistance) + rule.waitForIdle() + // ...and assert that we now laid out from our increased snap index + lazyListState.assertCurrentItem(index = 5) + + // Backwards fling, but snapIndex is forward + snapIndex = 9 + rule.onNodeWithTag("layout").swipeAcrossCenter(MediumSwipeDistance) + rule.waitForIdle() + // ...and assert that we now laid out from our increased snap index + lazyListState.assertCurrentItem(index = 9) + + // Backwards fling + snapIndex = 0 + rule.onNodeWithTag("layout").swipeAcrossCenter(MediumSwipeDistance) + rule.waitForIdle() + // ...and assert that we now laid out from our increased snap index + lazyListState.assertCurrentItem(index = 0) + + // Forward fling + snapIndex = 9 + rule.onNodeWithTag("layout").swipeAcrossCenter(-MediumSwipeDistance) + rule.waitForIdle() + // ...and assert that we now laid out from our increased snap index + lazyListState.assertCurrentItem(index = 9) + + // Forward fling, but snapIndex is backwards + snapIndex = 5 + rule.onNodeWithTag("layout").swipeAcrossCenter(-MediumSwipeDistance) + rule.waitForIdle() + // ...and assert that we now laid out from our increased snap index + lazyListState.assertCurrentItem(index = 5) + } + /** * Swipe across the center of the node. The major axis of the swipe is defined by the * overriding test. @@ -312,6 +367,7 @@ abstract class SnapperFlingBehaviorTest( private fun createSnapFlingBehavior( lazyListState: LazyListState, + snapIndex: ((SnapperLayoutInfo, currentIndex: Int, targetIndex: Int) -> Int)? = null, ): SnapperFlingBehavior = SnapperFlingBehavior( layoutInfo = LazyListSnapperLayoutInfo( lazyListState = lazyListState, @@ -319,7 +375,11 @@ abstract class SnapperFlingBehaviorTest( snapOffsetForItem = SnapOffsets.Start, ), decayAnimationSpec = exponentialDecay(), - maximumFlingDistance = { with(rule.density) { maxScrollDistanceDp.dp.toPx() } } + snapIndex = snapIndex ?: { layout, currentIndex, targetIndex -> + targetIndex + .coerceIn(currentIndex - snapIndexDelta, currentIndex + snapIndexDelta) + .coerceIn(0, layout.totalItemsCount - 1) + }, ) } diff --git a/lib/src/test/kotlin/dev/chrisbanes/snapper/RobolectricSnapperFlingLazyColumnTest.kt b/lib/src/test/kotlin/dev/chrisbanes/snapper/RobolectricSnapperFlingLazyColumnTest.kt index 671975b..64fadae 100644 --- a/lib/src/test/kotlin/dev/chrisbanes/snapper/RobolectricSnapperFlingLazyColumnTest.kt +++ b/lib/src/test/kotlin/dev/chrisbanes/snapper/RobolectricSnapperFlingLazyColumnTest.kt @@ -30,12 +30,12 @@ import org.robolectric.annotation.Config @Config(qualifiers = "w360dp-h640dp-xhdpi") @RunWith(ParameterizedRobolectricTestRunner::class) class RobolectricSnapperFlingLazyColumnTest( - maxScrollDistanceDp: Float, + snapIndexDelta: Int, contentPadding: PaddingValues, itemSpacingDp: Int, reverseLayout: Boolean, ) : BaseSnapperFlingLazyColumnTest( - maxScrollDistanceDp, + snapIndexDelta, contentPadding, itemSpacingDp, reverseLayout, @@ -43,25 +43,14 @@ class RobolectricSnapperFlingLazyColumnTest( companion object { @JvmStatic @ParameterizedRobolectricTestRunner.Parameters( - name = "maxScrollDistanceDp={0}," + + name = "snapIndexDelta={0}," + "contentPadding={1}," + "itemSpacing={2}," + "reverseLayout={3}" ) fun data() = parameterizedParams() - // maxScrollDistanceDp - .combineWithParameters( - // We add 4dp on to cater for item spacing - 1 * (ItemSize.value + 4), - 2 * (ItemSize.value + 4), - 4 * (ItemSize.value + 4), - // We also test without adding the item spacing. Key use cases like - // Accompanist Pager do not add the item spacing so we need to ensure things - // work as expected without it. - 1 * ItemSize.value, - 2 * ItemSize.value, - 4 * ItemSize.value, - ) + // snapIndexDelta + .combineWithParameters(1, 4, 10) // contentPadding .combineWithParameters( PaddingValues(bottom = 32.dp), // Alignment.Top diff --git a/lib/src/test/kotlin/dev/chrisbanes/snapper/RobolectricSnapperFlingLazyRowTest.kt b/lib/src/test/kotlin/dev/chrisbanes/snapper/RobolectricSnapperFlingLazyRowTest.kt index c7e2db0..6a78824 100644 --- a/lib/src/test/kotlin/dev/chrisbanes/snapper/RobolectricSnapperFlingLazyRowTest.kt +++ b/lib/src/test/kotlin/dev/chrisbanes/snapper/RobolectricSnapperFlingLazyRowTest.kt @@ -31,13 +31,13 @@ import org.robolectric.annotation.Config @Config(qualifiers = "w360dp-h640dp-xhdpi") @RunWith(ParameterizedRobolectricTestRunner::class) class RobolectricSnapperFlingLazyRowTest( - maxScrollDistanceDp: Float, + snapIndexDelta: Int, contentPadding: PaddingValues, itemSpacingDp: Int, layoutDirection: LayoutDirection, reverseLayout: Boolean, ) : BaseSnapperFlingLazyRowTest( - maxScrollDistanceDp, + snapIndexDelta, contentPadding, itemSpacingDp, layoutDirection, @@ -46,26 +46,15 @@ class RobolectricSnapperFlingLazyRowTest( companion object { @JvmStatic @ParameterizedRobolectricTestRunner.Parameters( - name = "maxScrollDistanceDp={0}," + + name = "snapIndexDelta={0}," + "contentPadding={1}," + "itemSpacing={2}," + "layoutDirection={3}," + "reverseLayout={4}" ) fun data() = parameterizedParams() - // maxScrollDistanceDp - .combineWithParameters( - // We add 4dp on to cater for item spacing - 1 * (ItemSize.value + 4), - 2 * (ItemSize.value + 4), - 4 * (ItemSize.value + 4), - // We also test without adding the item spacing. Key use cases like - // Accompanist Pager do not add the item spacing so we need to ensure things - // work as expected without it. - 1 * ItemSize.value, - 2 * ItemSize.value, - 4 * ItemSize.value, - ) + // snapIndexDelta + .combineWithParameters(1, 4, 10) // contentPadding .combineWithParameters( PaddingValues(end = 32.dp), // Alignment.Start