diff --git a/.idea/artifacts/lib_desktop.xml b/.idea/artifacts/lib_desktop.xml
new file mode 100644
index 0000000..59fdb26
--- /dev/null
+++ b/.idea/artifacts/lib_desktop.xml
@@ -0,0 +1,8 @@
+
+
+ $PROJECT_DIR$/kmp/lib/build/libs
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/artifacts/lib_desktop_1_0.xml b/.idea/artifacts/lib_desktop_1_0.xml
new file mode 100644
index 0000000..4faab70
--- /dev/null
+++ b/.idea/artifacts/lib_desktop_1_0.xml
@@ -0,0 +1,8 @@
+
+
+ $PROJECT_DIR$/kmp/lib/build/libs
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
new file mode 100644
index 0000000..b1077fb
--- /dev/null
+++ b/.idea/kotlinc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
index ab49227..13fff30 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,3 +1,4 @@
+import org.gradle.api.attributes.plugin.GradlePluginApiVersion
import org.jetbrains.dokka.gradle.DokkaMultiModuleTask
/*
@@ -18,6 +19,7 @@ import org.jetbrains.dokka.gradle.DokkaMultiModuleTask
buildscript {
repositories {
+ gradlePluginPortal()
google()
mavenCentral()
}
@@ -25,6 +27,7 @@ buildscript {
dependencies {
classpath libs.android.gradlePlugin
classpath libs.kotlin.gradlePlugin
+ classpath libs.atomicfu.gradlePlugin
classpath libs.gradleMavenPublishPlugin
@@ -161,7 +164,7 @@ subprojects {
def depVersion = dependency.version
if (depVersion != null && depVersion.endsWith('SNAPSHOT')) {
throw new IllegalArgumentException(
- "Using SNAPSHOT dependency with non-SNAPSHOT library version: $dependency"
+ "Using SNAPSHOT dependency with non-SNAPSHOT library version: $dependency"
)
}
}
diff --git a/gradle.properties b/gradle.properties
index 1c7e303..d228c02 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -22,6 +22,9 @@ org.gradle.parallel=true
# Declare we support AndroidX
android.useAndroidX=true
+# Enable iOS Compose experimental support
+org.jetbrains.compose.experimental.uikit.enabled=true
+
# Required to publish to Nexus (see https://github.com/gradle/gradle/issues/11308)
systemProp.org.gradle.internal.publish.checksums.insecure=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7bf75a8..2c2c063 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,14 +1,14 @@
[versions]
compose = "1.2.1"
-# Last version to support Kotlin 1.6.21
+# Last version to support Kotlin 1.7.20
# https://developer.android.com/jetpack/androidx/releases/compose-kotlin
-composecompiler = "1.2.0-rc02"
+composecompiler = "1.3.1"
composesnapshot = "-" # a single character = no snapshot
-agp = "7.2.2"
+agp = "8.0.0-alpha06"
ktlint = "0.45.2"
-kotlin = "1.6.21"
+kotlin = "1.7.10"
coroutines = "1.5.2"
androidxtest = "1.4.0"
@@ -16,6 +16,7 @@ androidxnavigation = "2.5.1"
[plugins]
metalavaGradle = { id = "me.tylerbwong.gradle.metalava", version = "0.2.3" }
+jetbrainsCompose = { id = "org.jetbrains.compose", version = "1.2.0" }
[libraries]
compose-ui-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" }
@@ -32,6 +33,8 @@ compose-animation-animation = { module = "androidx.compose.animation:animation",
android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "agp" }
gradleMavenPublishPlugin = "com.vanniktech:gradle-maven-publish-plugin:0.18.0"
+atomicfu-gradlePlugin = "org.jetbrains.kotlinx:atomicfu-gradle-plugin:0.18.4"
+
coil = "io.coil-kt:coil-compose:1.4.0"
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index 41d9927..249e583 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradlew b/gradlew
index 1b6c787..a69d9cb 100755
--- a/gradlew
+++ b/gradlew
@@ -205,6 +205,12 @@ set -- \
org.gradle.wrapper.GradleWrapperMain \
"$@"
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
diff --git a/gradlew.bat b/gradlew.bat
index ac1b06f..53a6b23 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -14,7 +14,7 @@
@rem limitations under the License.
@rem
-@if "%DEBUG%" == "" @echo off
+@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@@ -25,7 +25,7 @@
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
-if "%DIRNAME%" == "" set DIRNAME=.
+if "%DIRNAME%"=="" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@@ -40,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
-if "%ERRORLEVEL%" == "0" goto execute
+if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
@@ -75,13 +75,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
:end
@rem End local scope for the variables with windows NT shell
-if "%ERRORLEVEL%"=="0" goto mainEnd
+if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
-if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
-exit /b 1
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
diff --git a/internal-testutils/build.gradle b/internal-testutils/build.gradle
index 188afd9..5fb2b09 100644
--- a/internal-testutils/build.gradle
+++ b/internal-testutils/build.gradle
@@ -21,6 +21,7 @@ plugins {
android {
compileSdkVersion 33
+ namespace "dev.chrisbanes.snapper.internal.test"
defaultConfig {
minSdkVersion 21
diff --git a/internal-testutils/src/main/AndroidManifest.xml b/internal-testutils/src/main/AndroidManifest.xml
index a1dbe48..a25d572 100644
--- a/internal-testutils/src/main/AndroidManifest.xml
+++ b/internal-testutils/src/main/AndroidManifest.xml
@@ -14,8 +14,7 @@
~ limitations under the License.
-->
-
+
diff --git a/kmp/lib/README.md b/kmp/lib/README.md
new file mode 100644
index 0000000..30404ce
--- /dev/null
+++ b/kmp/lib/README.md
@@ -0,0 +1 @@
+TODO
\ No newline at end of file
diff --git a/kmp/lib/api/0.1.0.api b/kmp/lib/api/0.1.0.api
new file mode 100644
index 0000000..0b04c11
--- /dev/null
+++ b/kmp/lib/api/0.1.0.api
@@ -0,0 +1,84 @@
+// Signature format: 4.0
+package dev.chrisbanes.snapper {
+
+ @kotlin.RequiresOptIn(message="Snapper is experimental. The API may be changed in the future.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention) public @interface ExperimentalSnapperApi {
+ }
+
+ 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 super dev.chrisbanes.snapper.SnapperLayoutInfo,? super dev.chrisbanes.snapper.SnapperLayoutItemInfo,java.lang.Integer> 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 super dev.chrisbanes.snapper.SnapperLayoutInfo,? super dev.chrisbanes.snapper.SnapperLayoutItemInfo,java.lang.Integer> snapOffsetForItem, optional float endContentPadding, optional androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec, optional kotlin.jvm.functions.Function1 super dev.chrisbanes.snapper.SnapperLayoutInfo,java.lang.Float> maximumFlingDistance);
+ }
+
+ @dev.chrisbanes.snapper.ExperimentalSnapperApi public final class LazyListSnapperLayoutInfo extends dev.chrisbanes.snapper.SnapperLayoutInfo {
+ ctor public LazyListSnapperLayoutInfo(androidx.compose.foundation.lazy.LazyListState lazyListState, kotlin.jvm.functions.Function2 super dev.chrisbanes.snapper.SnapperLayoutInfo,? super dev.chrisbanes.snapper.SnapperLayoutItemInfo,java.lang.Integer> snapOffsetForItem, optional int endContentPadding);
+ method public boolean canScrollTowardsEnd();
+ method public boolean canScrollTowardsStart();
+ method public int determineTargetIndex(float velocity, androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, float maximumFlingDistance);
+ method public int distanceToIndexSnap(int index);
+ method public dev.chrisbanes.snapper.SnapperLayoutItemInfo? getCurrentItem();
+ method public int getEndScrollOffset();
+ method public int getStartScrollOffset();
+ method public kotlin.sequences.Sequence getVisibleItems();
+ property public dev.chrisbanes.snapper.SnapperLayoutItemInfo? currentItem;
+ property public int endScrollOffset;
+ property public int startScrollOffset;
+ property public kotlin.sequences.Sequence visibleItems;
+ }
+
+ @dev.chrisbanes.snapper.ExperimentalSnapperApi public final class SnapOffsets {
+ method public kotlin.jvm.functions.Function2 getCenter();
+ method public kotlin.jvm.functions.Function2 getEnd();
+ method public kotlin.jvm.functions.Function2 getStart();
+ property public final kotlin.jvm.functions.Function2 Center;
+ property public final kotlin.jvm.functions.Function2 End;
+ property public final kotlin.jvm.functions.Function2 Start;
+ field public static final dev.chrisbanes.snapper.SnapOffsets INSTANCE;
+ }
+
+ @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 super dev.chrisbanes.snapper.SnapperLayoutInfo,java.lang.Float> maximumFlingDistance, androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec);
+ method public Integer? getAnimationTarget();
+ method public suspend Object? performFling(androidx.compose.foundation.gestures.ScrollScope, float initialVelocity, kotlin.coroutines.Continuation super java.lang.Float> p);
+ property public final Integer? animationTarget;
+ }
+
+ @dev.chrisbanes.snapper.ExperimentalSnapperApi public final class SnapperFlingBehaviorDefaults {
+ method public kotlin.jvm.functions.Function1 getMaximumFlingDistance();
+ method public androidx.compose.animation.core.AnimationSpec getSpringAnimationSpec();
+ property public final kotlin.jvm.functions.Function1 MaximumFlingDistance;
+ 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 super dev.chrisbanes.snapper.SnapperLayoutInfo,java.lang.Float> maximumFlingDistance);
+ }
+
+ @dev.chrisbanes.snapper.ExperimentalSnapperApi public abstract class SnapperLayoutInfo {
+ ctor public SnapperLayoutInfo();
+ method public abstract boolean canScrollTowardsEnd();
+ method public abstract boolean canScrollTowardsStart();
+ method public abstract int determineTargetIndex(float velocity, androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, float maximumFlingDistance);
+ method public abstract int distanceToIndexSnap(int index);
+ method public abstract dev.chrisbanes.snapper.SnapperLayoutItemInfo? getCurrentItem();
+ method public abstract int getEndScrollOffset();
+ method public abstract int getStartScrollOffset();
+ 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 kotlin.sequences.Sequence visibleItems;
+ }
+
+ public abstract class SnapperLayoutItemInfo {
+ ctor public SnapperLayoutItemInfo();
+ method public abstract int getIndex();
+ method public abstract int getOffset();
+ method public abstract int getSize();
+ property public abstract int index;
+ property public abstract int offset;
+ property public abstract int size;
+ }
+
+}
+
diff --git a/kmp/lib/api/current.api b/kmp/lib/api/current.api
new file mode 100644
index 0000000..94e31c0
--- /dev/null
+++ b/kmp/lib/api/current.api
@@ -0,0 +1,100 @@
+// Signature format: 4.0
+package dev.chrisbanes.snapper {
+
+ @kotlin.RequiresOptIn(message="Snapper is experimental. The API may be changed in the future.") @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.BINARY) public @interface ExperimentalSnapperApi {
+ }
+
+ public final class LazyListKt {
+ method @Deprecated @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 super dev.chrisbanes.snapper.SnapperLayoutInfo,? super dev.chrisbanes.snapper.SnapperLayoutItemInfo,java.lang.Integer> snapOffsetForItem, optional float endContentPadding);
+ 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 super dev.chrisbanes.snapper.SnapperLayoutInfo,? super dev.chrisbanes.snapper.SnapperLayoutItemInfo,java.lang.Integer> snapOffsetForItem);
+ 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 super dev.chrisbanes.snapper.SnapperLayoutInfo,? super dev.chrisbanes.snapper.SnapperLayoutItemInfo,java.lang.Integer> snapOffsetForItem, optional androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec, optional kotlin.jvm.functions.Function3 super dev.chrisbanes.snapper.SnapperLayoutInfo,? super java.lang.Integer,? super java.lang.Integer,java.lang.Integer> snapIndex);
+ 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 super dev.chrisbanes.snapper.SnapperLayoutInfo,? super dev.chrisbanes.snapper.SnapperLayoutItemInfo,java.lang.Integer> snapOffsetForItem, optional float endContentPadding, optional androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec, optional kotlin.jvm.functions.Function3 super dev.chrisbanes.snapper.SnapperLayoutInfo,? super java.lang.Integer,? super java.lang.Integer,java.lang.Integer> snapIndex);
+ 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 super dev.chrisbanes.snapper.SnapperLayoutInfo,? super dev.chrisbanes.snapper.SnapperLayoutItemInfo,java.lang.Integer> snapOffsetForItem, optional float endContentPadding, optional androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec, kotlin.jvm.functions.Function1 super dev.chrisbanes.snapper.SnapperLayoutInfo,java.lang.Float> maximumFlingDistance);
+ }
+
+ @dev.chrisbanes.snapper.ExperimentalSnapperApi public final class LazyListSnapperLayoutInfo extends dev.chrisbanes.snapper.SnapperLayoutInfo {
+ ctor public LazyListSnapperLayoutInfo(androidx.compose.foundation.lazy.LazyListState lazyListState, kotlin.jvm.functions.Function2 super dev.chrisbanes.snapper.SnapperLayoutInfo,? super dev.chrisbanes.snapper.SnapperLayoutItemInfo,java.lang.Integer> snapOffsetForItem);
+ ctor @Deprecated public LazyListSnapperLayoutInfo(androidx.compose.foundation.lazy.LazyListState lazyListState, kotlin.jvm.functions.Function2 super dev.chrisbanes.snapper.SnapperLayoutInfo,? super dev.chrisbanes.snapper.SnapperLayoutItemInfo,java.lang.Integer> snapOffsetForItem, optional int endContentPadding);
+ method public boolean canScrollTowardsEnd();
+ method public boolean canScrollTowardsStart();
+ method public int determineTargetIndex(float velocity, androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, float maximumFlingDistance);
+ method public int distanceToIndexSnap(int index);
+ 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;
+ }
+
+ @dev.chrisbanes.snapper.ExperimentalSnapperApi public final class SnapOffsets {
+ method public kotlin.jvm.functions.Function2 getCenter();
+ method public kotlin.jvm.functions.Function2 getEnd();
+ method public kotlin.jvm.functions.Function2 getStart();
+ property public final kotlin.jvm.functions.Function2 Center;
+ property public final kotlin.jvm.functions.Function2 End;
+ property public final kotlin.jvm.functions.Function2 Start;
+ field public static final dev.chrisbanes.snapper.SnapOffsets INSTANCE;
+ }
+
+ @dev.chrisbanes.snapper.ExperimentalSnapperApi public final class SnapperFlingBehavior implements androidx.compose.foundation.gestures.FlingBehavior {
+ 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 super dev.chrisbanes.snapper.SnapperLayoutInfo,? super java.lang.Integer,? super java.lang.Integer,java.lang.Integer> 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 super dev.chrisbanes.snapper.SnapperLayoutInfo,java.lang.Float> maximumFlingDistance);
+ method public Integer? getAnimationTarget();
+ method public suspend Object? performFling(androidx.compose.foundation.gestures.ScrollScope, float initialVelocity, kotlin.coroutines.Continuation super java.lang.Float> p);
+ property public final Integer? animationTarget;
+ }
+
+ @dev.chrisbanes.snapper.ExperimentalSnapperApi public final class SnapperFlingBehaviorDefaults {
+ method @Deprecated public kotlin.jvm.functions.Function1 getMaximumFlingDistance();
+ method public kotlin.jvm.functions.Function3 getSnapIndex();
+ method public androidx.compose.animation.core.AnimationSpec getSpringAnimationSpec();
+ 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, kotlin.jvm.functions.Function3 super dev.chrisbanes.snapper.SnapperLayoutInfo,? super java.lang.Integer,? super java.lang.Integer,java.lang.Integer> 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 super dev.chrisbanes.snapper.SnapperLayoutInfo,java.lang.Float> maximumFlingDistance);
+ }
+
+ @dev.chrisbanes.snapper.ExperimentalSnapperApi public abstract class SnapperLayoutInfo {
+ ctor public SnapperLayoutInfo();
+ method public abstract boolean canScrollTowardsEnd();
+ method public abstract boolean canScrollTowardsStart();
+ method public abstract int determineTargetIndex(float velocity, androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, float maximumFlingDistance);
+ method public abstract int distanceToIndexSnap(int index);
+ 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;
+ }
+
+ public abstract class SnapperLayoutItemInfo {
+ ctor public SnapperLayoutItemInfo();
+ method public abstract int getIndex();
+ method public abstract int getOffset();
+ method public abstract int getSize();
+ property public abstract int index;
+ property public abstract int offset;
+ property public abstract int size;
+ }
+
+ public final class SnapperLogKt {
+ }
+
+}
+
diff --git a/kmp/lib/build.gradle.kts b/kmp/lib/build.gradle.kts
new file mode 100644
index 0000000..c416090
--- /dev/null
+++ b/kmp/lib/build.gradle.kts
@@ -0,0 +1,117 @@
+plugins {
+ kotlin("multiplatform")
+ alias(libs.plugins.jetbrainsCompose)
+ id("com.android.library")
+ id("maven-publish")
+ id("kotlinx-atomicfu")
+}
+
+kotlin {
+ android {
+ publishLibraryVariants("release")
+
+ mavenPublication {
+ artifactId = "${project.ext["POM_ARTIFACT_ID"].toString()}-android"
+ }
+ }
+
+ iosX64()
+ iosArm64()
+
+ sourceSets {
+ val commonMain by getting {
+ dependencies {
+ implementation(compose.foundation)
+ }
+ }
+
+ val commonTest by getting
+ val jvmCommonTest by creating
+
+ val androidMain by getting
+ val androidTest by getting {
+ dependsOn(jvmCommonTest)
+ dependencies {
+ implementation(project(":internal-testutils"))
+ implementation(libs.junit)
+ implementation(libs.truth)
+ implementation(libs.compose.ui.test.junit4)
+ implementation(libs.compose.ui.test.manifest)
+ implementation(libs.androidx.test.runner)
+ implementation(libs.robolectric)
+ }
+ }
+
+ val iosX64Main by getting
+ val iosArm64Main by getting
+
+ val iosMain by creating {
+ dependsOn(commonMain)
+ iosX64Main.dependsOn(this)
+ iosArm64Main.dependsOn(this)
+ }
+
+ val iosX64Test by getting
+ val iosArm64Test by getting
+
+ val iosTest by creating {
+ dependsOn(commonTest)
+ iosX64Test.dependsOn(this)
+ iosArm64Test.dependsOn(this)
+ }
+ }
+}
+
+android {
+ compileSdk = 33
+ namespace = "dev.chrisbanes.snapper"
+
+ defaultConfig {
+ minSdk = 24
+ }
+
+ packagingOptions {
+ resources.pickFirsts += "/META-INF/AL2.0"
+ resources.pickFirsts += "/META-INF/LGPL2.1"
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+}
+
+publishing {
+ publications {
+ this.withType(MavenPublication::class) {
+ group = project.ext["GROUP"].toString()
+ version = project.ext["VERSION_NAME"].toString()
+ // TODO make it nicer
+ artifactId = artifactId.replace("lib", project.ext["POM_ARTIFACT_ID"].toString())
+
+ pom {
+ name.set(project.ext["POM_NAME"].toString())
+ url.set(project.ext["POM_URL"].toString())
+ scm {
+ url.set(project.ext["POM_SCM_URL"].toString())
+ connection.set(project.ext["POM_SCM_CONNECTION"].toString())
+ developerConnection.set(project.ext["POM_SCM_DEV_CONNECTION"].toString())
+ }
+ licenses {
+ license {
+ name.set(project.ext["POM_LICENCE_NAME"].toString())
+ url.set(project.ext["POM_LICENCE_URL"].toString())
+ distribution.set(project.ext["POM_LICENCE_DIST"].toString())
+ }
+ }
+
+ developers {
+ developer {
+ id.set(project.ext["POM_DEVELOPER_ID"].toString())
+ name.set(project.ext["POM_DEVELOPER_NAME"].toString())
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/kmp/lib/gradle.properties b/kmp/lib/gradle.properties
new file mode 100644
index 0000000..8566192
--- /dev/null
+++ b/kmp/lib/gradle.properties
@@ -0,0 +1,3 @@
+POM_ARTIFACT_ID=snapper
+POM_NAME=Snapper for Jetpack Compose
+POM_PACKAGING=aar
\ No newline at end of file
diff --git a/kmp/lib/src/androidAndroidTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyColumnTest.kt b/kmp/lib/src/androidAndroidTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyColumnTest.kt
new file mode 100644
index 0000000..5575a1e
--- /dev/null
+++ b/kmp/lib/src/androidAndroidTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyColumnTest.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2021 Chris Banes
+ *
+ * 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
+ *
+ * https://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 dev.chrisbanes.snapper
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import dev.chrisbanes.internal.randomColor
+import dev.chrisbanes.internal.swipeAcrossCenterWithVelocity
+
+/**
+ * Contains [SnapperFlingBehavior] tests using [LazyColumn]. This class is extended
+ * in both the `androidTest` and `test` source sets for setup of the relevant
+ * test runner.
+ */
+@OptIn(ExperimentalSnapperApi::class) // SnapFlingBehavior is currently experimental
+public abstract class BaseSnapperFlingLazyColumnTest(
+ 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(snapIndexDelta) {
+
+ override fun SemanticsNodeInteraction.swipeAcrossCenter(
+ distancePercentage: Float,
+ velocityPerSec: Dp,
+ ): SemanticsNodeInteraction = swipeAcrossCenterWithVelocity(
+ distancePercentageY = if (reverseLayout) -distancePercentage else distancePercentage,
+ velocityPerSec = velocityPerSec,
+ )
+
+ override fun setTestContent(
+ flingBehavior: SnapperFlingBehavior,
+ count: () -> Int,
+ lazyListState: LazyListState,
+ ) {
+ rule.setContent {
+ applierScope = rememberCoroutineScope()
+ val itemCount = count()
+
+ Box {
+ LazyColumn(
+ state = lazyListState,
+ flingBehavior = flingBehavior,
+ verticalArrangement = Arrangement.spacedBy(itemSpacingDp.dp),
+ reverseLayout = reverseLayout,
+ contentPadding = contentPadding,
+ modifier = Modifier
+ .fillMaxSize()
+ .testTag("layout"),
+ ) {
+ items(itemCount) { index ->
+ Box(
+ modifier = Modifier
+ .size(ItemSize)
+ .background(randomColor())
+ .testTag(index.toString())
+ ) {
+ BasicText(
+ text = index.toString(),
+ modifier = Modifier.align(Alignment.Center)
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/kmp/lib/src/androidAndroidTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyRowTest.kt b/kmp/lib/src/androidAndroidTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyRowTest.kt
new file mode 100644
index 0000000..f8a7c3a
--- /dev/null
+++ b/kmp/lib/src/androidAndroidTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyRowTest.kt
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2021 Chris Banes
+ *
+ * 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
+ *
+ * https://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 dev.chrisbanes.snapper
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import dev.chrisbanes.internal.randomColor
+import dev.chrisbanes.internal.swipeAcrossCenterWithVelocity
+
+/**
+ * Contains [SnapperFlingBehavior] tests using [LazyRow]. This class is extended
+ * in both the `androidTest` and `test` source sets for setup of the relevant
+ * test runner.
+ */
+@OptIn(ExperimentalSnapperApi::class) // SnapFlingBehavior is currently experimental
+public abstract class BaseSnapperFlingLazyRowTest(
+ 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(snapIndexDelta) {
+
+ /**
+ * Returns the expected resolved layout direction for pages
+ */
+ private val laidOutRtl: Boolean
+ get() = if (layoutDirection == LayoutDirection.Rtl) !reverseLayout else reverseLayout
+
+ override fun SemanticsNodeInteraction.swipeAcrossCenter(
+ distancePercentage: Float,
+ velocityPerSec: Dp,
+ ): SemanticsNodeInteraction = swipeAcrossCenterWithVelocity(
+ distancePercentageX = if (laidOutRtl) -distancePercentage else distancePercentage,
+ velocityPerSec = velocityPerSec,
+ )
+
+ override fun setTestContent(
+ flingBehavior: SnapperFlingBehavior,
+ count: () -> Int,
+ lazyListState: LazyListState,
+ ) {
+ rule.setContent {
+ CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) {
+ applierScope = rememberCoroutineScope()
+ val itemCount = count()
+
+ Box {
+ LazyRow(
+ state = lazyListState,
+ flingBehavior = flingBehavior,
+ horizontalArrangement = Arrangement.spacedBy(itemSpacingDp.dp),
+ reverseLayout = reverseLayout,
+ contentPadding = contentPadding,
+ modifier = Modifier
+ .fillMaxSize()
+ .testTag("layout"),
+ ) {
+ items(itemCount) { index ->
+ Box(
+ modifier = Modifier
+ .size(ItemSize)
+ .background(randomColor())
+ .testTag(index.toString())
+ ) {
+ BasicText(
+ text = index.toString(),
+ modifier = Modifier.align(Alignment.Center)
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/kmp/lib/src/androidAndroidTest/kotlin/dev/chrisbanes/snapper/InstrumentedSnapperFlingLazyColumnTest.kt b/kmp/lib/src/androidAndroidTest/kotlin/dev/chrisbanes/snapper/InstrumentedSnapperFlingLazyColumnTest.kt
new file mode 100644
index 0000000..eef5078
--- /dev/null
+++ b/kmp/lib/src/androidAndroidTest/kotlin/dev/chrisbanes/snapper/InstrumentedSnapperFlingLazyColumnTest.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2021 Chris Banes
+ *
+ * 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
+ *
+ * https://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 dev.chrisbanes.snapper
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.ui.unit.dp
+import dev.chrisbanes.internal.combineWithParameters
+import dev.chrisbanes.internal.parameterizedParams
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+/**
+ * Version of [BaseSnapperFlingLazyColumnTest] which is designed to be run on device/emulators.
+ */
+@RunWith(Parameterized::class)
+class InstrumentedSnapperFlingLazyColumnTest(
+ snapIndexDelta: Int,
+ contentPadding: PaddingValues,
+ itemSpacingDp: Int,
+ reverseLayout: Boolean,
+) : BaseSnapperFlingLazyColumnTest(
+ snapIndexDelta,
+ contentPadding,
+ itemSpacingDp,
+ reverseLayout,
+) {
+ companion object {
+ /**
+ * On device we only test a subset of the combined parameters.
+ */
+ @JvmStatic
+ @Parameterized.Parameters(
+ name = "snapIndexDelta={0}," +
+ "contentPadding={1}," +
+ "itemSpacing={2}," +
+ "reverseLayout={3}"
+ )
+ fun data() = parameterizedParams()
+ // snapIndexDelta
+ .combineWithParameters(1, 4)
+ // contentPadding
+ .combineWithParameters(
+ PaddingValues(bottom = 32.dp), // Alignment.Top
+ PaddingValues(vertical = 32.dp), // Alignment.Center
+ PaddingValues(top = 32.dp), // Alignment.Bottom
+ )
+ // itemSpacingDp
+ .combineWithParameters(0, 4)
+ // reverseLayout
+ .combineWithParameters(false)
+ }
+}
diff --git a/kmp/lib/src/androidAndroidTest/kotlin/dev/chrisbanes/snapper/InstrumentedSnapperFlingLazyRowTest.kt b/kmp/lib/src/androidAndroidTest/kotlin/dev/chrisbanes/snapper/InstrumentedSnapperFlingLazyRowTest.kt
new file mode 100644
index 0000000..bdf8564
--- /dev/null
+++ b/kmp/lib/src/androidAndroidTest/kotlin/dev/chrisbanes/snapper/InstrumentedSnapperFlingLazyRowTest.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2021 Chris Banes
+ *
+ * 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
+ *
+ * https://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 dev.chrisbanes.snapper
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import dev.chrisbanes.internal.combineWithParameters
+import dev.chrisbanes.internal.parameterizedParams
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+/**
+ * Version of [BaseSnapperFlingLazyRowTest] which is designed to be run on device/emulators.
+ */
+@RunWith(Parameterized::class)
+class InstrumentedSnapperFlingLazyRowTest(
+ snapIndexDelta: Int,
+ contentPadding: PaddingValues,
+ itemSpacingDp: Int,
+ layoutDirection: LayoutDirection,
+ reverseLayout: Boolean,
+) : BaseSnapperFlingLazyRowTest(
+ snapIndexDelta,
+ contentPadding,
+ itemSpacingDp,
+ layoutDirection,
+ reverseLayout,
+) {
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(
+ name = "snapIndexDelta={0}," +
+ "contentPadding={1}," +
+ "itemSpacing={2}," +
+ "layoutDirection={3}," +
+ "reverseLayout={4}"
+ )
+ fun data() = parameterizedParams()
+ // snapIndexDelta
+ .combineWithParameters(1, 4)
+ // contentPadding
+ .combineWithParameters(
+ PaddingValues(end = 32.dp), // Alignment.Start
+ PaddingValues(horizontal = 32.dp), // Alignment.Center
+ PaddingValues(start = 32.dp), // Alignment.End
+ )
+ // itemSpacing
+ .combineWithParameters(0, 4)
+ // layoutDirection
+ .combineWithParameters(LayoutDirection.Ltr, LayoutDirection.Rtl)
+ // reverseLayout
+ .combineWithParameters(false)
+ }
+}
diff --git a/kmp/lib/src/androidAndroidTest/kotlin/dev/chrisbanes/snapper/SnapperFlingBehaviorTest.kt b/kmp/lib/src/androidAndroidTest/kotlin/dev/chrisbanes/snapper/SnapperFlingBehaviorTest.kt
new file mode 100644
index 0000000..887c9a4
--- /dev/null
+++ b/kmp/lib/src/androidAndroidTest/kotlin/dev/chrisbanes/snapper/SnapperFlingBehaviorTest.kt
@@ -0,0 +1,417 @@
+/*
+ * Copyright 2021 Chris Banes
+ *
+ * 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
+ *
+ * https://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 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
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.CoroutineScope
+import org.junit.Rule
+import org.junit.Test
+
+private const val MediumSwipeDistance = 0.75f
+private const val ShortSwipeDistance = 0.4f
+
+private val FastVelocity = 2000.dp
+private val MediumVelocity = 700.dp
+private val SlowVelocity = 100.dp
+
+internal val ItemSize = 200.dp
+
+@OptIn(ExperimentalSnapperApi::class) // Pager is currently experimental
+public abstract class SnapperFlingBehaviorTest(
+ private val snapIndexDelta: Int,
+) {
+ @get:Rule
+ val rule = createComposeRule()
+
+ /**
+ * This is a workaround for https://issuetracker.google.com/issues/179492185.
+ * Ideally we would have a way to get the applier scope from the rule
+ */
+ protected lateinit var applierScope: CoroutineScope
+
+ @Test
+ fun swipe() {
+ val lazyListState = LazyListState()
+ val snappingFlingBehavior = createSnapFlingBehavior(lazyListState)
+ setTestContent(
+ flingBehavior = snappingFlingBehavior,
+ lazyListState = lazyListState,
+ count = 10,
+ )
+
+ // First test swiping towards end, from 0 to -1, which should no-op
+ rule.onNodeWithTag("0").swipeAcrossCenter(MediumSwipeDistance)
+ rule.waitForIdle()
+ // ...and assert that nothing happened
+ lazyListState.assertCurrentItem(index = 0, offset = 0)
+
+ // Now swipe towards start, from page 0
+ rule.onNodeWithTag("0").swipeAcrossCenter(-MediumSwipeDistance)
+ rule.waitForIdle()
+
+ // ...and assert that we now laid out from page 1
+ lazyListState.assertCurrentItem(minIndex = 1, offset = 0)
+ }
+
+ @Test
+ fun swipeForwardAndBackFromZero() = swipeToEndAndBack(
+ initialIndex = 0,
+ count = 4
+ )
+
+ @Test
+ fun swipeForwardAndBackFromLargeIndex() = swipeToEndAndBack(
+ initialIndex = Int.MAX_VALUE / 2,
+ count = Int.MAX_VALUE
+ )
+
+ private fun swipeToEndAndBack(initialIndex: Int, count: Int) {
+ val lazyListState = LazyListState(firstVisibleItemIndex = initialIndex)
+ val snappingFlingBehavior = createSnapFlingBehavior(lazyListState)
+ setTestContent(
+ flingBehavior = snappingFlingBehavior,
+ lazyListState = lazyListState,
+ count = count,
+ )
+
+ var lastItemIndex = lazyListState.currentItem.index
+
+ // Now swipe towards start, from page 0 to page 1 and assert the layout
+ rule.onNodeWithTag("layout").swipeAcrossCenter(-MediumSwipeDistance)
+ rule.waitForIdle()
+ lazyListState.assertCurrentItem(minIndex = lastItemIndex + 1)
+ lastItemIndex = lazyListState.currentItem.index
+
+ // Repeat for 1 -> 2
+ rule.onNodeWithTag("layout").swipeAcrossCenter(-MediumSwipeDistance)
+ rule.waitForIdle()
+ lazyListState.assertCurrentItem(minIndex = lastItemIndex + 1)
+ lastItemIndex = lazyListState.currentItem.index
+
+ // Repeat for 2 -> 3
+ rule.onNodeWithTag("layout").swipeAcrossCenter(-MediumSwipeDistance)
+ rule.waitForIdle()
+ lazyListState.assertCurrentItem(minIndex = lastItemIndex + 1)
+ lastItemIndex = lazyListState.currentItem.index
+
+ // Swipe past the last item (if it is the last item). We shouldn't move
+ if (count - initialIndex == 4) {
+ rule.onNodeWithTag("layout").swipeAcrossCenter(-MediumSwipeDistance)
+ rule.waitForIdle()
+ lazyListState.assertCurrentItem(index = lastItemIndex, offset = 0)
+ }
+
+ // Swipe back from 3 -> 2
+ rule.onNodeWithTag("layout").swipeAcrossCenter(MediumSwipeDistance)
+ rule.waitForIdle()
+ lazyListState.assertCurrentItem(maxIndex = (lastItemIndex - 1).coerceAtLeast(0))
+ lastItemIndex = lazyListState.currentItem.index
+
+ // Swipe back from 2 -> 1
+ rule.onNodeWithTag("layout").swipeAcrossCenter(MediumSwipeDistance)
+ rule.waitForIdle()
+ lazyListState.assertCurrentItem(maxIndex = (lastItemIndex - 1).coerceAtLeast(0))
+ lastItemIndex = lazyListState.currentItem.index
+
+ // Swipe back from 1 -> 0
+ rule.onNodeWithTag("layout").swipeAcrossCenter(MediumSwipeDistance)
+ rule.waitForIdle()
+ lazyListState.assertCurrentItem(maxIndex = (lastItemIndex - 1).coerceAtLeast(0))
+ }
+
+ @Test
+ fun mediumDistance_fastSwipe_toFling() {
+ rule.mainClock.autoAdvance = false
+
+ val lazyListState = LazyListState()
+ val snappingFlingBehavior = createSnapFlingBehavior(lazyListState)
+ setTestContent(
+ flingBehavior = snappingFlingBehavior,
+ lazyListState = lazyListState,
+ count = 10,
+ )
+
+ assertThat(lazyListState.isScrollInProgress).isFalse()
+ assertThat(snappingFlingBehavior.animationTarget).isNull()
+ lazyListState.assertCurrentItem(index = 0, offset = 0)
+
+ // Now swipe towards start, from page 0 to page 1, over a medium distance of the item width.
+ // This should trigger a fling
+ rule.onNodeWithTag("0").swipeAcrossCenter(
+ distancePercentage = -MediumSwipeDistance,
+ velocityPerSec = FastVelocity,
+ )
+
+ assertThat(lazyListState.isScrollInProgress).isTrue()
+ assertThat(snappingFlingBehavior.animationTarget).isAtLeast(1)
+
+ // Now re-enable the clock advancement and let the fling animation run
+ rule.mainClock.autoAdvance = true
+ rule.waitForIdle()
+
+ // ...and assert that we now laid out from page 1
+ lazyListState.assertCurrentItem(minIndex = 1, offset = 0)
+ }
+
+ @Test
+ fun mediumDistance_slowSwipe_toSnapForward() {
+ rule.mainClock.autoAdvance = false
+
+ val lazyListState = LazyListState()
+ val snappingFlingBehavior = createSnapFlingBehavior(lazyListState)
+ setTestContent(
+ flingBehavior = snappingFlingBehavior,
+ lazyListState = lazyListState,
+ count = 10,
+ )
+
+ assertThat(lazyListState.isScrollInProgress).isFalse()
+ assertThat(snappingFlingBehavior.animationTarget).isNull()
+ lazyListState.assertCurrentItem(index = 0, offset = 0)
+
+ // Now swipe towards start, from page 0 to page 1, over a medium distance of the item width.
+ // This should trigger a spring to position 1
+ rule.onNodeWithTag("0").swipeAcrossCenter(
+ distancePercentage = -MediumSwipeDistance,
+ velocityPerSec = SlowVelocity,
+ )
+
+ assertThat(lazyListState.isScrollInProgress).isTrue()
+ 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(minIndex = 1, offset = 0)
+ }
+
+ @Test
+ fun shortDistance_fastSwipe_toFling() {
+ rule.mainClock.autoAdvance = false
+
+ val lazyListState = LazyListState()
+ val snappingFlingBehavior = createSnapFlingBehavior(lazyListState)
+ setTestContent(
+ flingBehavior = snappingFlingBehavior,
+ lazyListState = lazyListState,
+ count = 10,
+ )
+
+ assertThat(lazyListState.isScrollInProgress).isFalse()
+ assertThat(snappingFlingBehavior.animationTarget).isNull()
+ lazyListState.assertCurrentItem(index = 0, offset = 0)
+
+ // Now swipe towards start, from page 0 to page 1, over a short distance of the item width.
+ // This should trigger a spring back to the original position
+ rule.onNodeWithTag("0").swipeAcrossCenter(
+ distancePercentage = -ShortSwipeDistance,
+ velocityPerSec = FastVelocity,
+ )
+
+ assertThat(lazyListState.isScrollInProgress).isTrue()
+ assertThat(snappingFlingBehavior.animationTarget).isAtLeast(1)
+
+ // Now re-enable the clock advancement and let the fling animation run
+ rule.mainClock.autoAdvance = true
+ rule.waitForIdle()
+
+ // ...and assert that we now laid out from page 1
+ lazyListState.assertCurrentItem(minIndex = 1, offset = 0)
+ }
+
+ @Test
+ fun shortDistance_slowSwipe_toSnapBack() {
+ rule.mainClock.autoAdvance = false
+
+ val lazyListState = LazyListState()
+ val snappingFlingBehavior = createSnapFlingBehavior(lazyListState)
+ setTestContent(
+ flingBehavior = snappingFlingBehavior,
+ lazyListState = lazyListState,
+ count = 10,
+ )
+
+ assertThat(lazyListState.isScrollInProgress).isFalse()
+ assertThat(snappingFlingBehavior.animationTarget).isNull()
+ lazyListState.assertCurrentItem(index = 0, offset = 0)
+
+ // Now swipe towards start, from page 0 to page 1, over a short distance of the item width.
+ // This should trigger a spring back to the original position
+ rule.onNodeWithTag("0").swipeAcrossCenter(
+ distancePercentage = -ShortSwipeDistance,
+ velocityPerSec = SlowVelocity,
+ )
+
+ assertThat(lazyListState.isScrollInProgress).isTrue()
+ assertThat(snappingFlingBehavior.animationTarget).isEqualTo(0)
+
+ // Now re-enable the clock advancement and let the snap animation run
+ rule.mainClock.autoAdvance = true
+ rule.waitForIdle()
+
+ // ...and assert that we 'sprang back' to page 0
+ 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.
+ *
+ * @param distancePercentage The swipe distance in percentage of the node's size.
+ * Negative numbers mean swipe towards the start, positive towards the end.
+ * @param velocityPerSec Target end velocity for the swipe in Dps per second
+ */
+ abstract fun SemanticsNodeInteraction.swipeAcrossCenter(
+ distancePercentage: Float,
+ velocityPerSec: Dp = MediumVelocity
+ ): SemanticsNodeInteraction
+
+ private fun setTestContent(
+ count: Int,
+ lazyListState: LazyListState = LazyListState(),
+ flingBehavior: SnapperFlingBehavior = createSnapFlingBehavior(lazyListState),
+ ) {
+ setTestContent(
+ flingBehavior = flingBehavior,
+ count = { count },
+ lazyListState = lazyListState,
+ )
+ }
+
+ protected abstract fun setTestContent(
+ flingBehavior: SnapperFlingBehavior,
+ count: () -> Int,
+ lazyListState: LazyListState = LazyListState(),
+ )
+
+ private fun createSnapFlingBehavior(
+ lazyListState: LazyListState,
+ snapIndex: ((SnapperLayoutInfo, currentIndex: Int, targetIndex: Int) -> Int)? = null,
+ ): SnapperFlingBehavior = SnapperFlingBehavior(
+ layoutInfo = LazyListSnapperLayoutInfo(
+ lazyListState = lazyListState,
+ snapOffsetForItem = SnapOffsets.Start,
+ ),
+ decayAnimationSpec = exponentialDecay(),
+ snapIndex = snapIndex ?: { layout, currentIndex, targetIndex ->
+ targetIndex
+ .coerceIn(currentIndex - snapIndexDelta, currentIndex + snapIndexDelta)
+ .coerceIn(0, layout.totalItemsCount - 1)
+ },
+ )
+}
+
+/**
+ * This doesn't handle the scroll range < lazy size, but that won't happen in these tests
+ */
+private fun LazyListState.isScrolledToEnd(): Boolean {
+ val lastVisibleItem = layoutInfo.visibleItemsInfo.last()
+ if (lastVisibleItem.index == layoutInfo.totalItemsCount - 1) {
+ // This isn't perfect as it doesn't properly handle content padding, but good enough
+ return (lastVisibleItem.offset + lastVisibleItem.size) <= layoutInfo.viewportEndOffset
+ }
+ return false
+}
+
+private fun LazyListState.assertCurrentItem(
+ index: Int,
+ offset: Int = 0,
+) = assertCurrentItem(minIndex = index, maxIndex = index, offset = offset)
+
+private fun LazyListState.assertCurrentItem(
+ minIndex: Int = 0,
+ maxIndex: Int = Int.MAX_VALUE,
+ offset: Int = 0,
+) {
+ if (isScrolledToEnd()) return
+
+ currentItem.let {
+ assertThat(it.index).isAtLeast(minIndex)
+ assertThat(it.index).isAtMost(maxIndex)
+ assertThat(it.offset).isEqualTo(offset)
+ }
+}
+
+private val LazyListState.currentItem: LazyListItemInfo
+ get() = layoutInfo.visibleItemsInfo.asSequence()
+ .filter { it.offset <= 0 }
+ .last()
diff --git a/kmp/lib/src/androidMain/kotlin/dev/chrisbanes/snapper/SnapperLog.android.kt b/kmp/lib/src/androidMain/kotlin/dev/chrisbanes/snapper/SnapperLog.android.kt
new file mode 100644
index 0000000..3c2d07e
--- /dev/null
+++ b/kmp/lib/src/androidMain/kotlin/dev/chrisbanes/snapper/SnapperLog.android.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2022 Chris Banes
+ *
+ * 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
+ *
+ * https://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 dev.chrisbanes.snapper
+
+import android.util.Log
+
+internal actual object SnapperLog {
+ actual inline fun d(tag: String, message: () -> String) {
+ if (DebugLog) {
+ Log.d(tag, message())
+ }
+ }
+}
+
+internal actual fun Double.formatToString(): String = "%.3f".format(this)
diff --git a/kmp/lib/src/androidTest/kotlin/dev/chrisbanes/snapper/RobolectricSnapperFlingLazyColumnTest.kt b/kmp/lib/src/androidTest/kotlin/dev/chrisbanes/snapper/RobolectricSnapperFlingLazyColumnTest.kt
new file mode 100644
index 0000000..64fadae
--- /dev/null
+++ b/kmp/lib/src/androidTest/kotlin/dev/chrisbanes/snapper/RobolectricSnapperFlingLazyColumnTest.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2021 Chris Banes
+ *
+ * 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
+ *
+ * https://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 dev.chrisbanes.snapper
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.ui.unit.dp
+import dev.chrisbanes.internal.combineWithParameters
+import dev.chrisbanes.internal.parameterizedParams
+import org.junit.runner.RunWith
+import org.robolectric.ParameterizedRobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * Version of [BaseSnapperFlingLazyColumnTest] which is designed to be run on Robolectric.
+ */
+@Config(qualifiers = "w360dp-h640dp-xhdpi")
+@RunWith(ParameterizedRobolectricTestRunner::class)
+class RobolectricSnapperFlingLazyColumnTest(
+ snapIndexDelta: Int,
+ contentPadding: PaddingValues,
+ itemSpacingDp: Int,
+ reverseLayout: Boolean,
+) : BaseSnapperFlingLazyColumnTest(
+ snapIndexDelta,
+ contentPadding,
+ itemSpacingDp,
+ reverseLayout,
+) {
+ companion object {
+ @JvmStatic
+ @ParameterizedRobolectricTestRunner.Parameters(
+ name = "snapIndexDelta={0}," +
+ "contentPadding={1}," +
+ "itemSpacing={2}," +
+ "reverseLayout={3}"
+ )
+ fun data() = parameterizedParams()
+ // snapIndexDelta
+ .combineWithParameters(1, 4, 10)
+ // contentPadding
+ .combineWithParameters(
+ PaddingValues(bottom = 32.dp), // Alignment.Top
+ PaddingValues(vertical = 32.dp), // Alignment.Center
+ PaddingValues(top = 32.dp), // Alignment.Bottom
+ )
+ // itemSpacingDp
+ .combineWithParameters(0, 4)
+ // reverseLayout
+ .combineWithParameters(true, false)
+ }
+}
diff --git a/kmp/lib/src/androidTest/kotlin/dev/chrisbanes/snapper/RobolectricSnapperFlingLazyRowTest.kt b/kmp/lib/src/androidTest/kotlin/dev/chrisbanes/snapper/RobolectricSnapperFlingLazyRowTest.kt
new file mode 100644
index 0000000..6a78824
--- /dev/null
+++ b/kmp/lib/src/androidTest/kotlin/dev/chrisbanes/snapper/RobolectricSnapperFlingLazyRowTest.kt
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2021 Chris Banes
+ *
+ * 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
+ *
+ * https://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 dev.chrisbanes.snapper
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import dev.chrisbanes.internal.combineWithParameters
+import dev.chrisbanes.internal.parameterizedParams
+import org.junit.runner.RunWith
+import org.robolectric.ParameterizedRobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * Version of [BaseSnapperFlingLazyRowTest] which is designed to be run on Robolectric.
+ */
+@Config(qualifiers = "w360dp-h640dp-xhdpi")
+@RunWith(ParameterizedRobolectricTestRunner::class)
+class RobolectricSnapperFlingLazyRowTest(
+ snapIndexDelta: Int,
+ contentPadding: PaddingValues,
+ itemSpacingDp: Int,
+ layoutDirection: LayoutDirection,
+ reverseLayout: Boolean,
+) : BaseSnapperFlingLazyRowTest(
+ snapIndexDelta,
+ contentPadding,
+ itemSpacingDp,
+ layoutDirection,
+ reverseLayout,
+) {
+ companion object {
+ @JvmStatic
+ @ParameterizedRobolectricTestRunner.Parameters(
+ name = "snapIndexDelta={0}," +
+ "contentPadding={1}," +
+ "itemSpacing={2}," +
+ "layoutDirection={3}," +
+ "reverseLayout={4}"
+ )
+ fun data() = parameterizedParams()
+ // snapIndexDelta
+ .combineWithParameters(1, 4, 10)
+ // contentPadding
+ .combineWithParameters(
+ PaddingValues(end = 32.dp), // Alignment.Start
+ PaddingValues(horizontal = 32.dp), // Alignment.Center
+ PaddingValues(start = 32.dp), // Alignment.End
+ )
+ // itemSpacing
+ .combineWithParameters(0, 4)
+ // layoutDirection
+ .combineWithParameters(LayoutDirection.Ltr, LayoutDirection.Rtl)
+ // reverseLayout
+ .combineWithParameters(true, false)
+ }
+}
diff --git a/kmp/lib/src/androidTest/resources/robolectric.properties b/kmp/lib/src/androidTest/resources/robolectric.properties
new file mode 100644
index 0000000..2806eaf
--- /dev/null
+++ b/kmp/lib/src/androidTest/resources/robolectric.properties
@@ -0,0 +1,3 @@
+# Pin SDK to 30 since Robolectric does not currently support API 31:
+# https://github.com/robolectric/robolectric/issues/6635
+sdk=30
diff --git a/kmp/lib/src/commonMain/kotlin/dev/chrisbanes/snapper/LazyList.kt b/kmp/lib/src/commonMain/kotlin/dev/chrisbanes/snapper/LazyList.kt
new file mode 100644
index 0000000..ea87f24
--- /dev/null
+++ b/kmp/lib/src/commonMain/kotlin/dev/chrisbanes/snapper/LazyList.kt
@@ -0,0 +1,337 @@
+/*
+ * Copyright 2021 Chris Banes
+ *
+ * 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
+ *
+ * https://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 dev.chrisbanes.snapper
+
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.animation.core.DecayAnimationSpec
+import androidx.compose.animation.core.calculateTargetValue
+import androidx.compose.animation.rememberSplineBasedDecay
+import androidx.compose.foundation.gestures.FlingBehavior
+import androidx.compose.foundation.lazy.LazyListItemInfo
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import kotlin.math.abs
+import kotlin.math.absoluteValue
+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 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,
+ decayAnimationSpec: DecayAnimationSpec = rememberSplineBasedDecay(),
+ springAnimationSpec: AnimationSpec = SnapperFlingBehaviorDefaults.SpringAnimationSpec,
+ snapIndex: (SnapperLayoutInfo, startIndex: Int, targetIndex: Int) -> Int = SnapperFlingBehaviorDefaults.SnapIndex,
+): SnapperFlingBehavior = rememberSnapperFlingBehavior(
+ layoutInfo = rememberLazyListSnapperLayoutInfo(lazyListState, snapOffsetForItem),
+ decayAnimationSpec = decayAnimationSpec,
+ springAnimationSpec = springAnimationSpec,
+ snapIndex = snapIndex,
+)
+
+@Deprecated(
+ "endContentPadding is no longer necessary to be passed in",
+ ReplaceWith("rememberSnapperFlingBehavior(lazyListState, snapOffsetForItem, decayAnimationSpec, springAnimationSpec, snapIndex)")
+)
+@ExperimentalSnapperApi
+@Composable
+public fun rememberSnapperFlingBehavior(
+ lazyListState: LazyListState,
+ snapOffsetForItem: (layoutInfo: SnapperLayoutInfo, item: SnapperLayoutItemInfo) -> Int = SnapOffsets.Center,
+ @Suppress("UNUSED_PARAMETER") endContentPadding: Dp = 0.dp,
+ decayAnimationSpec: DecayAnimationSpec = rememberSplineBasedDecay(),
+ springAnimationSpec: AnimationSpec = SnapperFlingBehaviorDefaults.SpringAnimationSpec,
+ snapIndex: (SnapperLayoutInfo, startIndex: Int, targetIndex: Int) -> Int = SnapperFlingBehaviorDefaults.SnapIndex,
+): SnapperFlingBehavior = rememberSnapperFlingBehavior(
+ layoutInfo = rememberLazyListSnapperLayoutInfo(lazyListState, snapOffsetForItem),
+ decayAnimationSpec = decayAnimationSpec,
+ springAnimationSpec = springAnimationSpec,
+ snapIndex = snapIndex,
+)
+
+@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,
+ endContentPadding: Dp = 0.dp,
+ decayAnimationSpec: DecayAnimationSpec = rememberSplineBasedDecay(),
+ springAnimationSpec: AnimationSpec = SnapperFlingBehaviorDefaults.SpringAnimationSpec,
+ maximumFlingDistance: (SnapperLayoutInfo) -> Float,
+): SnapperFlingBehavior = rememberSnapperFlingBehavior(
+ layoutInfo = rememberLazyListSnapperLayoutInfo(
+ lazyListState = lazyListState,
+ snapOffsetForItem = snapOffsetForItem,
+ endContentPadding = endContentPadding
+ ),
+ decayAnimationSpec = decayAnimationSpec,
+ springAnimationSpec = springAnimationSpec,
+ maximumFlingDistance = maximumFlingDistance,
+)
+
+/**
+ * Create and remember a [SnapperLayoutInfo] which works with [LazyListState].
+ *
+ * @param lazyListState The [LazyListState] to update.
+ * @param snapOffsetForItem Block which returns which offset the given item should 'snap' to.
+ * See [SnapOffsets] for provided values.
+ */
+@Deprecated(
+ "endContentPadding is no longer necessary to be passed in",
+ ReplaceWith("rememberLazyListSnapperLayoutInfo(lazyListState, snapOffsetForItem)")
+)
+@ExperimentalSnapperApi
+@Composable
+public fun rememberLazyListSnapperLayoutInfo(
+ lazyListState: LazyListState,
+ snapOffsetForItem: (layoutInfo: SnapperLayoutInfo, item: SnapperLayoutItemInfo) -> Int = SnapOffsets.Center,
+ @Suppress("UNUSED_PARAMETER") endContentPadding: Dp = 0.dp,
+): LazyListSnapperLayoutInfo {
+ return rememberLazyListSnapperLayoutInfo(lazyListState, snapOffsetForItem)
+}
+
+/**
+ * Create and remember a [SnapperLayoutInfo] which works with [LazyListState].
+ *
+ * @param lazyListState The [LazyListState] to update.
+ * @param snapOffsetForItem Block which returns which offset the given item should 'snap' to.
+ * See [SnapOffsets] for provided values.
+ */
+@ExperimentalSnapperApi
+@Composable
+public fun rememberLazyListSnapperLayoutInfo(
+ lazyListState: LazyListState,
+ snapOffsetForItem: (layoutInfo: SnapperLayoutInfo, item: SnapperLayoutItemInfo) -> Int = SnapOffsets.Center,
+): LazyListSnapperLayoutInfo = remember(lazyListState, snapOffsetForItem) {
+ LazyListSnapperLayoutInfo(
+ lazyListState = lazyListState,
+ snapOffsetForItem = snapOffsetForItem,
+ )
+}
+
+/**
+ * A [SnapperLayoutInfo] which works with [LazyListState]. Typically this would be remembered
+ * using [rememberLazyListSnapperLayoutInfo].
+ *
+ * @param lazyListState The [LazyListState] to update.
+ * @param snapOffsetForItem Block which returns which offset the given item should 'snap' to.
+ * See [SnapOffsets] for provided values.
+ */
+@ExperimentalSnapperApi
+public class LazyListSnapperLayoutInfo(
+ private val lazyListState: LazyListState,
+ private val snapOffsetForItem: (layoutInfo: SnapperLayoutInfo, item: SnapperLayoutItemInfo) -> Int,
+) : SnapperLayoutInfo() {
+
+ @Deprecated(
+ "endContentPadding is no longer necessary to be passed in",
+ ReplaceWith("LazyListSnapperLayoutInfo(lazyListState, snapOffsetForItem)")
+ )
+ public constructor(
+ lazyListState: LazyListState,
+ snapOffsetForItem: (layoutInfo: SnapperLayoutInfo, item: SnapperLayoutItemInfo) -> Int,
+ @Suppress("UNUSED_PARAMETER") endContentPadding: Int = 0,
+ ) : this(lazyListState, snapOffsetForItem)
+
+ /**
+ * Lazy lists always use 0 as the start scroll offset (within content padding)
+ */
+ override val startScrollOffset: Int = 0
+
+ /**
+ * viewportEndOffset is the last visible offset, so we need to remove any end content padding
+ * to get the end of the scroll range
+ */
+ override val endScrollOffset: Int
+ get() = lazyListState.layoutInfo.let { it.viewportEndOffset - it.afterContentPadding }
+
+ private val itemCount: Int get() = lazyListState.layoutInfo.totalItemsCount
+
+ override val totalItemsCount: Int
+ get() = lazyListState.layoutInfo.totalItemsCount
+
+ override val currentItem: SnapperLayoutItemInfo? by derivedStateOf {
+ visibleItems.lastOrNull { it.offset <= snapOffsetForItem(this, it) }
+ }
+
+ override val visibleItems: Sequence
+ get() = lazyListState.layoutInfo.visibleItemsInfo.asSequence()
+ .map(::LazyListSnapperLayoutItemInfo)
+
+ override fun distanceToIndexSnap(index: Int): Int {
+ val itemInfo = visibleItems.firstOrNull { it.index == index }
+ if (itemInfo != null) {
+ // If we have the item visible, we can calculate using the offset. Woop.
+ return itemInfo.offset - snapOffsetForItem(this, itemInfo)
+ }
+
+ // Otherwise we need to guesstimate, using the current item snap point and
+ // multiplying distancePerItem by the index delta
+ val currentItem = currentItem ?: return 0 // TODO: throw?
+ return ((index - currentItem.index) * estimateDistancePerItem()).roundToInt() +
+ currentItem.offset -
+ snapOffsetForItem(this, currentItem)
+ }
+
+ override fun canScrollTowardsStart(): Boolean {
+ return lazyListState.layoutInfo.visibleItemsInfo.firstOrNull()?.let {
+ it.index > 0 || it.offset < startScrollOffset
+ } ?: false
+ }
+
+ override fun canScrollTowardsEnd(): Boolean {
+ return lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.let {
+ it.index < itemCount - 1 || (it.offset + it.size) > endScrollOffset
+ } ?: false
+ }
+
+ override fun determineTargetIndex(
+ velocity: Float,
+ decayAnimationSpec: DecayAnimationSpec,
+ maximumFlingDistance: Float,
+ ): Int {
+ val curr = currentItem ?: return -1
+
+ val distancePerItem = estimateDistancePerItem()
+ if (distancePerItem <= 0) {
+ // If we don't have a valid distance, return the current item
+ return curr.index
+ }
+
+ val distanceToCurrent = distanceToIndexSnap(curr.index)
+ val distanceToNext = distanceToIndexSnap(curr.index + 1)
+
+ if (abs(velocity) < 0.5f) {
+ // If we don't have a velocity, target whichever item is closer
+ return when {
+ distanceToCurrent.absoluteValue < distanceToNext.absoluteValue -> curr.index
+ else -> curr.index + 1
+ }.coerceIn(0, itemCount - 1)
+ }
+
+ // Otherwise we calculate using the velocity
+ val flingDistance = decayAnimationSpec.calculateTargetValue(0f, velocity)
+ .coerceIn(-maximumFlingDistance, maximumFlingDistance)
+ .let { distance ->
+ // It's likely that the user has already scrolled an amount before the fling
+ // has been started. We compensate for that by removing the scrolled distance
+ // from the calculated fling distance. This is necessary so that we don't fling
+ // past the max fling distance.
+ if (velocity < 0) {
+ (distance + distanceToNext).coerceAtMost(0f)
+ } else {
+ (distance + distanceToCurrent).coerceAtLeast(0f)
+ }
+ }
+
+ val flingIndexDelta = flingDistance / distancePerItem.toDouble()
+ val currentItemOffsetRatio = distanceToCurrent / distancePerItem.toDouble()
+
+ // The index offset from the current index. We round this value which results in
+ // flings rounding towards the (relative) infinity. The key use case for this is to
+ // support short + fast flings. These could result in a fling distance of ~70% of the
+ // item distance (example). The rounding ensures that we target the next page.
+ val indexOffset = (flingIndexDelta - currentItemOffsetRatio).roundToInt()
+
+ return (curr.index + indexOffset).coerceIn(0, itemCount - 1)
+ .also { result ->
+ SnapperLog.d {
+ "determineTargetIndex. " +
+ "result: $result, " +
+ "current item: $curr, " +
+ "current item offset: ${currentItemOffsetRatio.formatToString()}, " +
+ "distancePerItem: $distancePerItem, " +
+ "maximumFlingDistance: ${maximumFlingDistance.formatToString()}, " +
+ "flingDistance: ${flingDistance.formatToString()}, " +
+ "flingIndexDelta: ${flingIndexDelta.formatToString()}"
+ }
+ }
+ }
+
+ /**
+ * This attempts to calculate the item spacing for the layout, by looking at the distance
+ * between the visible items. If there's only 1 visible item available, it returns 0.
+ */
+ private fun calculateItemSpacing(): Int = with(lazyListState.layoutInfo) {
+ if (visibleItemsInfo.size >= 2) {
+ val first = visibleItemsInfo[0]
+ val second = visibleItemsInfo[1]
+ second.offset - (first.size + first.offset)
+ } else 0
+ }
+
+ /**
+ * Computes an average pixel value to pass a single child.
+ *
+ * Returns a negative value if it cannot be calculated.
+ *
+ * @return A float value that is the average number of pixels needed to scroll by one view in
+ * the relevant direction.
+ */
+ private fun estimateDistancePerItem(): Float = with(lazyListState.layoutInfo) {
+ if (visibleItemsInfo.isEmpty()) return -1f
+
+ val minPosView = visibleItemsInfo.minByOrNull { it.offset } ?: return -1f
+ val maxPosView = visibleItemsInfo.maxByOrNull { it.offset + it.size } ?: return -1f
+
+ val start = min(minPosView.offset, maxPosView.offset)
+ val end = max(minPosView.offset + minPosView.size, maxPosView.offset + maxPosView.size)
+
+ // We add an extra `itemSpacing` onto the calculated total distance. This ensures that
+ // the calculated mean contains an item spacing for each visible item
+ // (not just spacing between items)
+ return when (val distance = end - start) {
+ 0 -> -1f // If we don't have a distance, return -1
+ else -> (distance + calculateItemSpacing()) / visibleItemsInfo.size.toFloat()
+ }
+ }
+}
+
+private class LazyListSnapperLayoutItemInfo(
+ private val lazyListItem: LazyListItemInfo,
+) : SnapperLayoutItemInfo() {
+ override val index: Int get() = lazyListItem.index
+ override val offset: Int get() = lazyListItem.offset
+ override val size: Int get() = lazyListItem.size
+}
diff --git a/kmp/lib/src/commonMain/kotlin/dev/chrisbanes/snapper/SnapperFlingBehavior.kt b/kmp/lib/src/commonMain/kotlin/dev/chrisbanes/snapper/SnapperFlingBehavior.kt
new file mode 100644
index 0000000..749dba5
--- /dev/null
+++ b/kmp/lib/src/commonMain/kotlin/dev/chrisbanes/snapper/SnapperFlingBehavior.kt
@@ -0,0 +1,675 @@
+/*
+ * Copyright 2021 Chris Banes
+ *
+ * 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
+ *
+ * https://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.
+ */
+
+@file:Suppress("NOTHING_TO_INLINE")
+
+package dev.chrisbanes.snapper
+
+import androidx.compose.animation.core.AnimationScope
+import androidx.compose.animation.core.AnimationSpec
+import androidx.compose.animation.core.AnimationState
+import androidx.compose.animation.core.AnimationVector1D
+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.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
+import kotlin.math.absoluteValue
+
+@RequiresOptIn(message = "Snapper is experimental. The API may be changed in the future.")
+@Retention(AnnotationRetention.BINARY)
+public annotation class ExperimentalSnapperApi
+
+/**
+ * Default values used for [SnapperFlingBehavior] & [rememberSnapperFlingBehavior].
+ */
+@ExperimentalSnapperApi
+public object SnapperFlingBehaviorDefaults {
+ /**
+ * [AnimationSpec] used as the default value for the `snapAnimationSpec` parameter on
+ * [rememberSnapperFlingBehavior] and [SnapperFlingBehavior].
+ */
+ public val SpringAnimationSpec: AnimationSpec = spring(stiffness = 400f)
+
+ /**
+ * The default implementation for the `maximumFlingDistance` parameter of
+ * [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
+ )
+}
+
+/**
+ * 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 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,
+ decayAnimationSpec: DecayAnimationSpec = rememberSplineBasedDecay(),
+ springAnimationSpec: AnimationSpec = SnapperFlingBehaviorDefaults.SpringAnimationSpec,
+ maximumFlingDistance: (SnapperLayoutInfo) -> Float = SnapperFlingBehaviorDefaults.MaximumFlingDistance,
+): SnapperFlingBehavior = remember(
+ layoutInfo,
+ decayAnimationSpec,
+ springAnimationSpec,
+ maximumFlingDistance,
+) {
+ SnapperFlingBehavior(
+ layoutInfo = layoutInfo,
+ decayAnimationSpec = decayAnimationSpec,
+ springAnimationSpec = springAnimationSpec,
+ maximumFlingDistance = maximumFlingDistance,
+ )
+}
+
+/**
+ * Contains the necessary information about the scrolling layout for [SnapperFlingBehavior]
+ * to determine how to fling.
+ */
+@ExperimentalSnapperApi
+public abstract class SnapperLayoutInfo {
+ /**
+ * The start offset of where items can be scrolled to. This value should only include
+ * scrollable regions. For example this should not include fixed content padding.
+ * For most layouts, this will be 0.
+ */
+ public abstract val startScrollOffset: Int
+
+ /**
+ * The end offset of where items can be scrolled to. This value should only include
+ * scrollable regions. For example this should not include fixed content padding.
+ * For most layouts, this will the width of the container, minus content padding.
+ */
+ public abstract val endScrollOffset: Int
+
+ /**
+ * A sequence containing the currently visible items in the layout.
+ */
+ public abstract val visibleItems: Sequence
+
+ /**
+ * The current item which covers the desired snap point, or null if there is no item.
+ * The item returned may not yet currently be snapped into the final position.
+ */
+ 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].
+ *
+ * @param velocity Velocity of the fling. This can be 0.
+ * @param decayAnimationSpec The decay fling animation spec.
+ * @param maximumFlingDistance The maximum distance in pixels which should be scrolled.
+ */
+ public abstract fun determineTargetIndex(
+ velocity: Float,
+ decayAnimationSpec: DecayAnimationSpec,
+ maximumFlingDistance: Float,
+ ): Int
+
+ /**
+ * Calculate the distance in pixels needed to scroll to the given [index]. The value returned
+ * signifies which direction to scroll in:
+ *
+ * - Positive values indicate to scroll towards the end.
+ * - Negative values indicate to scroll towards the start.
+ *
+ * If a precise calculation can not be found, a realistic estimate is acceptable.
+ */
+ public abstract fun distanceToIndexSnap(index: Int): Int
+
+ /**
+ * Returns true if the layout has some scroll range remaining to scroll towards the start.
+ */
+ public abstract fun canScrollTowardsStart(): Boolean
+
+ /**
+ * Returns true if the layout has some scroll range remaining to scroll towards the end.
+ */
+ public abstract fun canScrollTowardsEnd(): Boolean
+}
+
+/**
+ * Contains information about a single item in a scrolling layout.
+ */
+public abstract class SnapperLayoutItemInfo {
+ public abstract val index: Int
+ public abstract val offset: Int
+ public abstract val size: Int
+
+ override fun toString(): String {
+ return "SnapperLayoutItemInfo(index=$index, offset=$offset, size=$size)"
+ }
+}
+
+/**
+ * Contains a number of values which can be used for the `snapOffsetForItem` parameter on
+ * [rememberLazyListSnapperLayoutInfo] and [LazyListSnapperLayoutInfo].
+ */
+@ExperimentalSnapperApi
+@Suppress("unused") // public vals which aren't used in the project
+public object SnapOffsets {
+ /**
+ * Snap offset which results in the start edge of the item, snapping to the start scrolling
+ * edge of the lazy list.
+ */
+ public val Start: (SnapperLayoutInfo, SnapperLayoutItemInfo) -> Int =
+ { layout, _ -> layout.startScrollOffset }
+
+ /**
+ * Snap offset which results in the item snapping in the center of the scrolling viewport
+ * of the lazy list.
+ */
+ public val Center: (SnapperLayoutInfo, SnapperLayoutItemInfo) -> Int = { layout, item ->
+ layout.startScrollOffset + (layout.endScrollOffset - layout.startScrollOffset - item.size) / 2
+ }
+
+ /**
+ * Snap offset which results in the end edge of the item, snapping to the end scrolling
+ * edge of the lazy list.
+ */
+ public val End: (SnapperLayoutInfo, SnapperLayoutItemInfo) -> Int = { layout, item ->
+ layout.endScrollOffset - item.size
+ }
+}
+
+/**
+ * A snapping [FlingBehavior] for [LazyListState]. Typically this would be created
+ * via [rememberSnapperFlingBehavior].
+ *
+ * Note: the default parameter value for [decayAnimationSpec] is different to the value used in
+ * [rememberSnapperFlingBehavior], due to not being able to access composable functions.
+ */
+@ExperimentalSnapperApi
+public class SnapperFlingBehavior private constructor(
+ private val layoutInfo: SnapperLayoutInfo,
+ private val decayAnimationSpec: DecayAnimationSpec,
+ 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.
+ */
+ public var animationTarget: Int? by mutableStateOf(null)
+ private set
+
+ override suspend fun ScrollScope.performFling(
+ initialVelocity: Float
+ ): Float {
+ // If we're at the start/end of the scroll range, we don't snap and assume the user
+ // wanted to scroll here.
+ if (!layoutInfo.canScrollTowardsStart() || !layoutInfo.canScrollTowardsEnd()) {
+ return initialVelocity
+ }
+
+ SnapperLog.d { "performFling. initialVelocity: $initialVelocity" }
+
+ val maxFlingDistance = maximumFlingDistance(layoutInfo)
+ require(maxFlingDistance > 0) {
+ "Distance returned by maximumFlingDistance should be greater than 0"
+ }
+
+ val initialItem = layoutInfo.currentItem ?: return initialVelocity
+
+ val targetIndex = layoutInfo.determineTargetIndex(
+ velocity = initialVelocity,
+ decayAnimationSpec = decayAnimationSpec,
+ maximumFlingDistance = maxFlingDistance,
+ ).let { target ->
+ // Let the snapIndex block transform the value
+ snapIndex(
+ layoutInfo,
+ // 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) initialItem.index + 1 else initialItem.index,
+ target,
+ )
+ }.also {
+ require(it in 0 until layoutInfo.totalItemsCount)
+ }
+
+ return flingToIndex(index = targetIndex, initialVelocity = initialVelocity)
+ }
+
+ private suspend fun ScrollScope.flingToIndex(
+ index: Int,
+ initialVelocity: Float,
+ ): Float {
+ val initialItem = layoutInfo.currentItem ?: return initialVelocity
+
+ if (initialItem.index == index && layoutInfo.distanceToIndexSnap(initialItem.index) == 0) {
+ SnapperLog.d {
+ "flingToIndex. Skipping fling, already at target. " +
+ "vel:$initialVelocity, " +
+ "initial item: $initialItem, " +
+ "target: $index"
+ }
+ return consumeVelocityIfNotAtScrollEdge(initialVelocity)
+ }
+
+ var velocityLeft = initialVelocity
+
+ if (decayAnimationSpec.canDecayBeyondCurrentItem(initialVelocity, initialItem)) {
+ // If the decay fling can scroll past the current item, start with a decayed fling
+ velocityLeft = performDecayFling(
+ initialItem = initialItem,
+ targetIndex = index,
+ initialVelocity = velocityLeft,
+ )
+ }
+
+ val currentItem = layoutInfo.currentItem ?: return initialVelocity
+ if (currentItem.index != index || layoutInfo.distanceToIndexSnap(index) != 0) {
+ // If we're not at the target index yet, spring to it
+ velocityLeft = performSpringFling(
+ initialItem = currentItem,
+ targetIndex = index,
+ initialVelocity = velocityLeft,
+ )
+ }
+
+ return consumeVelocityIfNotAtScrollEdge(velocityLeft)
+ }
+
+ /**
+ * Performs a decaying fling.
+ *
+ * If [flingThenSpring] is set to true, then a fling-then-spring animation might be used.
+ * If used, a decay fling will be run until we've scrolled to the preceding item of
+ * [targetIndex]. Once that happens, the decay animation is stopped and a spring animation
+ * is started to scroll the remainder of the distance. Visually this results in a much
+ * smoother finish to the animation, as it will slowly come to a stop at [targetIndex].
+ * Even if [flingThenSpring] is set to true, fling-then-spring animations are only available
+ * when scrolling 2 items or more.
+ *
+ * When [flingThenSpring] is not used, the decay animation will be stopped immediately upon
+ * scrolling past [targetIndex], which can result in an abrupt stop.
+ */
+ private suspend fun ScrollScope.performDecayFling(
+ initialItem: SnapperLayoutItemInfo,
+ targetIndex: Int,
+ initialVelocity: Float,
+ flingThenSpring: Boolean = true,
+ ): Float {
+ // If we're already at the target + snap offset, skip
+ if (initialItem.index == targetIndex && layoutInfo.distanceToIndexSnap(initialItem.index) == 0) {
+ SnapperLog.d {
+ "performDecayFling. Skipping decay, already at target. " +
+ "vel:$initialVelocity, " +
+ "current item: $initialItem, " +
+ "target: $targetIndex"
+ }
+ return consumeVelocityIfNotAtScrollEdge(initialVelocity)
+ }
+
+ SnapperLog.d {
+ "Performing decay fling. " +
+ "vel:$initialVelocity, " +
+ "current item: $initialItem, " +
+ "target: $targetIndex"
+ }
+
+ var velocityLeft = initialVelocity
+ var lastValue = 0f
+
+ // We can only fling-then-spring if we're flinging >= 2 items...
+ val canSpringThenFling = flingThenSpring && abs(targetIndex - initialItem.index) >= 2
+
+ try {
+ // Update the animationTarget
+ animationTarget = targetIndex
+
+ AnimationState(
+ initialValue = 0f,
+ initialVelocity = initialVelocity,
+ ).animateDecay(decayAnimationSpec) {
+ val delta = value - lastValue
+ val consumed = scrollBy(delta)
+ lastValue = value
+ velocityLeft = velocity
+
+ if (abs(delta - consumed) > 0.5f) {
+ // If some of the scroll was not consumed, cancel the animation now as we're
+ // likely at the end of the scroll range
+ cancelAnimation()
+ }
+
+ val currentItem = layoutInfo.currentItem
+ if (currentItem == null) {
+ cancelAnimation()
+ return@animateDecay
+ }
+
+ if (isRunning && canSpringThenFling) {
+ // If we're still running and fling-then-spring is enabled, check to see
+ // if we're at the 1 item width away (in the relevant direction). If we are,
+ // cancel the current decay and let flingToIndex() start a spring
+ if (velocity > 0 && currentItem.index == targetIndex - 1) {
+ cancelAnimation()
+ } else if (velocity < 0 && currentItem.index == targetIndex) {
+ cancelAnimation()
+ }
+ }
+
+ if (isRunning && performSnapBackIfNeeded(currentItem, targetIndex, ::scrollBy)) {
+ // If we're still running, check to see if we need to snap-back
+ // (if we've scrolled past the target)
+ cancelAnimation()
+ }
+ }
+ } finally {
+ animationTarget = null
+ }
+
+ SnapperLog.d {
+ "Decay fling finished. Distance: $lastValue. Final vel: $velocityLeft"
+ }
+
+ return velocityLeft
+ }
+
+ private suspend fun ScrollScope.performSpringFling(
+ initialItem: SnapperLayoutItemInfo,
+ targetIndex: Int,
+ initialVelocity: Float = 0f,
+ ): Float {
+ SnapperLog.d {
+ "performSpringFling. " +
+ "vel:$initialVelocity, " +
+ "initial item: $initialItem, " +
+ "target: $targetIndex"
+ }
+
+ var velocityLeft = when {
+ // Only use the initialVelocity if it is in the correct direction
+ targetIndex > initialItem.index && initialVelocity > 0 -> initialVelocity
+ targetIndex <= initialItem.index && initialVelocity < 0 -> initialVelocity
+ // Otherwise start at 0 velocity
+ else -> 0f
+ }
+ var lastValue = 0f
+
+ try {
+ // Update the animationTarget
+ animationTarget = targetIndex
+
+ AnimationState(
+ initialValue = lastValue,
+ initialVelocity = velocityLeft,
+ ).animateTo(
+ targetValue = layoutInfo.distanceToIndexSnap(targetIndex).toFloat(),
+ animationSpec = springAnimationSpec,
+ ) {
+ val delta = value - lastValue
+ val consumed = scrollBy(delta)
+ lastValue = value
+ velocityLeft = velocity
+
+ val currentItem = layoutInfo.currentItem
+ if (currentItem == null) {
+ cancelAnimation()
+ return@animateTo
+ }
+
+ if (performSnapBackIfNeeded(currentItem, targetIndex, ::scrollBy)) {
+ cancelAnimation()
+ } else if (abs(delta - consumed) > 0.5f) {
+ // If we're still running but some of the scroll was not consumed,
+ // cancel the animation now
+ cancelAnimation()
+ }
+ }
+ } finally {
+ animationTarget = null
+ }
+
+ SnapperLog.d {
+ "Spring fling finished. Distance: $lastValue. Final vel: $velocityLeft"
+ }
+
+ return velocityLeft
+ }
+
+ /**
+ * Returns true if we needed to perform a snap back, and the animation should be cancelled.
+ */
+ private fun AnimationScope.performSnapBackIfNeeded(
+ currentItem: SnapperLayoutItemInfo,
+ targetIndex: Int,
+ scrollBy: (pixels: Float) -> Float,
+ ): Boolean {
+ SnapperLog.d {
+ "scroll tick. " +
+ "vel:$velocity, " +
+ "current item: $currentItem"
+ }
+
+ // Calculate the 'snap back'. If the returned value is 0, we don't need to do anything.
+ val snapBackAmount = calculateSnapBack(velocity, currentItem, targetIndex)
+
+ if (snapBackAmount != 0) {
+ // 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
+ SnapperLog.d {
+ "Scrolled past item. " +
+ "vel:$velocity, " +
+ "current item: $currentItem} " +
+ "target:$targetIndex"
+ }
+ scrollBy(snapBackAmount.toFloat())
+ return true
+ }
+
+ return false
+ }
+
+ private fun DecayAnimationSpec.canDecayBeyondCurrentItem(
+ velocity: Float,
+ currentItem: SnapperLayoutItemInfo,
+ ): Boolean {
+ // If we don't have a velocity, return false
+ if (velocity.absoluteValue < 0.5f) return false
+
+ val flingDistance = calculateTargetValue(0f, velocity)
+
+ SnapperLog.d {
+ "canDecayBeyondCurrentItem. " +
+ "initialVelocity: $velocity, " +
+ "flingDistance: $flingDistance, " +
+ "current item: $currentItem"
+ }
+
+ return if (velocity < 0) {
+ // backwards, towards 0
+ flingDistance <= layoutInfo.distanceToIndexSnap(currentItem.index)
+ } else {
+ // forwards, toward index + 1
+ flingDistance >= layoutInfo.distanceToIndexSnap(currentItem.index + 1)
+ }
+ }
+
+ /**
+ * Returns the distance in pixels that is required to 'snap back' to the [targetIndex].
+ * Returns 0 if a snap back is not needed.
+ */
+ private fun calculateSnapBack(
+ initialVelocity: Float,
+ currentItem: SnapperLayoutItemInfo,
+ targetIndex: Int,
+ ): Int = when {
+ // forwards
+ initialVelocity > 0 && currentItem.index >= targetIndex -> {
+ layoutInfo.distanceToIndexSnap(currentItem.index)
+ }
+ initialVelocity < 0 && currentItem.index <= targetIndex - 1 -> {
+ layoutInfo.distanceToIndexSnap(currentItem.index + 1)
+ }
+ else -> 0
+ }
+
+ private fun consumeVelocityIfNotAtScrollEdge(velocity: Float): Float {
+ if (velocity < 0 && !layoutInfo.canScrollTowardsStart()) {
+ // If there is remaining velocity towards the start and we're at the scroll start,
+ // we don't consume. This enables the overscroll effect where supported
+ return velocity
+ } else if (velocity > 0 && !layoutInfo.canScrollTowardsEnd()) {
+ // If there is remaining velocity towards the end and we're at the scroll end,
+ // we don't consume. This enables the overscroll effect where supported
+ return velocity
+ }
+ // Else we return 0 to consume the remaining velocity
+ return 0f
+ }
+}
diff --git a/kmp/lib/src/commonMain/kotlin/dev/chrisbanes/snapper/SnapperLog.kt b/kmp/lib/src/commonMain/kotlin/dev/chrisbanes/snapper/SnapperLog.kt
new file mode 100644
index 0000000..3f9e1eb
--- /dev/null
+++ b/kmp/lib/src/commonMain/kotlin/dev/chrisbanes/snapper/SnapperLog.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2022 Chris Banes
+ *
+ * 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
+ *
+ * https://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 dev.chrisbanes.snapper
+
+internal const val DebugLog = false
+
+internal expect object SnapperLog {
+ inline fun d(tag: String = "SnapperFlingBehavior", message: () -> String)
+}
+
+internal expect fun Double.formatToString(): String
+internal fun Float.formatToString(): String = toDouble().formatToString()
diff --git a/kmp/lib/src/iosMain/kotlin/dev/chrisbanes/snapper/SnapperLog.ios.kt b/kmp/lib/src/iosMain/kotlin/dev/chrisbanes/snapper/SnapperLog.ios.kt
new file mode 100644
index 0000000..de29431
--- /dev/null
+++ b/kmp/lib/src/iosMain/kotlin/dev/chrisbanes/snapper/SnapperLog.ios.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2022 Chris Banes
+ *
+ * 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
+ *
+ * https://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 dev.chrisbanes.snapper
+
+import platform.Foundation.NSLog
+import platform.Foundation.NSString
+import platform.Foundation.stringWithFormat
+
+internal actual object SnapperLog {
+ actual inline fun d(tag: String, message: () -> String) {
+ if (DebugLog) {
+ NSLog("$tag: ${message()}")
+ }
+ }
+}
+
+internal actual fun Double.formatToString(): String = NSString.stringWithFormat("%.3d", this)
diff --git a/kmp/lib/src/jvmCommonTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyColumnTest.kt b/kmp/lib/src/jvmCommonTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyColumnTest.kt
new file mode 100644
index 0000000..c85b9e9
--- /dev/null
+++ b/kmp/lib/src/jvmCommonTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyColumnTest.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2021 Chris Banes
+ *
+ * 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
+ *
+ * https://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 dev.chrisbanes.snapper
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import dev.chrisbanes.internal.randomColor
+import dev.chrisbanes.internal.swipeAcrossCenterWithVelocity
+
+/**
+ * Contains [SnapperFlingBehavior] tests using [LazyColumn]. This class is extended
+ * in both the `androidTest` and `test` source sets for setup of the relevant
+ * test runner.
+ */
+@OptIn(ExperimentalSnapperApi::class) // SnapFlingBehavior is currently experimental
+abstract class BaseSnapperFlingLazyColumnTest(
+ 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(snapIndexDelta) {
+
+ override fun SemanticsNodeInteraction.swipeAcrossCenter(
+ distancePercentage: Float,
+ velocityPerSec: Dp,
+ ): SemanticsNodeInteraction = swipeAcrossCenterWithVelocity(
+ distancePercentageY = if (reverseLayout) -distancePercentage else distancePercentage,
+ velocityPerSec = velocityPerSec,
+ )
+
+ override fun setTestContent(
+ flingBehavior: SnapperFlingBehavior,
+ count: () -> Int,
+ lazyListState: LazyListState,
+ ) {
+ rule.setContent {
+ applierScope = rememberCoroutineScope()
+ val itemCount = count()
+
+ Box {
+ LazyColumn(
+ state = lazyListState,
+ flingBehavior = flingBehavior,
+ verticalArrangement = Arrangement.spacedBy(itemSpacingDp.dp),
+ reverseLayout = reverseLayout,
+ contentPadding = contentPadding,
+ modifier = Modifier
+ .fillMaxSize()
+ .testTag("layout"),
+ ) {
+ items(itemCount) { index ->
+ Box(
+ modifier = Modifier
+ .size(ItemSize)
+ .background(randomColor())
+ .testTag(index.toString())
+ ) {
+ BasicText(
+ text = index.toString(),
+ modifier = Modifier.align(Alignment.Center)
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/kmp/lib/src/jvmCommonTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyRowTest.kt b/kmp/lib/src/jvmCommonTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyRowTest.kt
new file mode 100644
index 0000000..897f68d
--- /dev/null
+++ b/kmp/lib/src/jvmCommonTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyRowTest.kt
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2021 Chris Banes
+ *
+ * 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
+ *
+ * https://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 dev.chrisbanes.snapper
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalLayoutDirection
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import dev.chrisbanes.internal.randomColor
+import dev.chrisbanes.internal.swipeAcrossCenterWithVelocity
+
+/**
+ * Contains [SnapperFlingBehavior] tests using [LazyRow]. This class is extended
+ * in both the `androidTest` and `test` source sets for setup of the relevant
+ * test runner.
+ */
+@OptIn(ExperimentalSnapperApi::class) // SnapFlingBehavior is currently experimental
+abstract class BaseSnapperFlingLazyRowTest(
+ 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(snapIndexDelta) {
+
+ /**
+ * Returns the expected resolved layout direction for pages
+ */
+ private val laidOutRtl: Boolean
+ get() = if (layoutDirection == LayoutDirection.Rtl) !reverseLayout else reverseLayout
+
+ override fun SemanticsNodeInteraction.swipeAcrossCenter(
+ distancePercentage: Float,
+ velocityPerSec: Dp,
+ ): SemanticsNodeInteraction = swipeAcrossCenterWithVelocity(
+ distancePercentageX = if (laidOutRtl) -distancePercentage else distancePercentage,
+ velocityPerSec = velocityPerSec,
+ )
+
+ override fun setTestContent(
+ flingBehavior: SnapperFlingBehavior,
+ count: () -> Int,
+ lazyListState: LazyListState,
+ ) {
+ rule.setContent {
+ CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) {
+ applierScope = rememberCoroutineScope()
+ val itemCount = count()
+
+ Box {
+ LazyRow(
+ state = lazyListState,
+ flingBehavior = flingBehavior,
+ horizontalArrangement = Arrangement.spacedBy(itemSpacingDp.dp),
+ reverseLayout = reverseLayout,
+ contentPadding = contentPadding,
+ modifier = Modifier
+ .fillMaxSize()
+ .testTag("layout"),
+ ) {
+ items(itemCount) { index ->
+ Box(
+ modifier = Modifier
+ .size(ItemSize)
+ .background(randomColor())
+ .testTag(index.toString())
+ ) {
+ BasicText(
+ text = index.toString(),
+ modifier = Modifier.align(Alignment.Center)
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/kmp/lib/src/jvmCommonTest/kotlin/dev/chrisbanes/snapper/SnapperFlingBehaviorTest.kt b/kmp/lib/src/jvmCommonTest/kotlin/dev/chrisbanes/snapper/SnapperFlingBehaviorTest.kt
new file mode 100644
index 0000000..88aa8d7
--- /dev/null
+++ b/kmp/lib/src/jvmCommonTest/kotlin/dev/chrisbanes/snapper/SnapperFlingBehaviorTest.kt
@@ -0,0 +1,417 @@
+/*
+ * Copyright 2021 Chris Banes
+ *
+ * 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
+ *
+ * https://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 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
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.CoroutineScope
+import org.junit.Rule
+import org.junit.Test
+
+private const val MediumSwipeDistance = 0.75f
+private const val ShortSwipeDistance = 0.4f
+
+private val FastVelocity = 2000.dp
+private val MediumVelocity = 700.dp
+private val SlowVelocity = 100.dp
+
+internal val ItemSize = 200.dp
+
+@OptIn(ExperimentalSnapperApi::class) // Pager is currently experimental
+abstract class SnapperFlingBehaviorTest(
+ private val snapIndexDelta: Int,
+) {
+ @get:Rule
+ val rule = createComposeRule()
+
+ /**
+ * This is a workaround for https://issuetracker.google.com/issues/179492185.
+ * Ideally we would have a way to get the applier scope from the rule
+ */
+ protected lateinit var applierScope: CoroutineScope
+
+ @Test
+ fun swipe() {
+ val lazyListState = LazyListState()
+ val snappingFlingBehavior = createSnapFlingBehavior(lazyListState)
+ setTestContent(
+ flingBehavior = snappingFlingBehavior,
+ lazyListState = lazyListState,
+ count = 10,
+ )
+
+ // First test swiping towards end, from 0 to -1, which should no-op
+ rule.onNodeWithTag("0").swipeAcrossCenter(MediumSwipeDistance)
+ rule.waitForIdle()
+ // ...and assert that nothing happened
+ lazyListState.assertCurrentItem(index = 0, offset = 0)
+
+ // Now swipe towards start, from page 0
+ rule.onNodeWithTag("0").swipeAcrossCenter(-MediumSwipeDistance)
+ rule.waitForIdle()
+
+ // ...and assert that we now laid out from page 1
+ lazyListState.assertCurrentItem(minIndex = 1, offset = 0)
+ }
+
+ @Test
+ fun swipeForwardAndBackFromZero() = swipeToEndAndBack(
+ initialIndex = 0,
+ count = 4
+ )
+
+ @Test
+ fun swipeForwardAndBackFromLargeIndex() = swipeToEndAndBack(
+ initialIndex = Int.MAX_VALUE / 2,
+ count = Int.MAX_VALUE
+ )
+
+ private fun swipeToEndAndBack(initialIndex: Int, count: Int) {
+ val lazyListState = LazyListState(firstVisibleItemIndex = initialIndex)
+ val snappingFlingBehavior = createSnapFlingBehavior(lazyListState)
+ setTestContent(
+ flingBehavior = snappingFlingBehavior,
+ lazyListState = lazyListState,
+ count = count,
+ )
+
+ var lastItemIndex = lazyListState.currentItem.index
+
+ // Now swipe towards start, from page 0 to page 1 and assert the layout
+ rule.onNodeWithTag("layout").swipeAcrossCenter(-MediumSwipeDistance)
+ rule.waitForIdle()
+ lazyListState.assertCurrentItem(minIndex = lastItemIndex + 1)
+ lastItemIndex = lazyListState.currentItem.index
+
+ // Repeat for 1 -> 2
+ rule.onNodeWithTag("layout").swipeAcrossCenter(-MediumSwipeDistance)
+ rule.waitForIdle()
+ lazyListState.assertCurrentItem(minIndex = lastItemIndex + 1)
+ lastItemIndex = lazyListState.currentItem.index
+
+ // Repeat for 2 -> 3
+ rule.onNodeWithTag("layout").swipeAcrossCenter(-MediumSwipeDistance)
+ rule.waitForIdle()
+ lazyListState.assertCurrentItem(minIndex = lastItemIndex + 1)
+ lastItemIndex = lazyListState.currentItem.index
+
+ // Swipe past the last item (if it is the last item). We shouldn't move
+ if (count - initialIndex == 4) {
+ rule.onNodeWithTag("layout").swipeAcrossCenter(-MediumSwipeDistance)
+ rule.waitForIdle()
+ lazyListState.assertCurrentItem(index = lastItemIndex, offset = 0)
+ }
+
+ // Swipe back from 3 -> 2
+ rule.onNodeWithTag("layout").swipeAcrossCenter(MediumSwipeDistance)
+ rule.waitForIdle()
+ lazyListState.assertCurrentItem(maxIndex = (lastItemIndex - 1).coerceAtLeast(0))
+ lastItemIndex = lazyListState.currentItem.index
+
+ // Swipe back from 2 -> 1
+ rule.onNodeWithTag("layout").swipeAcrossCenter(MediumSwipeDistance)
+ rule.waitForIdle()
+ lazyListState.assertCurrentItem(maxIndex = (lastItemIndex - 1).coerceAtLeast(0))
+ lastItemIndex = lazyListState.currentItem.index
+
+ // Swipe back from 1 -> 0
+ rule.onNodeWithTag("layout").swipeAcrossCenter(MediumSwipeDistance)
+ rule.waitForIdle()
+ lazyListState.assertCurrentItem(maxIndex = (lastItemIndex - 1).coerceAtLeast(0))
+ }
+
+ @Test
+ fun mediumDistance_fastSwipe_toFling() {
+ rule.mainClock.autoAdvance = false
+
+ val lazyListState = LazyListState()
+ val snappingFlingBehavior = createSnapFlingBehavior(lazyListState)
+ setTestContent(
+ flingBehavior = snappingFlingBehavior,
+ lazyListState = lazyListState,
+ count = 10,
+ )
+
+ assertThat(lazyListState.isScrollInProgress).isFalse()
+ assertThat(snappingFlingBehavior.animationTarget).isNull()
+ lazyListState.assertCurrentItem(index = 0, offset = 0)
+
+ // Now swipe towards start, from page 0 to page 1, over a medium distance of the item width.
+ // This should trigger a fling
+ rule.onNodeWithTag("0").swipeAcrossCenter(
+ distancePercentage = -MediumSwipeDistance,
+ velocityPerSec = FastVelocity,
+ )
+
+ assertThat(lazyListState.isScrollInProgress).isTrue()
+ assertThat(snappingFlingBehavior.animationTarget).isAtLeast(1)
+
+ // Now re-enable the clock advancement and let the fling animation run
+ rule.mainClock.autoAdvance = true
+ rule.waitForIdle()
+
+ // ...and assert that we now laid out from page 1
+ lazyListState.assertCurrentItem(minIndex = 1, offset = 0)
+ }
+
+ @Test
+ fun mediumDistance_slowSwipe_toSnapForward() {
+ rule.mainClock.autoAdvance = false
+
+ val lazyListState = LazyListState()
+ val snappingFlingBehavior = createSnapFlingBehavior(lazyListState)
+ setTestContent(
+ flingBehavior = snappingFlingBehavior,
+ lazyListState = lazyListState,
+ count = 10,
+ )
+
+ assertThat(lazyListState.isScrollInProgress).isFalse()
+ assertThat(snappingFlingBehavior.animationTarget).isNull()
+ lazyListState.assertCurrentItem(index = 0, offset = 0)
+
+ // Now swipe towards start, from page 0 to page 1, over a medium distance of the item width.
+ // This should trigger a spring to position 1
+ rule.onNodeWithTag("0").swipeAcrossCenter(
+ distancePercentage = -MediumSwipeDistance,
+ velocityPerSec = SlowVelocity,
+ )
+
+ assertThat(lazyListState.isScrollInProgress).isTrue()
+ 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(minIndex = 1, offset = 0)
+ }
+
+ @Test
+ fun shortDistance_fastSwipe_toFling() {
+ rule.mainClock.autoAdvance = false
+
+ val lazyListState = LazyListState()
+ val snappingFlingBehavior = createSnapFlingBehavior(lazyListState)
+ setTestContent(
+ flingBehavior = snappingFlingBehavior,
+ lazyListState = lazyListState,
+ count = 10,
+ )
+
+ assertThat(lazyListState.isScrollInProgress).isFalse()
+ assertThat(snappingFlingBehavior.animationTarget).isNull()
+ lazyListState.assertCurrentItem(index = 0, offset = 0)
+
+ // Now swipe towards start, from page 0 to page 1, over a short distance of the item width.
+ // This should trigger a spring back to the original position
+ rule.onNodeWithTag("0").swipeAcrossCenter(
+ distancePercentage = -ShortSwipeDistance,
+ velocityPerSec = FastVelocity,
+ )
+
+ assertThat(lazyListState.isScrollInProgress).isTrue()
+ assertThat(snappingFlingBehavior.animationTarget).isAtLeast(1)
+
+ // Now re-enable the clock advancement and let the fling animation run
+ rule.mainClock.autoAdvance = true
+ rule.waitForIdle()
+
+ // ...and assert that we now laid out from page 1
+ lazyListState.assertCurrentItem(minIndex = 1, offset = 0)
+ }
+
+ @Test
+ fun shortDistance_slowSwipe_toSnapBack() {
+ rule.mainClock.autoAdvance = false
+
+ val lazyListState = LazyListState()
+ val snappingFlingBehavior = createSnapFlingBehavior(lazyListState)
+ setTestContent(
+ flingBehavior = snappingFlingBehavior,
+ lazyListState = lazyListState,
+ count = 10,
+ )
+
+ assertThat(lazyListState.isScrollInProgress).isFalse()
+ assertThat(snappingFlingBehavior.animationTarget).isNull()
+ lazyListState.assertCurrentItem(index = 0, offset = 0)
+
+ // Now swipe towards start, from page 0 to page 1, over a short distance of the item width.
+ // This should trigger a spring back to the original position
+ rule.onNodeWithTag("0").swipeAcrossCenter(
+ distancePercentage = -ShortSwipeDistance,
+ velocityPerSec = SlowVelocity,
+ )
+
+ assertThat(lazyListState.isScrollInProgress).isTrue()
+ assertThat(snappingFlingBehavior.animationTarget).isEqualTo(0)
+
+ // Now re-enable the clock advancement and let the snap animation run
+ rule.mainClock.autoAdvance = true
+ rule.waitForIdle()
+
+ // ...and assert that we 'sprang back' to page 0
+ 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.
+ *
+ * @param distancePercentage The swipe distance in percentage of the node's size.
+ * Negative numbers mean swipe towards the start, positive towards the end.
+ * @param velocityPerSec Target end velocity for the swipe in Dps per second
+ */
+ abstract fun SemanticsNodeInteraction.swipeAcrossCenter(
+ distancePercentage: Float,
+ velocityPerSec: Dp = MediumVelocity
+ ): SemanticsNodeInteraction
+
+ private fun setTestContent(
+ count: Int,
+ lazyListState: LazyListState = LazyListState(),
+ flingBehavior: SnapperFlingBehavior = createSnapFlingBehavior(lazyListState),
+ ) {
+ setTestContent(
+ flingBehavior = flingBehavior,
+ count = { count },
+ lazyListState = lazyListState,
+ )
+ }
+
+ protected abstract fun setTestContent(
+ flingBehavior: SnapperFlingBehavior,
+ count: () -> Int,
+ lazyListState: LazyListState = LazyListState(),
+ )
+
+ private fun createSnapFlingBehavior(
+ lazyListState: LazyListState,
+ snapIndex: ((SnapperLayoutInfo, currentIndex: Int, targetIndex: Int) -> Int)? = null,
+ ): SnapperFlingBehavior = SnapperFlingBehavior(
+ layoutInfo = LazyListSnapperLayoutInfo(
+ lazyListState = lazyListState,
+ snapOffsetForItem = SnapOffsets.Start,
+ ),
+ decayAnimationSpec = exponentialDecay(),
+ snapIndex = snapIndex ?: { layout, currentIndex, targetIndex ->
+ targetIndex
+ .coerceIn(currentIndex - snapIndexDelta, currentIndex + snapIndexDelta)
+ .coerceIn(0, layout.totalItemsCount - 1)
+ },
+ )
+}
+
+/**
+ * This doesn't handle the scroll range < lazy size, but that won't happen in these tests
+ */
+private fun LazyListState.isScrolledToEnd(): Boolean {
+ val lastVisibleItem = layoutInfo.visibleItemsInfo.last()
+ if (lastVisibleItem.index == layoutInfo.totalItemsCount - 1) {
+ // This isn't perfect as it doesn't properly handle content padding, but good enough
+ return (lastVisibleItem.offset + lastVisibleItem.size) <= layoutInfo.viewportEndOffset
+ }
+ return false
+}
+
+private fun LazyListState.assertCurrentItem(
+ index: Int,
+ offset: Int = 0,
+) = assertCurrentItem(minIndex = index, maxIndex = index, offset = offset)
+
+private fun LazyListState.assertCurrentItem(
+ minIndex: Int = 0,
+ maxIndex: Int = Int.MAX_VALUE,
+ offset: Int = 0,
+) {
+ if (isScrolledToEnd()) return
+
+ currentItem.let {
+ assertThat(it.index).isAtLeast(minIndex)
+ assertThat(it.index).isAtMost(maxIndex)
+ assertThat(it.offset).isEqualTo(offset)
+ }
+}
+
+private val LazyListState.currentItem: LazyListItemInfo
+ get() = layoutInfo.visibleItemsInfo.asSequence()
+ .filter { it.offset <= 0 }
+ .last()
diff --git a/lib/build.gradle b/lib/build.gradle
index 25561a6..b16ef74 100644
--- a/lib/build.gradle
+++ b/lib/build.gradle
@@ -33,11 +33,10 @@ metalava {
android {
compileSdkVersion 33
+ namespace "dev.chrisbanes.snapper"
defaultConfig {
minSdkVersion 21
- // targetSdkVersion has no effect for libraries. This is only used for the test APK
- targetSdkVersion 33
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/lib/src/main/AndroidManifest.xml b/lib/src/main/AndroidManifest.xml
deleted file mode 100644
index 878786d..0000000
--- a/lib/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
diff --git a/sample/build.gradle b/sample/build.gradle
index 51028c8..d2eb907 100644
--- a/sample/build.gradle
+++ b/sample/build.gradle
@@ -21,6 +21,7 @@ plugins {
android {
compileSdk 33
+ namespace "dev.chrisbanes.snapper.sample"
defaultConfig {
applicationId "dev.chrisbanes.snapper.sample"
@@ -71,4 +72,4 @@ dependencies {
implementation libs.androidx.core
implementation libs.androidx.activity.compose
implementation libs.coil
-}
\ No newline at end of file
+}
diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml
index 759d7bf..5614bb9 100644
--- a/sample/src/main/AndroidManifest.xml
+++ b/sample/src/main/AndroidManifest.xml
@@ -1,6 +1,5 @@
-
+
@@ -24,4 +23,4 @@
-
\ No newline at end of file
+
diff --git a/settings.gradle b/settings.gradle
index 65b9c17..8d66358 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -29,3 +29,6 @@ gradleEnterprise {
include ':lib'
include ':internal-testutils'
include ':sample'
+
+
+include ":kmp:lib"