Skip to content

Commit

Permalink
iOS native scroll and feel (JetBrains#609)
Browse files Browse the repository at this point in the history
* developmentTeamToRunOnRealDevice

* print jvm arch

* iOS run configuration

* redundant run configurations

* run configurations

* Revert "run configurations"

This reverts commit 3b04108.

* Revert "redundant run configurations"

This reverts commit bb18b67.

* Revert "iOS run configuration"

This reverts commit 69b717e.

* simplify

* check runOnDevice correctly

* update README

* update README

* run with Xcode

* run with Xcode

* run with Xcode

* doc run mpp/demo-uikit sample on iOS

* projectProperties

* projectProperties

* fix iOS on x86-64

* simplify demo-uikit

* Added check to TEAM_ID

* Add geometry functions to Offset.kt

* Implement DecelerationTimingParameters

* add feature flag

* Add RubberBand effect business logic

* Fix comment

* Refactor RubberBand

* WIP: start experimenting

* Revert Scroll.kt changes

* Chop OvercrollEffect actual providers into uikit and macos targets. Implement stub for iOS.

* Implement working stub for iOS.

* Implement rubber band logic in IOSOverScrollEffect

* Delete dead code, mark private members accordingly.

* Explicitly pass environmental variables to Xcode build script.

* Integrate COMPOSE_DEMO_APPLE_TEAM_ID variable into xcodegen

* Add stub for demo with spring animation

* Add Underdamped spring solution

* Create logic for making spring solution

* Snapshot

* Remove dead code.

* Add spec-based implementation

* Add test case to check animation

* Snapshot

* Snapshot

* Snapshot

* Snapshot

* Snapshot everything except edge case working

* Move connecting fling behavior and overscroll further down the tree

* Add back button on nested selection screens in Demo App

* Play spring animation on zero velocity fling.

* Remove dead code

* Snapshot before fling from overscroll edge case

* Resolve edge case of flinging from inside overscroll area

* Update documentation

* Refactor boolean value to OverscrollOffsetSpace enum

* Refactor boolean value to OverscrollOffsetSpace enum

* Modify API to support interaction between OverscrollEffect and FlingBehavior

* Delete dead code.

* Update comment.

* Update comment.

* Move division by density outside of Offset

* Revert "Add geometry functions to Offset.kt"

This reverts commit 947b295.

* Remove redundant import

* Remove redundant CupertinoFlingBehavior.

* Remove redundant constructor arg

* Refine documentation

* Modify android code and tests to conform to API change

* Update document

* Reset change

* Remove gradle line.

* Revert changes

* Revert changes

* Revert changes

* Add comment

* Make constant internal

* Revert Scrollable.kt.

* Bring CupertinoFlingBehavior back

* Revert changes.

* Revert API change.

* Updater converter interface

* Update CupertinoOverscrollEffect

* Update CupertinoOverscrollEffect

* Revert "Modify android code and tests to conform to API change"

This reverts commit d5ad295.

* Revert changes

* Remove dead code mentions

* Bind cupertino fling and overscroll behavior together

* Fix a wrong construction argument bug

* Revert line

* Remove unneeded imports

* Remove rubberband/linear space logic. iOS seems to calculate everything in rubberband space.

* Update stiffness to counteract rubbe-band space visual feedback.

* Fix typo.

* Return it back.

* Refactor density injection logic.

* Fix edge case.

* Change var to val

* Remove new line

* Fix formatting

* Fix typo

* Adjust fling from overscroll logic

* Implement Cupertino behavior using old API

* Remove println

* Fix RTL and add demo

* Make added API internal

* Add optOut API

* Update comment

* Remove ScrollValueConverter altogether.

* Update documentation

* Move CupertinoScrollDecayAnimationSpec to uikit source set

* Move CupertinoOverscrollEffect to cupertino module

* Change visibility modifer

* Change visibility modifier

* Move CupertinoScrollDecayAnimationSpec to cupertino module

* Revert visibility modifier change.

* Fix formatting

* Remove dead code.

* Use specific type instead of a Pair

* Add explicit arguments' names

* Update formatting

* Replace boolean with enum

* Move Velocity<->Offset conversions to file scope.

* Replace default decelerationRate with iOS constant instead of literal

* Remove extra line

* Move opt-out rubber banding to ScrollConfig

* Refactor last change a bit

* Rename UIKitScroll config

* Fix experimental annotation to correct one

---------

Co-authored-by: dima.avdeev <dima.avdeev@jetbrains.com>
  • Loading branch information
elijah-semyonov and dima-avdeev-jb committed Jul 10, 2023
1 parent 8d08295 commit 7ca673f
Show file tree
Hide file tree
Showing 14 changed files with 809 additions and 112 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package androidx.compose.foundation.gestures

import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable

/**
Expand All @@ -42,4 +43,7 @@ interface FlingBehavior {
* @return remaining velocity after fling operation has ended
*/
suspend fun ScrollScope.performFling(initialVelocity: Float): Float
}
}

@Composable
internal expect fun rememberFlingBehavior(): FlingBehavior
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -198,12 +193,7 @@ object ScrollableDefaults {
* Create and remember default [FlingBehavior] that will represent natural fling curve.
*/
@Composable
fun flingBehavior(): FlingBehavior {
val flingSpec = rememberSplineBasedDecay<Float>()
return remember(flingSpec) {
DefaultFlingBehavior(flingSpec)
}
}
fun flingBehavior(): FlingBehavior = rememberFlingBehavior()

/**
* Create and remember default [OverscrollEffect] that will be used for showing over scroll
Expand Down Expand Up @@ -412,7 +402,7 @@ private suspend fun ScrollingLogic.animatedDispatchScroll(
tryReceiveNext()?.let {
target += it
}
if (target.isAboutZero()) {
if (target.isLowScrollingDelta()) {
return
}
scrollableState.scroll {
Expand All @@ -433,17 +423,17 @@ 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
}
lastValue += delta
}
tryReceiveNext()?.let {
target += it
requiredAnimation = !(target - lastValue).isAboutZero()
requiredAnimation = !(target - lastValue).isLowScrollingDelta()
cancelAnimation()
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Float>()
return remember(flingSpec) {
DefaultFlingBehavior(flingSpec)
}
}
Original file line number Diff line number Diff line change
@@ -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<Float>()
return remember(flingSpec) {
DefaultFlingBehavior(flingSpec)
}
}
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 7ca673f

Please sign in to comment.