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