Skip to content

Commit

Permalink
Move applying all scroll deltas into one coroutine
Browse files Browse the repository at this point in the history
  • Loading branch information
MatkovIvan committed Feb 12, 2024
1 parent 91e1824 commit 3db084b
Showing 1 changed file with 127 additions and 112 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package androidx.compose.foundation.gestures

import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateTo
import androidx.compose.animation.core.tween
Expand All @@ -40,16 +41,11 @@ import androidx.compose.ui.util.fastForEach
import kotlin.coroutines.coroutineContext
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope

private val AnimationThreshold = 4.dp
private val AnimationSpeed = 1.dp // dp / ms
private const val MaxAnimationDuration: Int = 100 // ms
import kotlinx.coroutines.withTimeoutOrNull

internal class MouseWheelScrollNode(
private val scrollingLogic: ScrollingLogic,
Expand All @@ -62,12 +58,13 @@ internal class MouseWheelScrollNode(
mouseWheelScrollConfig = platformScrollConfig()
physics = if (mouseWheelScrollConfig.isSmoothScrollingEnabled) {
AnimatedMouseWheelScrollPhysics(
coroutineScope = coroutineScope,
mouseWheelScrollConfig = mouseWheelScrollConfig,
scrollingLogic = scrollingLogic,
density = { currentValueOf(LocalDensity) }
).apply {
launchAnimatedDispatchScroll()
coroutineScope.launch {
receiveMouseWheelEvents()
}
}
} else {
RawMouseWheelScrollPhysics(mouseWheelScrollConfig, scrollingLogic)
Expand Down Expand Up @@ -140,59 +137,29 @@ private class RawMouseWheelScrollPhysics(
}

private class AnimatedMouseWheelScrollPhysics(
private val coroutineScope: CoroutineScope,
mouseWheelScrollConfig: ScrollConfig,
scrollingLogic: ScrollingLogic,
val density: () -> Density,
) : ScrollPhysics(mouseWheelScrollConfig, scrollingLogic) {
private var isAnimationRunning = false
private val channel = Channel<Float>(capacity = Channel.UNLIMITED)

fun launchAnimatedDispatchScroll() = coroutineScope.launch {
while (coroutineContext.isActive) {
val eventDelta = channel.receive()
isAnimationRunning = true

try {
val speed = with(density()) { AnimationSpeed.toPx() }
scrollingLogic.animatedDispatchScroll(eventDelta, speed) {
// Sum delta from all pending events to avoid multiple animation restarts.
channel.sumOrNull()
}
} finally {
isAnimationRunning = false
}
}
private data class MouseWheelScrollDelta(
val value: Offset,
val shouldApplyImmediately: Boolean
) {
operator fun plus(other: MouseWheelScrollDelta) = MouseWheelScrollDelta(
value = value + other.value,
shouldApplyImmediately = shouldApplyImmediately || other.shouldApplyImmediately
)
}
private val channel = Channel<MouseWheelScrollDelta>(capacity = Channel.UNLIMITED)

private fun animateWheelScroll(delta: Float) =
channel.trySend(delta).isSuccess

private fun ScrollingLogic.dispatchWheelScroll(delta: Float) {
val offset = delta.reverseIfNeeded().toOffset()
coroutineScope.launch {
scrollableState.userScroll {
dispatchScroll(offset, NestedScrollSource.Wheel)
}

/*
* TODO Set isScrollInProgress to true in case of touchpad.
* Dispatching raw delta doesn't cause a progress indication even with wrapping in
* `scrollableState.scroll` block, since it applies the change within single frame.
* Touchpads emit just multiple mouse wheel events, so detecting start and end of this
* "gesture" is not straight forward.
* Ideally it should be resolved by catching real touches from input device instead of
* introducing a timeout (after each event before resetting progress flag).
*/
suspend fun receiveMouseWheelEvents() {
while (coroutineContext.isActive) {
val scrollDelta = channel.receive()
val speed = with(density()) { AnimationSpeed.toPx() }
scrollingLogic.dispatchMouseWheelScroll(scrollDelta, speed)
}
}

private fun ScrollScope.dispatchWheelScroll(delta: Float): Float = with(scrollingLogic) {
val offset = delta.reverseIfNeeded().toOffset()
val consumed = dispatchScroll(offset, NestedScrollSource.Wheel)
consumed.reverseIfNeeded().toFloat()
}

private suspend fun ScrollableState.userScroll(
block: suspend ScrollScope.() -> Unit
) = supervisorScope {
Expand All @@ -204,31 +171,22 @@ private class AnimatedMouseWheelScrollPhysics(
val scrollDelta = with(mouseWheelScrollConfig) {
calculateMouseWheelScroll(pointerEvent, size)
}
return with(scrollingLogic) {
val delta = scrollDelta.reverseIfNeeded().toFloat()
if (delta != 0f && canConsumeDelta(delta)) {
if (isAnimationRunning) {
animateWheelScroll(delta)
} else {
val thresholdInPx = AnimationThreshold.toPx()
if (abs(delta) > thresholdInPx) {
val thresholdDelta = if (delta > 0f) thresholdInPx else -thresholdInPx
dispatchWheelScroll(thresholdDelta)
animateWheelScroll(delta - thresholdDelta)
} else {
dispatchWheelScroll(delta)
true
}
}
} else false
}
}
return if (scrollingLogic.canConsumeDelta(scrollDelta)) {
channel.trySend(MouseWheelScrollDelta(
value = scrollDelta,

private fun Channel<Float>.sumOrNull(): Float? {
val elements = untilNull { tryReceive().getOrNull() }.toList()
return if (elements.isEmpty()) null else elements.sum()
// In case of high-resolution wheel, such as a freely rotating wheel with
// no notches or trackpads, delta should apply immediately, without any delays.
shouldApplyImmediately = mouseWheelScrollConfig.isPreciseWheelScroll(pointerEvent)
)).isSuccess
} else false
}

private fun Channel<MouseWheelScrollDelta>.sumOrNull() =
untilNull { tryReceive().getOrNull() }
.toList()
.reduceOrNull { accumulator, it -> accumulator + it }

private fun <E> untilNull(builderAction: () -> E?) = sequence<E> {
do {
val element = builderAction()?.also {
Expand All @@ -237,65 +195,122 @@ private class AnimatedMouseWheelScrollPhysics(
} while (element != null)
}

private fun ScrollingLogic.canConsumeDelta(delta: Float): Boolean {
return if (delta > 0f) {
private fun ScrollingLogic.canConsumeDelta(scrollDelta: Offset): Boolean {
val delta = scrollDelta.reverseIfNeeded().toFloat() // Use only current axis
return if (delta == 0f) {
false // It means that it's for another axis and cannot be consumed
} else if (delta > 0f) {
scrollableState.canScrollForward
} else {
scrollableState.canScrollBackward
}
}

private suspend fun ScrollingLogic.animatedDispatchScroll(
eventDelta: Float,
private suspend fun ScrollingLogic.dispatchMouseWheelScroll(
scrollDelta: MouseWheelScrollDelta,
speed: Float, // px / ms
tryReceiveNext: () -> Float?
) {
var target = eventDelta
tryReceiveNext()?.let {
target += it
var targetScrollDelta = scrollDelta
// Sum delta from all pending events to avoid multiple animation restarts.
channel.sumOrNull()?.let {
targetScrollDelta += it
}
if (target.isLowScrollingDelta()) {
var targetValue = targetScrollDelta.value.reverseIfNeeded().toFloat()
if (targetValue.isLowScrollingDelta()) {
return
}
var requiredAnimation = true
var lastValue = 0f
val anim = AnimationState(0f)
while (requiredAnimation && coroutineContext.isActive) {
requiredAnimation = false
val durationMillis = (abs(target - anim.value) / speed)
.roundToInt()
.coerceAtMost(MaxAnimationDuration)
scrollableState.userScroll {
anim.animateTo(
target,
animationSpec = tween(
durationMillis = durationMillis,
easing = LinearEasing
),
sequentialAnimation = true
) {
val delta = value - lastValue
if (!delta.isLowScrollingDelta()) {
val consumedDelta = dispatchWheelScroll(delta)
if (!(delta - consumedDelta).isLowScrollingDelta()) {
cancelAnimation()
return@animateTo

/*
* TODO Handle real down/up events from touchpad to set isScrollInProgress correctly.
* Touchpads emit just multiple mouse wheel events, so detecting start and end of this
* "gesture" is not straight forward.
* Ideally it should be resolved by catching real touches from input device instead of
* waiting the next event with timeout (before resetting progress flag).
*/
suspend fun waitNextScrollDelta(timeoutMillis: Long): Boolean {
if (timeoutMillis < 0) return false
return withTimeoutOrNull(timeoutMillis) {
channel.receive()
}?.let {
targetScrollDelta = it
targetValue = targetScrollDelta.value.reverseIfNeeded().toFloat()

!targetValue.isLowScrollingDelta()
} ?: false
}

scrollableState.userScroll {
val animationState = AnimationState(0f)
var requiredAnimation = true
while (requiredAnimation) {
requiredAnimation = false
if (targetScrollDelta.shouldApplyImmediately) {
dispatchMouseWheelScroll(targetValue)
requiredAnimation = waitNextScrollDelta(ProgressTimeout)
} else {
val durationMillis = (abs(targetValue - animationState.value) / speed)
.roundToInt()
.coerceAtMost(MaxAnimationDuration)
animateMouseWheelScroll(animationState, targetValue, durationMillis) { lastValue ->
// Sum delta from all pending events to avoid multiple animation restarts.
val nextScrollDelta = channel.sumOrNull()
if (nextScrollDelta != null) {
targetScrollDelta += nextScrollDelta
targetValue = targetScrollDelta.value.reverseIfNeeded().toFloat()

requiredAnimation = !(targetValue - lastValue).isLowScrollingDelta()
}
lastValue += delta
}
tryReceiveNext()?.let {
target += it
requiredAnimation = !(target - lastValue).isLowScrollingDelta()
cancelAnimation()
nextScrollDelta != null
}
requiredAnimation = waitNextScrollDelta(ProgressTimeout - durationMillis)
}
}
}
}

private suspend fun ScrollScope.animateMouseWheelScroll(
animationState: AnimationState<Float, AnimationVector1D>,
targetValue: Float,
durationMillis: Int,
shouldCancelAnimation: (lastValue: Float) -> Boolean
) {
var lastValue = animationState.value
animationState.animateTo(
targetValue,
animationSpec = tween(
durationMillis = durationMillis,
easing = LinearEasing
),
sequentialAnimation = true
) {
val delta = value - lastValue
if (!delta.isLowScrollingDelta()) {
val consumedDelta = dispatchMouseWheelScroll(delta)
if (!(delta - consumedDelta).isLowScrollingDelta()) {
cancelAnimation()
return@animateTo
}
lastValue += delta
}
if (shouldCancelAnimation(lastValue)) {
cancelAnimation()
}
}
}

private fun ScrollScope.dispatchMouseWheelScroll(delta: Float) = with(scrollingLogic) {
val offset = delta.reverseIfNeeded().toOffset()
val consumed = dispatchScroll(offset, NestedScrollSource.Wheel)
consumed.reverseIfNeeded().toFloat()
}
}

/*
* 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 val AnimationSpeed = 1.dp // dp / ms
private const val MaxAnimationDuration = 100 // ms
private const val ProgressTimeout = 100L // ms

0 comments on commit 3db084b

Please sign in to comment.