diff --git a/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/Animation.kt b/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/Animation.kt index f494d2b89740a..b55ba34bd1d86 100644 --- a/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/Animation.kt +++ b/compose/animation/animation-core/src/commonMain/kotlin/androidx/compose/animation/core/Animation.kt @@ -17,6 +17,7 @@ package androidx.compose.animation.core import androidx.compose.animation.core.internal.JvmDefaultWithCompatibility +import kotlin.math.roundToLong /** * This interface provides a convenient way to query from an [VectorizedAnimationSpec] or @@ -90,6 +91,13 @@ internal val Animation<*, *>.durationMillis: Long get() = durationNanos / MillisToNanos internal const val MillisToNanos: Long = 1_000_000L +internal const val SecondsToNanos: Long = 1_000_000_000L + +internal fun convertSecondsToNanos(seconds: Float): Long = + (seconds.toDouble() * SecondsToNanos).roundToLong() + +internal fun convertNanosToSeconds(nanos: Long): Double = + nanos.toDouble() / SecondsToNanos /** * Returns the velocity of the animation at the given play time. diff --git a/compose/animation/animation-core/src/uikitMain/kotlin/androidx/compose/animation/core/cupertino/CupertinoScrollDecayAnimationSpec.kt b/compose/animation/animation-core/src/uikitMain/kotlin/androidx/compose/animation/core/cupertino/CupertinoScrollDecayAnimationSpec.kt new file mode 100644 index 0000000000000..000f40c1600d5 --- /dev/null +++ b/compose/animation/animation-core/src/uikitMain/kotlin/androidx/compose/animation/core/cupertino/CupertinoScrollDecayAnimationSpec.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * 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 + * + * http://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 androidx.compose.animation.core.cupertino + +import androidx.compose.animation.core.FloatDecayAnimationSpec +import androidx.compose.animation.core.convertNanosToSeconds +import androidx.compose.animation.core.convertSecondsToNanos +import kotlin.math.abs +import kotlin.math.ln +import kotlin.math.pow +import platform.UIKit.UIScrollViewDecelerationRateNormal + +/** + * A class that represents the animation specification for a scroll decay animation + * using iOS-style decay behavior. + * + * @property decelerationRate The rate at which the velocity decelerates over time. + * Default value is equal to one used by default UIScrollView behavior. + */ +class CupertinoScrollDecayAnimationSpec( + private val decelerationRate: Float = UIScrollViewDecelerationRateNormal.toFloat() +) : FloatDecayAnimationSpec { + + private val coefficient: Float = 1000f * ln(decelerationRate) + + override val absVelocityThreshold: Float = 0.5f // Half pixel + + override fun getTargetValue(initialValue: Float, initialVelocity: Float): Float = + initialValue - initialVelocity / coefficient + + override fun getValueFromNanos( + playTimeNanos: Long, + initialValue: Float, + initialVelocity: Float + ): Float { + val playTimeSeconds = convertNanosToSeconds(playTimeNanos).toFloat() + val initialVelocityOverTimeIntegral = + (decelerationRate.pow(1000f * playTimeSeconds) - 1f) / coefficient * initialVelocity + return initialValue + initialVelocityOverTimeIntegral + } + + override fun getDurationNanos(initialValue: Float, initialVelocity: Float): Long { + val absVelocity = abs(initialVelocity) + + if (absVelocity < absVelocityThreshold) { + return 0 + } + + val seconds = ln(-coefficient * absVelocityThreshold / absVelocity) / coefficient + + return convertSecondsToNanos(seconds) + } + + override fun getVelocityFromNanos( + playTimeNanos: Long, + initialValue: Float, + initialVelocity: Float + ): Float { + val playTimeSeconds = convertNanosToSeconds(playTimeNanos).toFloat() + + return initialVelocity * decelerationRate.pow(1000f * playTimeSeconds) + } +} \ No newline at end of file diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/FlingBehavior.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/FlingBehavior.kt index 733b4f45bddc9..736d361bca06f 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/FlingBehavior.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/FlingBehavior.kt @@ -16,6 +16,7 @@ package androidx.compose.foundation.gestures +import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable /** @@ -42,4 +43,7 @@ interface FlingBehavior { * @return remaining velocity after fling operation has ended */ suspend fun ScrollScope.performFling(initialVelocity: Float): Float -} \ No newline at end of file +} + +@Composable +internal expect fun rememberFlingBehavior(): FlingBehavior \ No newline at end of file diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt index e199be34cb8a4..1a8dfa715c31b 100644 --- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt +++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/gestures/Scrollable.kt @@ -22,11 +22,7 @@ import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateDecay import androidx.compose.animation.core.animateTo import androidx.compose.animation.core.tween -import androidx.compose.animation.rememberSplineBasedDecay -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.MutatePriority -import androidx.compose.foundation.OverscrollEffect -import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.* import androidx.compose.foundation.gestures.Orientation.Horizontal import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.rememberOverscrollEffect @@ -70,7 +66,6 @@ import androidx.compose.ui.util.fastForEach import kotlin.math.abs import kotlin.math.roundToInt import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -198,12 +193,7 @@ object ScrollableDefaults { * Create and remember default [FlingBehavior] that will represent natural fling curve. */ @Composable - fun flingBehavior(): FlingBehavior { - val flingSpec = rememberSplineBasedDecay() - return remember(flingSpec) { - DefaultFlingBehavior(flingSpec) - } - } + fun flingBehavior(): FlingBehavior = rememberFlingBehavior() /** * Create and remember default [OverscrollEffect] that will be used for showing over scroll @@ -412,7 +402,7 @@ private suspend fun ScrollingLogic.animatedDispatchScroll( tryReceiveNext()?.let { target += it } - if (target.isAboutZero()) { + if (target.isLowScrollingDelta()) { return } scrollableState.scroll { @@ -433,9 +423,9 @@ private suspend fun ScrollingLogic.animatedDispatchScroll( sequentialAnimation = true ) { val delta = value - lastValue - if (!delta.isAboutZero()) { + if (!delta.isLowScrollingDelta()) { val consumedDelta = scrollBy(delta) - if (!(delta - consumedDelta).isAboutZero()) { + if (!(delta - consumedDelta).isLowScrollingDelta()) { cancelAnimation() return@animateTo } @@ -443,7 +433,7 @@ private suspend fun ScrollingLogic.animatedDispatchScroll( } tryReceiveNext()?.let { target += it - requiredAnimation = !(target - lastValue).isAboutZero() + requiredAnimation = !(target - lastValue).isLowScrollingDelta() cancelAnimation() } } @@ -471,7 +461,12 @@ private fun Modifier.mouseWheelInput( private inline val PointerEvent.isConsumed: Boolean get() = changes.fastAny { it.isConsumed } private inline fun PointerEvent.consume() = changes.fastForEach { it.consume() } -private inline fun Float.isAboutZero(): Boolean = abs(this) < 0.5f + +/* + * Returns true, if the value is too low for visible change in scroll (consumed delta, animation-based change, etc), + * false otherwise + */ +private inline fun Float.isLowScrollingDelta(): Boolean = abs(this) < 0.5f private suspend fun AwaitPointerEventScope.awaitScrollEvent(): PointerEvent { var event: PointerEvent @@ -491,6 +486,7 @@ private class ScrollingLogic( val overscrollEffect: OverscrollEffect? ) { private val isNestedFlinging = mutableStateOf(false) + fun Float.toOffset(): Offset = when { this == 0f -> Offset.Zero orientation == Horizontal -> Offset(this, 0f) @@ -571,49 +567,61 @@ private class ScrollingLogic( val availableVelocity = initialVelocity.singleAxisVelocity() - val performFling: suspend (Velocity) -> Velocity = { velocity -> - val preConsumedByParent = nestedScrollDispatcher - .value.dispatchPreFling(velocity) - val available = velocity - preConsumedByParent - val velocityLeft = doFlingAnimation(available) - val consumedPost = - nestedScrollDispatcher.value.dispatchPostFling( - (available - velocityLeft), - velocityLeft - ) - val totalLeft = velocityLeft - consumedPost - velocity - totalLeft - } + scrollableState.scroll { + val performFling: suspend (Velocity) -> Velocity = { velocity -> + val preConsumedByParent = nestedScrollDispatcher + .value.dispatchPreFling(velocity) + val available = velocity - preConsumedByParent + val velocityLeft = doFlingAnimation(available) + val consumedPost = + nestedScrollDispatcher.value.dispatchPostFling( + (available - velocityLeft), + velocityLeft + ) + val totalLeft = velocityLeft - consumedPost + velocity - totalLeft + } - if (overscrollEffect != null && shouldDispatchOverscroll) { - overscrollEffect.applyToFling(availableVelocity, performFling) - } else { - performFling(availableVelocity) + if (overscrollEffect != null && shouldDispatchOverscroll) { + overscrollEffect.applyToFling(availableVelocity, performFling) + } else { + performFling(availableVelocity) + } } // Self stopped flinging, reset registerNestedFling(false) } - suspend fun doFlingAnimation(available: Velocity): Velocity { + suspend fun ScrollScope.doFlingAnimation(available: Velocity): Velocity { var result: Velocity = available - scrollableState.scroll { - val outerScopeScroll: (Offset) -> Offset = { delta -> - dispatchScroll(delta.reverseIfNeeded(), Fling).reverseIfNeeded() - } - val scope = object : ScrollScope { - override fun scrollBy(pixels: Float): Float { - return outerScopeScroll.invoke(pixels.toOffset()).toFloat() - } + + val outerScopeScroll: (Offset) -> Offset = { delta -> + dispatchScroll(delta.reverseIfNeeded(), Fling).reverseIfNeeded() + } + val scope = object : ScrollScope { + override fun scrollBy(pixels: Float): Float { + return outerScopeScroll.invoke(pixels.toOffset()).toFloat() } - with(scope) { - with(flingBehavior) { - result = result.update( - performFling(available.toFloat().reverseIfNeeded()).reverseIfNeeded() - ) - } + } + with(scope) { + with(flingBehavior) { + result = result.update( + performFling(available.toFloat().reverseIfNeeded()).reverseIfNeeded() + ) } } + + return result + } + + suspend fun doFlingAnimationInNewScrollScope(available: Velocity): Velocity { + var result: Velocity = available + + scrollableState.scroll { + result = doFlingAnimation(available) + } + return result } @@ -683,7 +691,7 @@ private fun scrollableNestedScrollConnection( available: Velocity ): Velocity { return if (enabled) { - val velocityLeft = scrollLogic.value.doFlingAnimation(available) + val velocityLeft = scrollLogic.value.doFlingAnimationInNewScrollScope(available) available - velocityLeft } else { Velocity.Zero diff --git a/compose/foundation/foundation/src/jsWasmMain/kotlin/androidx/compose/foundation/gestures/FlingBehavior.jsWasm.kt b/compose/foundation/foundation/src/jsWasmMain/kotlin/androidx/compose/foundation/gestures/FlingBehavior.jsWasm.kt new file mode 100644 index 0000000000000..b64d1189cac74 --- /dev/null +++ b/compose/foundation/foundation/src/jsWasmMain/kotlin/androidx/compose/foundation/gestures/FlingBehavior.jsWasm.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * 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 + * + * http://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 androidx.compose.foundation.gestures + +import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +@Composable +internal actual fun rememberFlingBehavior(): FlingBehavior { + val flingSpec = rememberSplineBasedDecay() + return remember(flingSpec) { + DefaultFlingBehavior(flingSpec) + } +} \ No newline at end of file diff --git a/compose/foundation/foundation/src/jvmMain/kotlin/androidx/compose/foundation/gestures/FlingBehavior.jvm.kt b/compose/foundation/foundation/src/jvmMain/kotlin/androidx/compose/foundation/gestures/FlingBehavior.jvm.kt new file mode 100644 index 0000000000000..b64d1189cac74 --- /dev/null +++ b/compose/foundation/foundation/src/jvmMain/kotlin/androidx/compose/foundation/gestures/FlingBehavior.jvm.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * 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 + * + * http://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 androidx.compose.foundation.gestures + +import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +@Composable +internal actual fun rememberFlingBehavior(): FlingBehavior { + val flingSpec = rememberSplineBasedDecay() + return remember(flingSpec) { + DefaultFlingBehavior(flingSpec) + } +} \ No newline at end of file diff --git a/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/Overscroll.macos.kt b/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/Overscroll.macos.kt new file mode 100644 index 0000000000000..f8585f3deb9a2 --- /dev/null +++ b/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/Overscroll.macos.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * 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 + * + * http://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 androidx.compose.foundation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.foundation.NoOpOverscrollEffect + +@ExperimentalFoundationApi +@Composable +internal actual fun rememberOverscrollEffect(): OverscrollEffect = NoOpOverscrollEffect \ No newline at end of file diff --git a/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/gestures/FlingBehavior.macos.kt b/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/gestures/FlingBehavior.macos.kt new file mode 100644 index 0000000000000..b64d1189cac74 --- /dev/null +++ b/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/gestures/FlingBehavior.macos.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * 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 + * + * http://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 androidx.compose.foundation.gestures + +import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember + +@Composable +internal actual fun rememberFlingBehavior(): FlingBehavior { + val flingSpec = rememberSplineBasedDecay() + return remember(flingSpec) { + DefaultFlingBehavior(flingSpec) + } +} \ No newline at end of file diff --git a/compose/foundation/foundation/src/nativeMain/kotlin/androidx/compose/foundation/Overscroll.native.kt b/compose/foundation/foundation/src/nativeMain/kotlin/androidx/compose/foundation/Overscroll.native.kt deleted file mode 100644 index dd8bbf1e503d4..0000000000000 --- a/compose/foundation/foundation/src/nativeMain/kotlin/androidx/compose/foundation/Overscroll.native.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2023 The Android Open Source Project - * - * 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 - * - * http://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 androidx.compose.foundation - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.unit.Velocity - -@OptIn(ExperimentalFoundationApi::class) -@Composable -internal actual fun rememberOverscrollEffect(): OverscrollEffect { - return remember { - NativeOverscrollEffect() // TODO: Split between UIKit and MacOS - } -} - -@OptIn(ExperimentalFoundationApi::class) -private class NativeOverscrollEffect() : OverscrollEffect { - override fun applyToScroll( - delta: Offset, - source: NestedScrollSource, - performScroll: (Offset) -> Offset - ): Offset { - val overscrollDelta = Offset.Zero // TODO: implement similar to Android - return overscrollDelta + performScroll(delta) - } - - override suspend fun applyToFling( - velocity: Velocity, - performFling: suspend (Velocity) -> Velocity - ) { - // TODO: implement similar to Android - performFling(velocity) - } - - override val isInProgress = false - override val effectModifier = Modifier -} diff --git a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/Overscroll.uikit.kt b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/Overscroll.uikit.kt new file mode 100644 index 0000000000000..cb7b0bdb7bfa5 --- /dev/null +++ b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/Overscroll.uikit.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * 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 + * + * http://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 androidx.compose.foundation + +import androidx.compose.foundation.cupertino.CupertinoOverscrollEffect +import androidx.compose.foundation.gestures.UiKitScrollConfig +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal actual fun rememberOverscrollEffect(): OverscrollEffect = + if (UiKitScrollConfig.isRubberBandingOverscrollEnabled) { + val density = LocalDensity.current.density + val layoutDirection = LocalLayoutDirection.current + + remember(density, layoutDirection) { + CupertinoOverscrollEffect(density, layoutDirection) + } + } else { + NoOpOverscrollEffect + } \ No newline at end of file diff --git a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/cupertino/CupertinoOverscrollEffect.kt b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/cupertino/CupertinoOverscrollEffect.kt new file mode 100644 index 0000000000000..66207db66fe24 --- /dev/null +++ b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/cupertino/CupertinoOverscrollEffect.kt @@ -0,0 +1,409 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * 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 + * + * http://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 androidx.compose.foundation.cupertino + +import androidx.compose.animation.core.* +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.OverscrollEffect +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.unit.* +import kotlin.coroutines.coroutineContext +import kotlin.math.abs +import kotlin.math.sign +import kotlinx.coroutines.isActive + +private enum class CupertinoScrollSource { + DRAG, FLING +} + +private enum class CupertinoOverscrollDirection { + UNKNOWN, VERTICAL, HORIZONTAL +} + +private enum class CupertinoSpringAnimationReason { + FLING_FROM_OVERSCROLL, POSSIBLE_SPRING_IN_THE_END +} + +/* + * Encapsulates internal calculation data representing per-dimension change after drag delta is consumed (or not) + * by [CupertinoOverscrollEffect] + */ +private data class CupertinoOverscrollAvailableDelta( + // delta which will be used to perform actual content scroll + val availableDelta: Float, + + // new overscroll value for dimension in context of which calculation returning + // instance of this type was returned + val newOverscrollValue: Float +) + +@OptIn(ExperimentalFoundationApi::class) +class CupertinoOverscrollEffect( + /* + * Density to be taken into consideration during computations; Cupertino formulas use + * DPs, and scroll machinery uses raw values. + */ + private val density: Float, + layoutDirection: LayoutDirection +) : OverscrollEffect { + /* + * Direction of scrolling for this overscroll effect, derived from arguments during + * [applyToScroll] calls. Technically this effect supports both dimensions, but current API requires + * that different stages of animations spawned by this effect for both dimensions + * end at the same time, which is not the case: + * Spring->Fling->Spring, Fling->Spring, Spring->Fling effects can have different timing per dimension + * (see Notes of https://github.com/JetBrains/compose-multiplatform-core/pull/609), + * which is not possible to express without changing API. Hence this effect will be fixed to latest + * received delta. + */ + private var direction: CupertinoOverscrollDirection = CupertinoOverscrollDirection.UNKNOWN + + private val reverseHorizontal = + when (layoutDirection) { + LayoutDirection.Ltr -> false + LayoutDirection.Rtl -> true + } + + /* + * Size of container is taking into consideration when computing rubber banding + */ + private var scrollSize: Size = Size.Zero + + /* + * Current offset in overscroll area + * Negative for bottom-right + * Positive for top-left + * Zero if within the scrollable range + * It will be mapped to the actual visible offset using the rubber banding rule inside + * [Modifier.offset] within [effectModifier] + */ + private var overscrollOffset: Offset by mutableStateOf(Offset.Zero) + + private var lastFlingUncosumedDelta: Offset = Offset.Zero + private val visibleOverscrollOffset: IntOffset + get() = + overscrollOffset.reverseHorizontalIfNeeded().rubberBanded().round() + + override val isInProgress: Boolean + get() = + // If visible overscroll offset has at least one pixel + // this effect is considered to be in progress + visibleOverscrollOffset.toOffset().getDistance() > 0.5f + + override val effectModifier = Modifier + .onPlaced { + scrollSize = it.size.toSize() + } + .offset { + visibleOverscrollOffset + } + + private fun NestedScrollSource.toCupertinoScrollSource(): CupertinoScrollSource? = + when (this) { + NestedScrollSource.Drag -> CupertinoScrollSource.DRAG + NestedScrollSource.Fling -> CupertinoScrollSource.FLING + else -> null + } + + /* + * Takes input scroll delta, current overscroll value, and scroll source, return [CupertinoOverscrollAvailableDelta] + */ + @Stable + private fun availableDelta( + delta: Float, + overscroll: Float, + source: CupertinoScrollSource + ): CupertinoOverscrollAvailableDelta { + // if source is fling: + // 1. no delta will be consumed + // 2. overscroll will stay the same + if (source == CupertinoScrollSource.FLING) { + return CupertinoOverscrollAvailableDelta(delta, overscroll) + } + + val newOverscroll = overscroll + delta + + return if (delta >= 0f && overscroll <= 0f) { + if (newOverscroll > 0f) { + CupertinoOverscrollAvailableDelta(newOverscroll, 0f) + } else { + CupertinoOverscrollAvailableDelta(0f, newOverscroll) + } + } else if (delta <= 0f && overscroll >= 0f) { + if (newOverscroll < 0f) { + CupertinoOverscrollAvailableDelta(newOverscroll, 0f) + } else { + CupertinoOverscrollAvailableDelta(0f, newOverscroll) + } + } else { + CupertinoOverscrollAvailableDelta(0f, newOverscroll) + } + } + + /* + * Returns the amount of scroll delta available after user performed scroll inside overscroll area + * It will update [overscroll] resulting in visual change because of [Modifier.offset] depending on it + */ + private fun availableDelta(delta: Offset, source: CupertinoScrollSource): Offset { + val (x, overscrollX) = availableDelta(delta.x, overscrollOffset.x, source) + val (y, overscrollY) = availableDelta(delta.y, overscrollOffset.y, source) + + overscrollOffset = Offset(overscrollX, overscrollY) + + return Offset(x, y) + } + + /* + * Semantics of this method match the [OverscrollEffect.applyToScroll] one, + * The only difference is NestedScrollSource being remapped to CupertinoScrollSource to narrow + * processed states invariant + */ + private fun applyToScroll( + delta: Offset, + source: CupertinoScrollSource, + performScroll: (Offset) -> Offset + ): Offset { + // Calculate how much delta is available after being consumed by scrolling inside overscroll area + val deltaLeftForPerformScroll = availableDelta(delta, source) + + // Then pass remaining delta to scroll closure + val deltaConsumedByPerformScroll = performScroll(deltaLeftForPerformScroll) + + // Delta which is left after `performScroll` was invoked with availableDelta + val unconsumedDelta = deltaLeftForPerformScroll - deltaConsumedByPerformScroll + + return when (source) { + CupertinoScrollSource.DRAG -> { + // [unconsumedDelta] is going into overscroll again in case a user drags and hits the + // overscroll->content->overscroll or content->overscroll scenario within single frame + overscrollOffset += unconsumedDelta + lastFlingUncosumedDelta = Offset.Zero + delta - unconsumedDelta + } + + CupertinoScrollSource.FLING -> { + // If unconsumedDelta is not Zero, [CupertinoFlingEffect] will cancel fling and + // start spring animation instead + lastFlingUncosumedDelta = unconsumedDelta + delta - unconsumedDelta + } + } + } + + override fun applyToScroll( + delta: Offset, + source: NestedScrollSource, + performScroll: (Offset) -> Offset + ): Offset { + direction = direction.combinedWith(delta.toCupertinoOverscrollDirection()) + + return source.toCupertinoScrollSource()?.let { + applyToScroll(delta, it, performScroll) + } ?: performScroll(delta) + } + + override suspend fun applyToFling( + velocity: Velocity, + performFling: suspend (Velocity) -> Velocity + ) { + val availableFlingVelocity = playInitialSpringAnimationIfNeeded(velocity) + val velocityConsumedByFling = performFling(availableFlingVelocity) + val postFlingVelocity = availableFlingVelocity - velocityConsumedByFling + + playSpringAnimation( + lastFlingUncosumedDelta.toFloat(), + postFlingVelocity.toFloat(), + CupertinoSpringAnimationReason.POSSIBLE_SPRING_IN_THE_END + ) + } + + private fun Offset.toCupertinoOverscrollDirection(): CupertinoOverscrollDirection { + val hasXPart = abs(x) > 0f + val hasYPart = abs(y) > 0f + + return if (hasXPart xor hasYPart) { + if (hasXPart) { + CupertinoOverscrollDirection.HORIZONTAL + } else { + // hasYPart != hasXPart and hasXPart is false + CupertinoOverscrollDirection.VERTICAL + } + } else { + // hasXPart and hasYPart are equal + CupertinoOverscrollDirection.UNKNOWN + } + } + + private fun CupertinoOverscrollDirection.combinedWith(other: CupertinoOverscrollDirection): CupertinoOverscrollDirection = + when (this) { + CupertinoOverscrollDirection.UNKNOWN -> when (other) { + CupertinoOverscrollDirection.UNKNOWN -> CupertinoOverscrollDirection.UNKNOWN + CupertinoOverscrollDirection.VERTICAL -> CupertinoOverscrollDirection.VERTICAL + CupertinoOverscrollDirection.HORIZONTAL -> CupertinoOverscrollDirection.HORIZONTAL + } + + CupertinoOverscrollDirection.VERTICAL -> when (other) { + CupertinoOverscrollDirection.UNKNOWN, CupertinoOverscrollDirection.VERTICAL -> CupertinoOverscrollDirection.VERTICAL + CupertinoOverscrollDirection.HORIZONTAL -> CupertinoOverscrollDirection.HORIZONTAL + } + + CupertinoOverscrollDirection.HORIZONTAL -> when (other) { + CupertinoOverscrollDirection.UNKNOWN, CupertinoOverscrollDirection.HORIZONTAL -> CupertinoOverscrollDirection.HORIZONTAL + CupertinoOverscrollDirection.VERTICAL -> CupertinoOverscrollDirection.VERTICAL + } + } + + private fun Velocity.toFloat(): Float = + toOffset().toFloat() + + private fun Float.toVelocity(): Velocity = + toOffset().toVelocity() + + private fun Offset.toFloat(): Float = + when (direction) { + CupertinoOverscrollDirection.UNKNOWN -> 0f + CupertinoOverscrollDirection.VERTICAL -> y + CupertinoOverscrollDirection.HORIZONTAL -> x + } + + private fun Float.toOffset(): Offset = + when (direction) { + CupertinoOverscrollDirection.UNKNOWN -> Offset.Zero + CupertinoOverscrollDirection.VERTICAL -> Offset(0f, this) + CupertinoOverscrollDirection.HORIZONTAL -> Offset(this, 0f) + } + + private suspend fun playInitialSpringAnimationIfNeeded(initialVelocity: Velocity): Velocity { + val velocity = initialVelocity.toFloat() + val overscroll = overscrollOffset.toFloat() + + return if ((velocity < 0f && overscroll > 0f) || (velocity > 0f && overscroll < 0f)) { + playSpringAnimation( + unconsumedDelta = 0f, + velocity, + CupertinoSpringAnimationReason.FLING_FROM_OVERSCROLL + ).toVelocity() + } else { + initialVelocity + } + } + + private suspend fun playSpringAnimation( + unconsumedDelta: Float, + initialVelocity: Float, + reason: CupertinoSpringAnimationReason + ): Float { + val initialValue = overscrollOffset.toFloat() + unconsumedDelta + val initialSign = sign(initialValue) + var currentVelocity = initialVelocity + + // All input values are divided by density so all internal calculations are performed as if + // they operated on DPs. Callback value is then scaled back to raw pixels. + val visibilityThreshold = 0.5f / density + + val spec = when (reason) { + CupertinoSpringAnimationReason.FLING_FROM_OVERSCROLL -> { + spring( + dampingRatio = Spring.DampingRatioLowBouncy, + stiffness = 400f, + visibilityThreshold = visibilityThreshold + ) + } + CupertinoSpringAnimationReason.POSSIBLE_SPRING_IN_THE_END -> { + spring( + stiffness = 200f, + visibilityThreshold = visibilityThreshold + ) + } + } + + AnimationState( + Float.VectorConverter, + initialValue / density, + initialVelocity / density + ).animateTo( + targetValue = 0f, + animationSpec = spec + ) { + overscrollOffset = (value * density).toOffset() + currentVelocity = velocity * density + + // If it was fling from overscroll, cancel animation and return velocity + if (reason == CupertinoSpringAnimationReason.FLING_FROM_OVERSCROLL && initialSign != 0f && sign(value) != initialSign) { + this.cancelAnimation() + } + } + + if (coroutineContext.isActive) { + // The spring is critically damped, so in case spring-fling-spring sequence + // is slightly offset and velocity is of the opposite sign, it will end up with no animation + overscrollOffset = Offset.Zero + } + + if (reason == CupertinoSpringAnimationReason.POSSIBLE_SPRING_IN_THE_END) { + currentVelocity = 0f + } + + return currentVelocity + } + + private fun Offset.reverseHorizontalIfNeeded(): Offset = + Offset( + if (reverseHorizontal) -x else x, + y + ) + + private fun Offset.rubberBanded(): Offset { + if (scrollSize.width == 0f || scrollSize.height == 0f) { + return Offset.Zero + } + + val dpOffset = this / density + val dpSize = scrollSize / density + + return Offset( + rubberBandedValue(dpOffset.x, dpSize.width, RUBBER_BAND_COEFFICIENT), + rubberBandedValue(dpOffset.y, dpSize.height, RUBBER_BAND_COEFFICIENT) + ) * density + } + + /* + * Maps raw delta offset [value] on an axis within scroll container with [dimension] + * to actual visible offset + */ + private fun rubberBandedValue(value: Float, dimension: Float, coefficient: Float) = + sign(value) * (1f - (1f / (abs(value) * coefficient / dimension + 1f))) * dimension + + companion object Companion { + private const val RUBBER_BAND_COEFFICIENT = 0.55f + } +} + +private fun Velocity.toOffset(): Offset = + Offset(x, y) + +private fun Offset.toVelocity(): Velocity = + Velocity(x, y) \ No newline at end of file diff --git a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/gestures/FlingBehavior.uikit.kt b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/gestures/FlingBehavior.uikit.kt new file mode 100644 index 0000000000000..8581adf30b1c4 --- /dev/null +++ b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/gestures/FlingBehavior.uikit.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * 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 + * + * http://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 androidx.compose.foundation.gestures + +import androidx.compose.animation.core.cupertino.CupertinoScrollDecayAnimationSpec +import androidx.compose.animation.core.generateDecayAnimationSpec +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity + +@Composable +internal actual fun rememberFlingBehavior(): FlingBehavior { + val density = LocalDensity.current.density + + return remember(density) { + DefaultFlingBehavior(CupertinoScrollDecayAnimationSpec().generateDecayAnimationSpec()) + } +} \ No newline at end of file diff --git a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/gestures/UikitScrollable.uikit.kt b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/gestures/UikitScrollable.uikit.kt index 6704427edfd8d..aa4cf3711510d 100644 --- a/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/gestures/UikitScrollable.uikit.kt +++ b/compose/foundation/foundation/src/uikitMain/kotlin/androidx/compose/foundation/gestures/UikitScrollable.uikit.kt @@ -18,6 +18,7 @@ package androidx.compose.foundation.gestures +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.fastFold import androidx.compose.runtime.Composable import androidx.compose.ui.geometry.Offset @@ -27,9 +28,22 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @Composable -internal actual fun platformScrollConfig(): ScrollConfig = UiKitConfig +internal actual fun platformScrollConfig(): ScrollConfig = UiKitScrollConfig + +/** + * Opt out of the Cupertino overscroll behavior (rubber banding and spring effect). + * + * This method should be called before any @Composable function using this effect is executed + * (so as early as possible, e.g. during app start up). + */ +@ExperimentalFoundationApi +fun optOutOfCupertinoOverscroll() { + UiKitScrollConfig.isRubberBandingOverscrollEnabled = false +} + +internal object UiKitScrollConfig : ScrollConfig { + var isRubberBandingOverscrollEnabled: Boolean = true -private object UiKitConfig : ScrollConfig { /* * There are no scroll events produced on iOS, * so in reality this function should not be ever called. diff --git a/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/LazyLayouts.kt b/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/LazyLayouts.kt index 1ae01d38ea9da..86a40d537a868 100644 --- a/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/LazyLayouts.kt +++ b/compose/mpp/demo/src/commonMain/kotlin/androidx/compose/mpp/demo/LazyLayouts.kt @@ -21,18 +21,20 @@ import androidx.compose.foundation.background import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.material.Button import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.runtime.withFrameMillis +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import kotlin.random.Random @@ -41,6 +43,7 @@ val LazyLayouts = Screen.Selection( Screen.Example("LazyColumn") { ExampleLazyColumn() }, Screen.Example("LazyGrid") { ExampleLazyGrid() }, Screen.Example("StaggeredGrid") { ExampleStaggeredGrid() }, + Screen.Example("TwoDirectionsAndRTL") { ExampleTwoDirectionsAndRTL() } ) @Composable @@ -87,3 +90,50 @@ private fun ExampleStaggeredGrid() { } } } + +@Composable +private fun ExampleTwoDirectionsAndRTL() { + val colors = listOf( + Color.Black, + Color.LightGray, + Color.DarkGray, + Color.Gray + ) + + val rows = 20 + val columns = 20 + + val rowHeight = 200.dp + + var layoutDirection by remember { mutableStateOf(LayoutDirection.Ltr) } + + CompositionLocalProvider( + LocalLayoutDirection provides layoutDirection + ) { + Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { + Button(onClick = { + layoutDirection = when (layoutDirection) { + LayoutDirection.Ltr -> LayoutDirection.Rtl + LayoutDirection.Rtl -> LayoutDirection.Ltr + } + }) { + Text("Toggle layout direction") + } + LazyColumn(Modifier.fillMaxSize()) { + items(rows) {row -> + LazyRow(Modifier.height(rowHeight)) { + items(columns) {col -> + val color = colors[(row + col) % colors.size] + + Box( + Modifier + .size(200.dp, rowHeight) + .background(color) + ) + } + } + } + } + } + } +}