From 0618f954d49207e5113ad0a66656f047a3292834 Mon Sep 17 00:00:00 2001 From: Blaz Solar Date: Fri, 21 Oct 2022 13:56:16 +0200 Subject: [PATCH 1/3] Add iOS support --- .idea/artifacts/lib_desktop.xml | 8 + .idea/artifacts/lib_desktop_1_0.xml | 8 + .idea/kotlinc.xml | 6 + build.gradle | 5 +- gradle.properties | 3 + gradle/libs.versions.toml | 11 +- gradle/wrapper/gradle-wrapper.jar | Bin 59821 -> 60756 bytes gradlew | 6 + gradlew.bat | 14 +- internal-testutils/build.gradle | 1 + .../src/main/AndroidManifest.xml | 3 +- kmp/lib/README.md | 1 + kmp/lib/api/0.1.0.api | 84 +++ kmp/lib/api/current.api | 100 +++ kmp/lib/build.gradle.kts | 117 +++ kmp/lib/gradle.properties | 3 + .../snapper/BaseSnapperFlingLazyColumnTest.kt | 97 +++ .../snapper/BaseSnapperFlingLazyRowTest.kt | 109 +++ .../InstrumentedSnapperFlingLazyColumnTest.kt | 66 ++ .../InstrumentedSnapperFlingLazyRowTest.kt | 69 ++ .../snapper/SnapperFlingBehaviorTest.kt | 417 +++++++++++ .../chrisbanes/snapper/SnapperLog.android.kt | 29 + .../RobolectricSnapperFlingLazyColumnTest.kt | 65 ++ .../RobolectricSnapperFlingLazyRowTest.kt | 71 ++ .../resources/robolectric.properties | 3 + .../kotlin/dev/chrisbanes/snapper/LazyList.kt | 337 +++++++++ .../snapper/SnapperFlingBehavior.kt | 675 ++++++++++++++++++ .../dev/chrisbanes/snapper/SnapperLog.kt | 26 + .../dev/chrisbanes/snapper/SnapperLog.ios.kt | 31 + .../snapper/BaseSnapperFlingLazyColumnTest.kt | 97 +++ .../snapper/BaseSnapperFlingLazyRowTest.kt | 109 +++ .../snapper/SnapperFlingBehaviorTest.kt | 417 +++++++++++ lib/build.gradle | 3 +- lib/src/main/AndroidManifest.xml | 17 - sample/build.gradle | 3 +- sample/src/main/AndroidManifest.xml | 5 +- settings.gradle | 4 + 37 files changed, 2984 insertions(+), 36 deletions(-) create mode 100644 .idea/artifacts/lib_desktop.xml create mode 100644 .idea/artifacts/lib_desktop_1_0.xml create mode 100644 .idea/kotlinc.xml create mode 100644 kmp/lib/README.md create mode 100644 kmp/lib/api/0.1.0.api create mode 100644 kmp/lib/api/current.api create mode 100644 kmp/lib/build.gradle.kts create mode 100644 kmp/lib/gradle.properties create mode 100644 kmp/lib/src/androidAndroidTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyColumnTest.kt create mode 100644 kmp/lib/src/androidAndroidTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyRowTest.kt create mode 100644 kmp/lib/src/androidAndroidTest/kotlin/dev/chrisbanes/snapper/InstrumentedSnapperFlingLazyColumnTest.kt create mode 100644 kmp/lib/src/androidAndroidTest/kotlin/dev/chrisbanes/snapper/InstrumentedSnapperFlingLazyRowTest.kt create mode 100644 kmp/lib/src/androidAndroidTest/kotlin/dev/chrisbanes/snapper/SnapperFlingBehaviorTest.kt create mode 100644 kmp/lib/src/androidMain/kotlin/dev/chrisbanes/snapper/SnapperLog.android.kt create mode 100644 kmp/lib/src/androidTest/kotlin/dev/chrisbanes/snapper/RobolectricSnapperFlingLazyColumnTest.kt create mode 100644 kmp/lib/src/androidTest/kotlin/dev/chrisbanes/snapper/RobolectricSnapperFlingLazyRowTest.kt create mode 100644 kmp/lib/src/androidTest/resources/robolectric.properties create mode 100644 kmp/lib/src/commonMain/kotlin/dev/chrisbanes/snapper/LazyList.kt create mode 100644 kmp/lib/src/commonMain/kotlin/dev/chrisbanes/snapper/SnapperFlingBehavior.kt create mode 100644 kmp/lib/src/commonMain/kotlin/dev/chrisbanes/snapper/SnapperLog.kt create mode 100644 kmp/lib/src/iosMain/kotlin/dev/chrisbanes/snapper/SnapperLog.ios.kt create mode 100644 kmp/lib/src/jvmCommonTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyColumnTest.kt create mode 100644 kmp/lib/src/jvmCommonTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyRowTest.kt create mode 100644 kmp/lib/src/jvmCommonTest/kotlin/dev/chrisbanes/snapper/SnapperFlingBehaviorTest.kt delete mode 100644 lib/src/main/AndroidManifest.xml 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..7daccd1 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-alpha05" 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 41d9927a4d4fb3f96a785543079b8df6723c946b..249e5832f090a2944b7473328c07c9755baa3196 100644 GIT binary patch delta 10197 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 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 snapOffsetForItem, optional float endContentPadding); + method @androidx.compose.runtime.Composable @dev.chrisbanes.snapper.ExperimentalSnapperApi public static dev.chrisbanes.snapper.SnapperFlingBehavior rememberSnapperFlingBehavior(androidx.compose.foundation.lazy.LazyListState lazyListState, optional kotlin.jvm.functions.Function2 snapOffsetForItem, optional float endContentPadding, optional androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec, optional kotlin.jvm.functions.Function1 maximumFlingDistance); + } + + @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 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 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 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 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 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 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 snapOffsetForItem, optional androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec, optional kotlin.jvm.functions.Function3 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 snapOffsetForItem, optional float endContentPadding, optional androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec, optional kotlin.jvm.functions.Function3 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 snapOffsetForItem, optional float endContentPadding, optional androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec, kotlin.jvm.functions.Function1 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 snapOffsetForItem); + ctor @Deprecated public LazyListSnapperLayoutInfo(androidx.compose.foundation.lazy.LazyListState lazyListState, kotlin.jvm.functions.Function2 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 snapIndex); + ctor @Deprecated public SnapperFlingBehavior(dev.chrisbanes.snapper.SnapperLayoutInfo layoutInfo, androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec, optional kotlin.jvm.functions.Function1 maximumFlingDistance); + method public Integer? getAnimationTarget(); + method public suspend Object? performFling(androidx.compose.foundation.gestures.ScrollScope, float initialVelocity, kotlin.coroutines.Continuation p); + property public final Integer? animationTarget; + } + + @dev.chrisbanes.snapper.ExperimentalSnapperApi public final class SnapperFlingBehaviorDefaults { + method @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 snapIndex); + method @androidx.compose.runtime.Composable @dev.chrisbanes.snapper.ExperimentalSnapperApi public static inline dev.chrisbanes.snapper.SnapperFlingBehavior rememberSnapperFlingBehavior(dev.chrisbanes.snapper.SnapperLayoutInfo layoutInfo, optional androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec); + method @Deprecated @androidx.compose.runtime.Composable @dev.chrisbanes.snapper.ExperimentalSnapperApi public static dev.chrisbanes.snapper.SnapperFlingBehavior rememberSnapperFlingBehavior(dev.chrisbanes.snapper.SnapperLayoutInfo layoutInfo, optional androidx.compose.animation.core.DecayAnimationSpec decayAnimationSpec, optional androidx.compose.animation.core.AnimationSpec springAnimationSpec, optional kotlin.jvm.functions.Function1 maximumFlingDistance); + } + + @dev.chrisbanes.snapper.ExperimentalSnapperApi public abstract class SnapperLayoutInfo { + 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..51645ac 100644 --- a/settings.gradle +++ b/settings.gradle @@ -29,3 +29,7 @@ gradleEnterprise { include ':lib' include ':internal-testutils' include ':sample' + + +include ":kmp:lib" +include ":kmp:android" From eb68829699563b573a2c73bd535fbf8f0bd995e1 Mon Sep 17 00:00:00 2001 From: Blaz Solar Date: Tue, 8 Nov 2022 14:52:41 +0100 Subject: [PATCH 2/3] Cleanup --- settings.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/settings.gradle b/settings.gradle index 51645ac..8d66358 100644 --- a/settings.gradle +++ b/settings.gradle @@ -32,4 +32,3 @@ include ':sample' include ":kmp:lib" -include ":kmp:android" From fb706efb7b858caad42ec0b96253481b27daf085 Mon Sep 17 00:00:00 2001 From: Blaz Solar Date: Tue, 8 Nov 2022 14:56:11 +0100 Subject: [PATCH 3/3] AGP update --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7daccd1..2c2c063 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,7 +5,7 @@ compose = "1.2.1" composecompiler = "1.3.1" composesnapshot = "-" # a single character = no snapshot -agp = "8.0.0-alpha05" +agp = "8.0.0-alpha06" ktlint = "0.45.2" kotlin = "1.7.10"