diff --git a/.gitignore b/.gitignore
index 489a6df..44eb7fd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@ captures
# IntelliJ .idea folder
.idea/workspace.xml
+.idea/artifacts
.idea/libraries
.idea/caches
.idea/navEditor.xml
diff --git a/build.gradle b/build.gradle
index 918ad53..03cf15b 100644
--- a/build.gradle
+++ b/build.gradle
@@ -51,9 +51,11 @@ allprojects {
google()
mavenCentral()
+ maven { url 'https://maven.pkg.jetbrains.space/public/p/compose/dev' }
+
def composeSnapshot = libs.versions.composesnapshot.get()
if (composeSnapshot.length() > 1) {
- maven { url "https://androidx.dev/snapshots/builds/$composeSnapshot/artifacts/repository/" }
+ maven { url 'https://androidx.dev/snapshots/builds/$composeSnapshot/artifacts/repository/' }
}
}
}
@@ -78,9 +80,9 @@ subprojects {
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach { compile ->
kotlinOptions {
// Treat all Kotlin warnings as errors
- allWarningsAsErrors = true
- // Set JVM target to 1.8
- jvmTarget = "1.8"
+ // allWarningsAsErrors = true
+ // Set JVM target to 11
+ jvmTarget = "11"
// Allow use of @OptIn
freeCompilerArgs += "-Xopt-in=kotlin.RequiresOptIn"
// Enable default methods in interfaces
diff --git a/gradle.properties b/gradle.properties
index 129c837..6cff85a 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -45,3 +45,7 @@ POM_LICENCE_DIST=repo
POM_DEVELOPER_ID=chrisbanes
POM_DEVELOPER_NAME=Chris Banes
+
+# From IntelliJ Project wizard!
+kotlin.mpp.enableGranularSourceSetsMetadata=true
+kotlin.native.enableDependencyPropagation=false
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b2db3cb..b4d593a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,11 +1,11 @@
[versions]
-compose = "1.0.3"
+compose = "1.0.5"
composesnapshot = "-" # a single character = no snapshot
gradlePlugin = "7.0.2"
ktlint = "0.42.1"
-kotlin = "1.5.30"
+kotlin = "1.5.31"
coroutines = "1.5.2"
androidxtest = "1.4.0"
diff --git a/internal-testutils/build.gradle b/internal-testutils/build.gradle
index 49c39af..27f3850 100644
--- a/internal-testutils/build.gradle
+++ b/internal-testutils/build.gradle
@@ -16,7 +16,7 @@
plugins {
id 'com.android.library'
- id 'kotlin-android'
+ id 'org.jetbrains.kotlin.android'
}
android {
@@ -28,8 +28,8 @@ android {
}
compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
+ sourceCompatibility JavaVersion.VERSION_11
+ targetCompatibility JavaVersion.VERSION_11
}
buildFeatures {
diff --git a/kmp/android/build.gradle.kts b/kmp/android/build.gradle.kts
new file mode 100644
index 0000000..d264c2f
--- /dev/null
+++ b/kmp/android/build.gradle.kts
@@ -0,0 +1,33 @@
+plugins {
+ id("org.jetbrains.compose") version "1.0.0"
+ id("com.android.application")
+ kotlin("android")
+}
+
+group = "me.chris"
+version = "1.0"
+
+dependencies {
+ implementation(project(":kmp:lib"))
+ implementation("androidx.activity:activity-compose:1.3.0")
+}
+
+android {
+ compileSdk = 31
+ defaultConfig {
+ applicationId = "me.chris.android"
+ minSdk = 24
+ targetSdk = 31
+ versionCode = 1
+ versionName = "1.0"
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+ buildTypes {
+ getByName("release") {
+ isMinifyEnabled = false
+ }
+ }
+}
\ No newline at end of file
diff --git a/kmp/android/src/main/AndroidManifest.xml b/kmp/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..9887077
--- /dev/null
+++ b/kmp/android/src/main/AndroidManifest.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/kmp/android/src/main/java/me/chris/android/MainActivity.kt b/kmp/android/src/main/java/me/chris/android/MainActivity.kt
new file mode 100644
index 0000000..90afb4d
--- /dev/null
+++ b/kmp/android/src/main/java/me/chris/android/MainActivity.kt
@@ -0,0 +1,17 @@
+package me.chris.android
+
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.material.MaterialTheme
+
+class MainActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ MaterialTheme {
+ //
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/kmp/desktop/build.gradle.kts b/kmp/desktop/build.gradle.kts
new file mode 100644
index 0000000..4381420
--- /dev/null
+++ b/kmp/desktop/build.gradle.kts
@@ -0,0 +1,39 @@
+import org.jetbrains.compose.compose
+import org.jetbrains.compose.desktop.application.dsl.TargetFormat
+
+plugins {
+ kotlin("multiplatform")
+ id("org.jetbrains.compose") version "1.0.0"
+}
+
+group = "me.chris"
+version = "1.0"
+
+kotlin {
+ jvm {
+ compilations.all {
+ kotlinOptions.jvmTarget = "11"
+ }
+ withJava()
+ }
+ sourceSets {
+ val jvmMain by getting {
+ dependencies {
+ implementation(project(":kmp:lib"))
+ implementation(compose.desktop.currentOs)
+ }
+ }
+ val jvmTest by getting
+ }
+}
+
+compose.desktop {
+ application {
+ mainClass = "MainKt"
+ nativeDistributions {
+ targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
+ packageName = "jvm"
+ packageVersion = "1.0.0"
+ }
+ }
+}
\ No newline at end of file
diff --git a/kmp/desktop/src/jvmMain/kotlin/Main.kt b/kmp/desktop/src/jvmMain/kotlin/Main.kt
new file mode 100644
index 0000000..6b6dddd
--- /dev/null
+++ b/kmp/desktop/src/jvmMain/kotlin/Main.kt
@@ -0,0 +1,13 @@
+package me.chris.desktop
+
+import androidx.compose.material.MaterialTheme
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.application
+
+fun main() = application {
+ Window(onCloseRequest = ::exitApplication) {
+ MaterialTheme {
+ //
+ }
+ }
+}
\ No newline at end of file
diff --git a/kmp/lib/build.gradle.kts b/kmp/lib/build.gradle.kts
new file mode 100644
index 0000000..5039661
--- /dev/null
+++ b/kmp/lib/build.gradle.kts
@@ -0,0 +1,65 @@
+import org.jetbrains.compose.compose
+
+plugins {
+ kotlin("multiplatform")
+ id("org.jetbrains.compose") version "1.0.0"
+ id("com.android.library")
+}
+
+kotlin {
+ android()
+
+ jvm("desktop") {
+ compilations.all {
+ kotlinOptions.jvmTarget = "11"
+ }
+ }
+
+ sourceSets {
+ val commonMain by getting {
+ dependencies {
+ api(compose.foundation)
+ implementation(libs.napier)
+ }
+ }
+
+ val commonTest by getting {
+ dependencies {
+ api(libs.junit)
+ api(libs.truth)
+ api(compose("org.jetbrains.compose.ui:ui-test-junit4"))
+ }
+ }
+
+ val androidMain by getting
+ val androidTest by getting
+
+ val desktopMain by getting {
+ dependencies {
+ implementation(compose.desktop.currentOs)
+ }
+ }
+
+ val desktopTest by getting
+ }
+}
+
+android {
+ compileSdk = 31
+ sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
+
+ defaultConfig {
+ minSdk = 24
+ targetSdk = 31
+ }
+
+ packagingOptions {
+ resources.pickFirsts += "/META-INF/AL2.0"
+ resources.pickFirsts += "/META-INF/LGPL2.1"
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+}
\ No newline at end of file
diff --git a/kmp/lib/src/androidMain/AndroidManifest.xml b/kmp/lib/src/androidMain/AndroidManifest.xml
new file mode 100644
index 0000000..878786d
--- /dev/null
+++ b/kmp/lib/src/androidMain/AndroidManifest.xml
@@ -0,0 +1,17 @@
+
+
+
diff --git a/kmp/lib/src/androidTest/kotlin/dev/chrisbanes/snapper/InstrumentedSnapperFlingLazyColumnTest.kt b/kmp/lib/src/androidTest/kotlin/dev/chrisbanes/snapper/InstrumentedSnapperFlingLazyColumnTest.kt
new file mode 100644
index 0000000..517c2a6
--- /dev/null
+++ b/kmp/lib/src/androidTest/kotlin/dev/chrisbanes/snapper/InstrumentedSnapperFlingLazyColumnTest.kt
@@ -0,0 +1,68 @@
+/*
+ * 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 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(
+ maxScrollDistanceDp: Float,
+ contentPadding: PaddingValues,
+ itemSpacingDp: Int,
+ reverseLayout: Boolean,
+) : BaseSnapperFlingLazyColumnTest(
+ maxScrollDistanceDp,
+ contentPadding,
+ itemSpacingDp,
+ reverseLayout,
+) {
+ companion object {
+ /**
+ * On device we only test a subset of the combined parameters.
+ */
+ @JvmStatic
+ @Parameterized.Parameters(
+ name = "maxScrollDistanceDp={0}," +
+ "contentPadding={1}," +
+ "itemSpacing={2}," +
+ "reverseLayout={3}"
+ )
+ fun data() = parameterizedParams()
+ // maxScrollDistanceDp
+ .combineWithParameters(
+ // We add 4dp on to cater for itemSpacing
+ 1 * (ItemSize.value + 4),
+ 4 * (ItemSize.value + 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/androidTest/kotlin/dev/chrisbanes/snapper/InstrumentedSnapperFlingLazyRowTest.kt b/kmp/lib/src/androidTest/kotlin/dev/chrisbanes/snapper/InstrumentedSnapperFlingLazyRowTest.kt
new file mode 100644
index 0000000..1e542fa
--- /dev/null
+++ b/kmp/lib/src/androidTest/kotlin/dev/chrisbanes/snapper/InstrumentedSnapperFlingLazyRowTest.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 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(
+ maxScrollDistanceDp: Float,
+ contentPadding: PaddingValues,
+ itemSpacingDp: Int,
+ layoutDirection: LayoutDirection,
+ reverseLayout: Boolean,
+) : BaseSnapperFlingLazyRowTest(
+ maxScrollDistanceDp,
+ contentPadding,
+ itemSpacingDp,
+ layoutDirection,
+ reverseLayout,
+) {
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(
+ name = "maxScrollDistanceDp={0}," +
+ "contentPadding={1}," +
+ "itemSpacing={2}," +
+ "layoutDirection={3}," +
+ "reverseLayout={4}"
+ )
+ fun data() = parameterizedParams()
+ // maxScrollDistanceDp
+ .combineWithParameters(
+ // We add 4dp on to cater for itemSpacing
+ 1 * (ItemSize.value + 4),
+ 4 * (ItemSize.value + 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/commonMain/kotlin/dev/chrisbanes/snapper/LazyList.kt b/kmp/lib/src/commonMain/kotlin/dev/chrisbanes/snapper/LazyList.kt
new file mode 100644
index 0000000..8283ade
--- /dev/null
+++ b/kmp/lib/src/commonMain/kotlin/dev/chrisbanes/snapper/LazyList.kt
@@ -0,0 +1,276 @@
+/*
+ * 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.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import io.github.aakira.napier.Napier
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.roundToInt
+import kotlin.math.truncate
+
+/**
+ * Create and remember a snapping [FlingBehavior] to be used with [LazyListState].
+ *
+ * This is a convenience function for using [rememberLazyListSnapperLayoutInfo] and
+ * [rememberSnapperFlingBehavior]. If you require access to the layout info, you can safely use
+ * those APIs directly.
+ *
+ * @param lazyListState The [LazyListState] to update.
+ * @param snapOffsetForItem Block which returns which offset the given item should 'snap' to.
+ * See [SnapOffsets] for provided values.
+ * @param endContentPadding The amount of content padding on the end edge of the lazy list
+ * in dps (end/bottom depending on the scrolling direction).
+ * @param decayAnimationSpec The decay animation spec to use for decayed flings.
+ * @param springAnimationSpec The animation spec to use when snapping.
+ * @param maximumFlingDistance Block which returns the maximum fling distance in pixels.
+ * The returned value should be > 0.
+ */
+@ExperimentalSnapperApi
+@Composable
+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 = SnapperFlingBehaviorDefaults.MaximumFlingDistance,
+): 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.
+ * @param endContentPadding The amount of content padding on the end edge of the lazy list
+ * in dps (end/bottom depending on the scrolling direction).
+ */
+@ExperimentalSnapperApi
+@Composable
+fun rememberLazyListSnapperLayoutInfo(
+ lazyListState: LazyListState,
+ snapOffsetForItem: (layoutInfo: SnapperLayoutInfo, item: SnapperLayoutItemInfo) -> Int = SnapOffsets.Center,
+ endContentPadding: Dp = 0.dp,
+): LazyListSnapperLayoutInfo = remember(lazyListState, snapOffsetForItem) {
+ LazyListSnapperLayoutInfo(
+ lazyListState = lazyListState,
+ snapOffsetForItem = snapOffsetForItem,
+ )
+}.apply {
+ this.endContentPadding = with(LocalDensity.current) { endContentPadding.roundToPx() }
+}
+
+/**
+ * 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.
+ * @param endContentPadding The amount of content padding on the end edge of the lazy list
+ * in pixels (end/bottom depending on the scrolling direction).
+*/
+@ExperimentalSnapperApi
+class LazyListSnapperLayoutInfo(
+ private val lazyListState: LazyListState,
+ private val snapOffsetForItem: (layoutInfo: SnapperLayoutInfo, item: SnapperLayoutItemInfo) -> Int,
+ endContentPadding: Int = 0,
+) : SnapperLayoutInfo() {
+ override val startScrollOffset: Int = 0
+
+ internal var endContentPadding: Int by mutableStateOf(endContentPadding)
+
+ override val endScrollOffset: Int
+ get() = lazyListState.layoutInfo.viewportEndOffset - endContentPadding
+
+ private val itemCount: Int get() = lazyListState.layoutInfo.totalItemsCount
+
+ override val currentItem: SnapperLayoutItemInfo?
+ get() = 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 flingDistance = decayAnimationSpec.calculateTargetValue(0f, velocity)
+ .coerceIn(-maximumFlingDistance, maximumFlingDistance)
+
+ val distanceNext = distanceToIndexSnap(curr.index + 1)
+ val distanceCurrent = distanceToIndexSnap(curr.index)
+
+ // If the fling doesn't reach the next snap point (in the fling direction), we try
+ // and snap depending on which snap point is closer to the current scroll position
+ if (
+ (flingDistance >= 0 && flingDistance < distanceNext) ||
+ (flingDistance < 0 && flingDistance > distanceCurrent)
+ ) {
+ return if (distanceNext < -distanceCurrent) {
+ (curr.index + 1).coerceIn(0, itemCount - 1)
+ } else {
+ curr.index
+ }
+ }
+
+ // forwards, toward index + 1, backwards towards index
+ val distanceToNextSnap = if (velocity > 0) distanceNext else distanceCurrent
+
+ /**
+ * We calculate the index delta by dividing the fling distance by the average
+ * scroll per child.
+ *
+ * We take the current item offset into account by subtracting `distanceToNextSnap`
+ * from the fling distance. This is then applied as an extra index delta below.
+ */
+ val indexDelta = truncate(
+ (flingDistance - distanceToNextSnap) / distancePerItem
+ ).let {
+ // As we removed the `distanceToNextSnap` from the fling distance, we need to calculate
+ // whether we need to take that into account...
+ if (velocity > 0) {
+ // If we're flinging forward, distanceToNextSnap represents the scroll distance
+ // to index + 1, so we need to add that (1) to the calculate delta
+ it.toInt() + 1
+ } else {
+ // If we're going backwards, distanceToNextSnap represents the scroll distance
+ // to the snap point of the current index, so there's nothing to do
+ it.toInt()
+ }
+ }
+
+ Napier.d(
+ message = {
+ "current item: $curr, " +
+ "distancePerChild: $distancePerItem, " +
+ "maximumFlingDistance: $maximumFlingDistance, " +
+ "flingDistance: $flingDistance, " +
+ "indexDelta: $indexDelta"
+ }
+ )
+
+ return (curr.index + indexDelta).coerceIn(0, itemCount - 1)
+ }
+
+ /**
+ * 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..49a5327
--- /dev/null
+++ b/kmp/lib/src/commonMain/kotlin/dev/chrisbanes/snapper/SnapperFlingBehavior.kt
@@ -0,0 +1,579 @@
+/*
+ * 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 io.github.aakira.napier.DebugAntilog
+import io.github.aakira.napier.Napier
+import kotlin.math.abs
+import kotlin.math.absoluteValue
+
+private const val DebugLog = false
+
+@RequiresOptIn(message = "Snapper is experimental. The API may be changed in the future.")
+@Retention(AnnotationRetention.BINARY)
+annotation class ExperimentalSnapperApi
+
+/**
+ * Default values used for [SnapperFlingBehavior] & [rememberSnapperFlingBehavior].
+ */
+@ExperimentalSnapperApi
+object SnapperFlingBehaviorDefaults {
+ /**
+ * [AnimationSpec] used as the default value for the `snapAnimationSpec` parameter on
+ * [rememberSnapperFlingBehavior] and [SnapperFlingBehavior].
+ */
+ val SpringAnimationSpec: AnimationSpec = spring(stiffness = 400f)
+
+ /**
+ * The default implementation for the `maximumFlingDistance` parameter of
+ * [rememberSnapperFlingBehavior] and [SnapperFlingBehavior], which does not limit
+ * the fling distance.
+ */
+ val MaximumFlingDistance: (SnapperLayoutInfo) -> Float = { Float.MAX_VALUE }
+}
+
+/**
+ * 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.
+ */
+@ExperimentalSnapperApi
+@Composable
+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
+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.
+ */
+ 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.
+ */
+ abstract val endScrollOffset: Int
+
+ /**
+ * A sequence containing the currently visible items in the layout.
+ */
+ 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.
+ */
+ abstract val currentItem: SnapperLayoutItemInfo?
+
+ /**
+ * 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.
+ */
+ 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.
+ */
+ abstract fun distanceToIndexSnap(index: Int): Int
+
+ /**
+ * Returns true if the layout has some scroll range remaining to scroll towards the start.
+ */
+ abstract fun canScrollTowardsStart(): Boolean
+
+ /**
+ * Returns true if the layout has some scroll range remaining to scroll towards the end.
+ */
+ abstract fun canScrollTowardsEnd(): Boolean
+}
+
+/**
+ * Contains information about a single item in a scrolling layout.
+ */
+abstract class SnapperLayoutItemInfo {
+ abstract val index: Int
+ abstract val offset: Int
+ 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
+object SnapOffsets {
+ /**
+ * Snap offset which results in the start edge of the item, snapping to the start scrolling
+ * edge of the lazy list.
+ */
+ 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.
+ */
+ 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.
+ */
+ 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.
+ *
+ * @param layoutInfo The [SnapperLayoutInfo] to use.
+ * @param decayAnimationSpec The decay animation spec to use for decayed flings.
+ * @param springAnimationSpec The animation spec to use when snapping.
+ * @param maximumFlingDistance Block which returns the maximum fling distance in pixels.
+ * The returned value should be > 0.
+ */
+@ExperimentalSnapperApi
+class SnapperFlingBehavior(
+ private val layoutInfo: SnapperLayoutInfo,
+ private val maximumFlingDistance: (SnapperLayoutInfo) -> Float = SnapperFlingBehaviorDefaults.MaximumFlingDistance,
+ private val decayAnimationSpec: DecayAnimationSpec,
+ private val springAnimationSpec: AnimationSpec = SnapperFlingBehaviorDefaults.SpringAnimationSpec,
+) : FlingBehavior {
+ /**
+ * The target item index for any on-going animations.
+ */
+ var animationTarget: Int? by mutableStateOf(null)
+ private set
+
+ override suspend fun ScrollScope.performFling(
+ initialVelocity: Float
+ ): Float {
+ // 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
+ }
+
+ Napier.d(message = { "initialVelocity: $initialVelocity" })
+
+ val maxFlingDistance = maximumFlingDistance(layoutInfo)
+ require(maxFlingDistance > 0) {
+ "Distance returned by maximumFlingDistance should be greater than 0"
+ }
+
+ return flingToIndex(
+ index = layoutInfo.determineTargetIndex(
+ velocity = initialVelocity,
+ decayAnimationSpec = decayAnimationSpec,
+ maximumFlingDistance = maxFlingDistance,
+ ),
+ 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) {
+ Napier.d(
+ message = {
+ "Skipping fling: already at target. " +
+ "vel:$initialVelocity, " +
+ "initial item: $initialItem, " +
+ "target: $index"
+ }
+ )
+ return consumeVelocityIfNotAtScrollEdge(initialVelocity)
+ }
+
+ return if (decayAnimationSpec.canDecayBeyondCurrentItem(initialVelocity, initialItem)) {
+ // If the decay fling can scroll past the current item, fling with decay
+ performDecayFling(
+ initialItem = initialItem,
+ targetIndex = index,
+ initialVelocity = initialVelocity,
+ )
+ } else {
+ // Otherwise we 'spring' to current/next item
+ performSpringFling(
+ initialItem = initialItem,
+ targetIndex = index,
+ initialVelocity = initialVelocity,
+ )
+ }
+ }
+
+ /**
+ * 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) {
+ Napier.d(
+ message = {
+ "Skipping decay: already at target. " +
+ "vel:$initialVelocity, " +
+ "current item: $initialItem, " +
+ "target: $targetIndex"
+ }
+ )
+ return consumeVelocityIfNotAtScrollEdge(initialVelocity)
+ }
+
+ Napier.d(
+ message = {
+ "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
+ var needSpringAfter = false
+
+ 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,
+ // set the spring-after flag and cancel the current decay
+ if (velocity > 0 && currentItem.index == targetIndex - 1) {
+ needSpringAfter = true
+ cancelAnimation()
+ } else if (velocity < 0 && currentItem.index == targetIndex) {
+ needSpringAfter = true
+ 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
+ }
+
+ Napier.d(
+ message = {
+ "Decay fling finished. Distance: $lastValue. Final vel: $velocityLeft"
+ }
+ )
+
+ if (needSpringAfter) {
+ // The needSpringAfter flag is enabled, so start a spring to the target using the
+ // remaining velocity
+ return performSpringFling(layoutInfo.currentItem!!, targetIndex, velocityLeft)
+ }
+
+ return consumeVelocityIfNotAtScrollEdge(velocityLeft)
+ }
+
+ private suspend fun ScrollScope.performSpringFling(
+ initialItem: SnapperLayoutItemInfo,
+ targetIndex: Int,
+ initialVelocity: Float = 0f,
+ ): Float {
+ Napier.d(
+ message = {
+ "Performing spring. " +
+ "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
+ }
+
+ Napier.d(
+ message = {
+ "Spring fling finished. Distance: $lastValue. Final vel: $velocityLeft"
+ }
+ )
+
+ return consumeVelocityIfNotAtScrollEdge(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 {
+ Napier.d(
+ message = {
+ "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
+ Napier.d(
+ message = {
+ "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)
+
+ Napier.d(
+ message = {
+ "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
+ }
+
+ private companion object {
+ init {
+ if (DebugLog) {
+ Napier.base(DebugAntilog(defaultTag = "SnapFlingBehavior"))
+ }
+ }
+ }
+}
diff --git a/kmp/lib/src/commonTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyColumnTest.kt b/kmp/lib/src/commonTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyColumnTest.kt
new file mode 100644
index 0000000..87e20fe
--- /dev/null
+++ b/kmp/lib/src/commonTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyColumnTest.kt
@@ -0,0 +1,98 @@
+/*
+ * 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
+
+/**
+ * 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(
+ private val maxScrollDistanceDp: Float,
+ private val contentPadding: PaddingValues,
+ // We don't use the Dp type due to https://youtrack.jetbrains.com/issue/KT-35523
+ private val itemSpacingDp: Int,
+ private val reverseLayout: Boolean,
+) : SnapperFlingBehaviorTest(maxScrollDistanceDp) {
+
+ override val endContentPadding: Int
+ get() = with(rule.density) { contentPadding.calculateBottomPadding().roundToPx() }
+
+ 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/commonTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyRowTest.kt b/kmp/lib/src/commonTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyRowTest.kt
new file mode 100644
index 0000000..85e3a22
--- /dev/null
+++ b/kmp/lib/src/commonTest/kotlin/dev/chrisbanes/snapper/BaseSnapperFlingLazyRowTest.kt
@@ -0,0 +1,113 @@
+/*
+ * 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.calculateEndPadding
+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
+
+/**
+ * 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(
+ private val maxScrollDistanceDp: Float,
+ private val contentPadding: PaddingValues,
+ // We don't use the Dp type due to https://youtrack.jetbrains.com/issue/KT-35523
+ private val itemSpacingDp: Int,
+ private val layoutDirection: LayoutDirection,
+ private val reverseLayout: Boolean,
+) : SnapperFlingBehaviorTest(maxScrollDistanceDp) {
+
+ override val endContentPadding: Int
+ get() = with(rule.density) {
+ contentPadding.calculateEndPadding(layoutDirection).roundToPx()
+ }
+
+ /**
+ * 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/commonTest/kotlin/dev/chrisbanes/snapper/SnapperFlingBehaviorTest.kt b/kmp/lib/src/commonTest/kotlin/dev/chrisbanes/snapper/SnapperFlingBehaviorTest.kt
new file mode 100644
index 0000000..f5bb95f
--- /dev/null
+++ b/kmp/lib/src/commonTest/kotlin/dev/chrisbanes/snapper/SnapperFlingBehaviorTest.kt
@@ -0,0 +1,355 @@
+/*
+ * 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.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 io.github.aakira.napier.Napier
+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
+
+val ItemSize = 200.dp
+
+@OptIn(ExperimentalSnapperApi::class) // Pager is currently experimental
+abstract class SnapperFlingBehaviorTest(
+ private val maxScrollDistanceDp: Float,
+) {
+ @get:Rule
+ val rule = createComposeRule()
+
+ abstract val endContentPadding: Int
+
+ /**
+ * 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 swipeToEndAndBack() {
+ val lazyListState = LazyListState()
+ val snappingFlingBehavior = createSnapFlingBehavior(lazyListState)
+ setTestContent(
+ flingBehavior = snappingFlingBehavior,
+ lazyListState = lazyListState,
+ count = 4,
+ )
+
+ // Now swipe towards start, from page 0 to page 1 and assert the layout
+ rule.onNodeWithTag("layout").swipeAcrossCenter(-MediumSwipeDistance)
+ rule.waitForIdle()
+ lazyListState.assertCurrentItem(minIndex = 1, offset = 0)
+
+ // Repeat for 1 -> 2
+ rule.onNodeWithTag("layout").swipeAcrossCenter(-MediumSwipeDistance)
+ rule.waitForIdle()
+ lazyListState.assertCurrentItem(minIndex = 2, offset = 0)
+
+ // Repeat for 2 -> 3
+ rule.onNodeWithTag("layout").swipeAcrossCenter(-MediumSwipeDistance)
+ rule.waitForIdle()
+ lazyListState.assertCurrentItem(index = 3, offset = 0)
+
+ // Swipe past the last item. We shouldn't move
+ rule.onNodeWithTag("layout").swipeAcrossCenter(-MediumSwipeDistance)
+ rule.waitForIdle()
+ lazyListState.assertCurrentItem(index = 3, offset = 0)
+
+ // Swipe back from 3 -> 2
+ rule.onNodeWithTag("layout").swipeAcrossCenter(MediumSwipeDistance)
+ rule.waitForIdle()
+ lazyListState.assertCurrentItem(maxIndex = 2, offset = 0)
+
+ // Swipe back from 2 -> 1
+ rule.onNodeWithTag("layout").swipeAcrossCenter(MediumSwipeDistance)
+ rule.waitForIdle()
+ lazyListState.assertCurrentItem(maxIndex = 1, offset = 0)
+
+ // Swipe back from 1 -> 0
+ rule.onNodeWithTag("layout").swipeAcrossCenter(MediumSwipeDistance)
+ rule.waitForIdle()
+ lazyListState.assertCurrentItem(index = 0, offset = 0)
+
+ // Swipe past the first item. We shouldn't move
+ rule.onNodeWithTag("layout").swipeAcrossCenter(MediumSwipeDistance)
+ rule.waitForIdle()
+ lazyListState.assertCurrentItem(index = 0, offset = 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()
+
+ lazyListState.logVisibleItems()
+
+ // ...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).isEqualTo(1)
+
+ // Now re-enable the clock advancement and let the snap animation run
+ rule.mainClock.autoAdvance = true
+ rule.waitForIdle()
+
+ // ...and assert that we now laid out from page 1
+ lazyListState.assertCurrentItem(index = 1, offset = 0)
+ }
+
+ @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)
+ }
+
+ /**
+ * 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,
+ ): SnapperFlingBehavior = SnapperFlingBehavior(
+ layoutInfo = LazyListSnapperLayoutInfo(
+ lazyListState = lazyListState,
+ endContentPadding = endContentPadding,
+ snapOffsetForItem = SnapOffsets.Start,
+ ),
+ decayAnimationSpec = exponentialDecay(),
+ maximumFlingDistance = { with(rule.density) { maxScrollDistanceDp.dp.toPx() } }
+ )
+}
+
+/**
+ * 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.logVisibleItems() = Napier.d(
+ message = {
+ "Visible Items. " + layoutInfo.visibleItemsInfo.joinToString { it.log() }
+ }
+)
+
+private fun LazyListItemInfo.log(): String = "[i:$index,o:$offset,s:$size]"
+
+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).isIn(minIndex..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/commonTest/kotlin/dev/chrisbanes/snapper/SwipeVelocity.kt b/kmp/lib/src/commonTest/kotlin/dev/chrisbanes/snapper/SwipeVelocity.kt
new file mode 100644
index 0000000..2970bb2
--- /dev/null
+++ b/kmp/lib/src/commonTest/kotlin/dev/chrisbanes/snapper/SwipeVelocity.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.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.percentOffset
+import androidx.compose.ui.test.performGesture
+import androidx.compose.ui.test.swipe
+import androidx.compose.ui.test.swipeWithVelocity
+import androidx.compose.ui.unit.Dp
+import kotlin.math.absoluteValue
+import kotlin.math.hypot
+import kotlin.math.roundToLong
+
+fun SemanticsNodeInteraction.swipeAcrossCenterWithVelocity(
+ velocityPerSec: Dp,
+ distancePercentageX: Float = 0f,
+ distancePercentageY: Float = 0f,
+): SemanticsNodeInteraction = performGesture {
+ val startOffset = percentOffset(
+ x = 0.5f - distancePercentageX / 2,
+ y = 0.5f - distancePercentageY / 2
+ )
+ val endOffset = percentOffset(
+ x = 0.5f + distancePercentageX / 2,
+ y = 0.5f + distancePercentageY / 2
+ )
+
+ val node = fetchSemanticsNode("Failed to retrieve bounds of the node.")
+ val density = node.root!!.density
+ val velocityPxPerSec = with(density) { velocityPerSec.toPx() }
+
+ try {
+ swipeWithVelocity(
+ start = startOffset,
+ end = endOffset,
+ endVelocity = velocityPxPerSec,
+ )
+ } catch (e: IllegalArgumentException) {
+ // swipeWithVelocity throws an exception if the given distance + velocity isn't feasible:
+ // https://issuetracker.google.com/182477143. To work around this, we catch the exception
+ // and instead run a swipe() with a computed duration instead. This is not perfect,
+ // but good enough.
+ val distance = hypot(endOffset.x - startOffset.x, endOffset.y - startOffset.y)
+ swipe(
+ start = startOffset,
+ end = endOffset,
+ durationMillis = ((distance.absoluteValue / velocityPxPerSec) * 1000).roundToLong(),
+ )
+ }
+}
diff --git a/kmp/lib/src/commonTest/kotlin/dev/chrisbanes/snapper/TestUtils.kt b/kmp/lib/src/commonTest/kotlin/dev/chrisbanes/snapper/TestUtils.kt
new file mode 100644
index 0000000..fa35e08
--- /dev/null
+++ b/kmp/lib/src/commonTest/kotlin/dev/chrisbanes/snapper/TestUtils.kt
@@ -0,0 +1,50 @@
+/*
+ * 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.ui.graphics.Color
+import kotlin.random.Random
+
+inline fun parameterizedParams(): List> = emptyList()
+
+inline fun List>.combineWithParameters(
+ vararg values: T
+): List> {
+ if (isEmpty()) {
+ return values.map { arrayOf(it) }
+ }
+
+ return fold(emptyList()) { acc, args ->
+ val result = acc.toMutableList()
+ values.forEach { v ->
+ result += ArrayList().apply {
+ addAll(args)
+ add(v)
+ }.toTypedArray()
+ }
+ result.toList()
+ }
+}
+
+fun randomColor() = Color(
+ alpha = 1f,
+ red = Random.nextFloat(),
+ green = Random.nextFloat(),
+ blue = Random.nextFloat(),
+)
diff --git a/kmp/lib/src/desktopTest/kotlin/dev/chrisbanes/snapper/DesktopSnapperFlingLazyColumnTest.kt b/kmp/lib/src/desktopTest/kotlin/dev/chrisbanes/snapper/DesktopSnapperFlingLazyColumnTest.kt
new file mode 100644
index 0000000..057b2e8
--- /dev/null
+++ b/kmp/lib/src/desktopTest/kotlin/dev/chrisbanes/snapper/DesktopSnapperFlingLazyColumnTest.kt
@@ -0,0 +1,63 @@
+/*
+ * 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 org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@RunWith(Parameterized::class)
+class DesktopSnapperFlingLazyColumnTest(
+ maxScrollDistanceDp: Float,
+ contentPadding: PaddingValues,
+ itemSpacingDp: Int,
+ reverseLayout: Boolean,
+) : BaseSnapperFlingLazyColumnTest(
+ maxScrollDistanceDp,
+ contentPadding,
+ itemSpacingDp,
+ reverseLayout,
+) {
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(
+ name = "maxScrollDistanceDp={0}," +
+ "contentPadding={1}," +
+ "itemSpacing={2}," +
+ "reverseLayout={3}"
+ )
+ fun data() = parameterizedParams()
+ // maxScrollDistanceDp
+ .combineWithParameters(
+ // We add 4dp on to cater for itemSpacing
+ 1 * (ItemSize.value + 4),
+ 2 * (ItemSize.value + 4),
+ 4 * (ItemSize.value + 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(true, false)
+ }
+}
diff --git a/kmp/lib/src/desktopTest/kotlin/dev/chrisbanes/snapper/DesktopSnapperFlingLazyRowTest.kt b/kmp/lib/src/desktopTest/kotlin/dev/chrisbanes/snapper/DesktopSnapperFlingLazyRowTest.kt
new file mode 100644
index 0000000..63fb2b6
--- /dev/null
+++ b/kmp/lib/src/desktopTest/kotlin/dev/chrisbanes/snapper/DesktopSnapperFlingLazyRowTest.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 org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@RunWith(Parameterized::class)
+class DesktopSnapperFlingLazyRowTest(
+ maxScrollDistanceDp: Float,
+ contentPadding: PaddingValues,
+ itemSpacingDp: Int,
+ layoutDirection: LayoutDirection,
+ reverseLayout: Boolean,
+) : BaseSnapperFlingLazyRowTest(
+ maxScrollDistanceDp,
+ contentPadding,
+ itemSpacingDp,
+ layoutDirection,
+ reverseLayout,
+) {
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(
+ name = "maxScrollDistanceDp={0}," +
+ "contentPadding={1}," +
+ "itemSpacing={2}," +
+ "layoutDirection={3}," +
+ "reverseLayout={4}"
+ )
+ fun data() = parameterizedParams()
+ // maxScrollDistanceDp
+ .combineWithParameters(
+ // We add 4dp on to cater for itemSpacing
+ 1 * (ItemSize.value + 4),
+ 2 * (ItemSize.value + 4),
+ 4 * (ItemSize.value + 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(true, false)
+ }
+}
diff --git a/lib/build.gradle b/lib/build.gradle
index c3b6fab..e9e4c32 100644
--- a/lib/build.gradle
+++ b/lib/build.gradle
@@ -16,7 +16,7 @@
plugins {
id 'com.android.library'
- id 'kotlin-android'
+ id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.dokka'
}
@@ -35,8 +35,8 @@ android {
}
compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
+ sourceCompatibility JavaVersion.VERSION_11
+ targetCompatibility JavaVersion.VERSION_11
}
buildFeatures {
diff --git a/sample/build.gradle b/sample/build.gradle
index f7da5eb..e561b4c 100644
--- a/sample/build.gradle
+++ b/sample/build.gradle
@@ -37,8 +37,8 @@ android {
}
compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
+ sourceCompatibility JavaVersion.VERSION_11
+ targetCompatibility JavaVersion.VERSION_11
}
buildTypes {
diff --git a/settings.gradle b/settings.gradle.kts
similarity index 62%
rename from settings.gradle
rename to settings.gradle.kts
index 4e4c527..c1f5093 100644
--- a/settings.gradle
+++ b/settings.gradle.kts
@@ -14,20 +14,33 @@
* limitations under the License.
*/
+pluginManagement {
+ repositories {
+ google()
+ gradlePluginPortal()
+ mavenCentral()
+ maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
+ }
+}
+
plugins {
- id 'com.gradle.enterprise' version '3.5'
+ id("com.gradle.enterprise") version "3.5"
}
gradleEnterprise {
buildScan {
- termsOfServiceUrl = 'https://gradle.com/terms-of-service'
- termsOfServiceAgree = 'yes'
+ termsOfServiceUrl = "https://gradle.com/terms-of-service"
+ termsOfServiceAgree = "yes"
}
}
-include ':lib'
-include ':internal-testutils'
-include ':sample'
+include(":lib")
+include(":internal-testutils")
+include(":sample")
+
+include(":kmp:lib")
+include(":kmp:android")
+include(":kmp:desktop")
// Enable Gradle's version catalog support
// https://docs.gradle.org/current/userguide/platforms.html