Skip to content

Commit

Permalink
Trigger fling callbacks on mouse wheel scroll (#1100)
Browse files Browse the repository at this point in the history
## Proposed Changes

- Based on #1055
- Trigger `onScrollStopped` callback after mouse wheel scroll
- Ignore `ScrollableDefaultFlingBehavior`s during mouse scroll to avoid
breaking changes
- Remove scrolling via `dispatchRawDelta`, `isSmoothScrollingEnabled =
false` will just disable animation instead
- Add pager test page in mpp demo

## Testing

Test: run pager page in mpp demo

## Issues Fixed

Fixes JetBrains/compose-multiplatform#3447
Fixes JetBrains/compose-multiplatform#3454
  • Loading branch information
MatkovIvan committed Feb 14, 2024
1 parent c6c9310 commit 9a39a79
Show file tree
Hide file tree
Showing 9 changed files with 195 additions and 157 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -32,79 +32,62 @@ import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode
import androidx.compose.ui.node.CompositionLocalConsumerModifierNode
import androidx.compose.ui.node.DelegatingNode
import androidx.compose.ui.node.ObserverModifierNode
import androidx.compose.ui.node.currentValueOf
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import kotlin.coroutines.coroutineContext
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlin.math.sign
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.select
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withTimeoutOrNull

internal class MouseWheelScrollNode(
private val scrollingLogic: ScrollingLogic,
private var _enabled: Boolean,
) : DelegatingNode(), CompositionLocalConsumerModifierNode, ObserverModifierNode {
private val onScrollStopped: suspend CoroutineScope.(velocity: Velocity) -> Unit,
private var enabled: Boolean,
) : DelegatingNode(), CompositionLocalConsumerModifierNode {
private lateinit var mouseWheelScrollConfig: ScrollConfig
private lateinit var physics: ScrollPhysics

override fun onAttach() {
mouseWheelScrollConfig = platformScrollConfig()
physics = if (mouseWheelScrollConfig.isSmoothScrollingEnabled) {
AnimatedMouseWheelScrollPhysics(
mouseWheelScrollConfig = mouseWheelScrollConfig,
scrollingLogic = scrollingLogic,
density = { currentValueOf(LocalDensity) }
).apply {
coroutineScope.launch {
receiveMouseWheelEvents()
}
}
} else {
RawMouseWheelScrollPhysics(mouseWheelScrollConfig, scrollingLogic)
coroutineScope.launch {
receiveMouseWheelEvents()
}
}

// TODO(https://youtrack.jetbrains.com/issue/COMPOSE-731/Scrollable-doesnt-react-on-density-changes)
// it isn't called, because LocalDensity is staticCompositionLocalOf
override fun onObservedReadsChanged() {
physics.mouseWheelScrollConfig = mouseWheelScrollConfig
physics.scrollingLogic = scrollingLogic
}

private val pointerInputNode = delegate(SuspendingPointerInputModifierNode {
if (_enabled) {
if (enabled) {
mouseWheelInput()
}
})

var enabled
get() = _enabled
set(value) {
if (_enabled != value) {
_enabled = value
pointerInputNode.resetPointerInputHandler()
}
fun update(
enabled: Boolean,
) {
var resetPointerInputHandling = false
if (this.enabled != enabled) {
this.enabled = enabled
resetPointerInputHandling = true
}
if (resetPointerInputHandling) {
pointerInputNode.resetPointerInputHandler()
}
}

private suspend fun PointerInputScope.mouseWheelInput() = awaitPointerEventScope {
while (coroutineScope.isActive) {
val event = awaitScrollEvent()
if (!event.isConsumed) {
val consumed = with(physics) { onMouseWheel(event) }
val consumed = onMouseWheel(event)
if (consumed) {
event.consume()
}
Expand All @@ -122,32 +105,7 @@ internal class MouseWheelScrollNode(

private inline val PointerEvent.isConsumed: Boolean get() = changes.fastAny { it.isConsumed }
private inline fun PointerEvent.consume() = changes.fastForEach { it.consume() }
}

private abstract class ScrollPhysics(
var mouseWheelScrollConfig: ScrollConfig,
var scrollingLogic: ScrollingLogic,
) {
abstract fun PointerInputScope.onMouseWheel(pointerEvent: PointerEvent): Boolean
}

private class RawMouseWheelScrollPhysics(
mouseWheelScrollConfig: ScrollConfig,
scrollingLogic: ScrollingLogic,
) : ScrollPhysics(mouseWheelScrollConfig, scrollingLogic) {
override fun PointerInputScope.onMouseWheel(pointerEvent: PointerEvent): Boolean {
val delta = with(mouseWheelScrollConfig) {
calculateMouseWheelScroll(pointerEvent, size)
}
return scrollingLogic.dispatchRawDelta(delta) != Offset.Zero
}
}

private class AnimatedMouseWheelScrollPhysics(
mouseWheelScrollConfig: ScrollConfig,
scrollingLogic: ScrollingLogic,
val density: () -> Density,
) : ScrollPhysics(mouseWheelScrollConfig, scrollingLogic) {
private data class MouseWheelScrollDelta(
val value: Offset,
val shouldApplyImmediately: Boolean
Expand All @@ -159,10 +117,10 @@ private class AnimatedMouseWheelScrollPhysics(
}
private val channel = Channel<MouseWheelScrollDelta>(capacity = Channel.UNLIMITED)

suspend fun receiveMouseWheelEvents() {
private suspend fun receiveMouseWheelEvents() {
while (coroutineContext.isActive) {
val scrollDelta = channel.receive()
val density = density()
val density = currentValueOf(LocalDensity)
val threshold = with(density) { AnimationThreshold.toPx() }
val speed = with(density) { AnimationSpeed.toPx() }
scrollingLogic.dispatchMouseWheelScroll(scrollDelta, threshold, speed)
Expand All @@ -176,17 +134,18 @@ private class AnimatedMouseWheelScrollPhysics(
scroll(MutatePriority.UserInput, block)
}

override fun PointerInputScope.onMouseWheel(pointerEvent: PointerEvent): Boolean {
private fun PointerInputScope.onMouseWheel(pointerEvent: PointerEvent): Boolean {
val scrollDelta = with(mouseWheelScrollConfig) {
calculateMouseWheelScroll(pointerEvent, size)
}
return if (scrollingLogic.canConsumeDelta(scrollDelta)) {
channel.trySend(MouseWheelScrollDelta(
value = scrollDelta,
shouldApplyImmediately = !mouseWheelScrollConfig.isSmoothScrollingEnabled

// 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)
// In case of high-resolution wheel, such as a freely rotating wheel with
// no notches or trackpads, delta should apply immediately, without any delays.
|| mouseWheelScrollConfig.isPreciseWheelScroll(pointerEvent)
)).isSuccess
} else false
}
Expand Down Expand Up @@ -311,6 +270,9 @@ private class AnimatedMouseWheelScrollPhysics(
}
}
}
val velocityPxInMs = minOf(abs(targetValue) / MaxAnimationDuration, speed)
val velocity = Velocity.Zero.update(sign(targetValue).reverseIfNeeded() * velocityPxInMs * 1000)
coroutineScope.onScrollStopped(velocity)
}

private suspend fun ScrollScope.animateMouseWheelScroll(
Expand Down Expand Up @@ -359,4 +321,4 @@ private inline fun Float.isLowScrollingDelta(): Boolean = abs(this) < 0.5f
private val AnimationThreshold = 6.dp // (AnimationSpeed * MaxAnimationDuration) / (1000ms / 60Hz)
private val AnimationSpeed = 1.dp // dp / ms
private const val MaxAnimationDuration = 100 // ms
private const val ScrollProgressTimeout = 100L // ms
private const val ScrollProgressTimeout = 50L // ms
Original file line number Diff line number Diff line change
Expand Up @@ -313,16 +313,42 @@ private class ScrollableNode(
delegate(FocusedBoundsObserverNode { contentInViewNode.onFocusBoundsChanged(it) })
}

private val draggableState = ScrollDraggableState(scrollingLogic)
private val startDragImmediately = { scrollingLogic.shouldScrollImmediately() }
private val onDragStopped: suspend CoroutineScope.(velocity: Velocity) -> Unit = { velocity ->
nestedScrollDispatcher.coroutineScope.launch {
scrollingLogic.onScrollStopped(velocity, Drag)
}
}

/**
* Pointer gesture handling
*/
val scrollableGesturesNode = delegate(
ScrollableGesturesNode(
interactionSource = interactionSource,
val draggableGesturesNode = delegate(
DraggableNode(
draggableState,
orientation = orientation,
enabled = enabled,
nestedScrollDispatcher = nestedScrollDispatcher,
scrollLogic = scrollingLogic
interactionSource = interactionSource,
reverseDirection = false,
startDragImmediately = startDragImmediately,
onDragStopped = onDragStopped,
canDrag = CanDragCalculation,
onDragStarted = NoOpOnDragStarted
)
)

private val onWheelScrollStopped: suspend CoroutineScope.(velocity: Velocity) -> Unit = { velocity ->
nestedScrollDispatcher.coroutineScope.launch {
scrollingLogic.onScrollStopped(velocity, Wheel)
}
}

val mouseWheelScrollNode = delegate(
MouseWheelScrollNode(
scrollingLogic = scrollingLogic,
onScrollStopped = onWheelScrollStopped,
enabled = enabled,
)
)

Expand Down Expand Up @@ -353,9 +379,19 @@ private class ScrollableNode(
nestedScrollDispatcher = nestedScrollDispatcher
)

scrollableGesturesNode.update(
interactionSource = interactionSource,
draggableGesturesNode.update(
draggableState,
orientation = orientation,
enabled = enabled,
interactionSource = interactionSource,
reverseDirection = false,
startDragImmediately = startDragImmediately,
onDragStarted = NoOpOnDragStarted,
onDragStopped = onDragStopped,
canDrag = CanDragCalculation
)

mouseWheelScrollNode.update(
enabled = enabled
)

Expand Down Expand Up @@ -523,9 +559,6 @@ interface BringIntoViewSpec {
}
}

@Composable
internal expect fun rememberFlingBehavior(): FlingBehavior

/**
* Contains the default values used by [scrollable]
*/
Expand All @@ -535,7 +568,7 @@ object ScrollableDefaults {
* Create and remember default [FlingBehavior] that will represent natural fling curve.
*/
@Composable
fun flingBehavior(): FlingBehavior = rememberFlingBehavior()
fun flingBehavior(): FlingBehavior = rememberPlatformDefaultFlingBehavior()

/**
* Create and remember default [OverscrollEffect] that will be used for showing over scroll
Expand Down Expand Up @@ -596,63 +629,6 @@ internal interface ScrollConfig {

internal expect fun CompositionLocalConsumerModifierNode.platformScrollConfig(): ScrollConfig

/**
* A node that detects and processes all scrollable gestures.
*/
private class ScrollableGesturesNode(
val scrollLogic: ScrollingLogic,
val orientation: Orientation,
val enabled: Boolean,
val nestedScrollDispatcher: NestedScrollDispatcher,
val interactionSource: MutableInteractionSource?
) : DelegatingNode() {
val draggableState = ScrollDraggableState(scrollLogic)
private val startDragImmediately = { scrollLogic.shouldScrollImmediately() }
private val onDragStopped: suspend CoroutineScope.(velocity: Velocity) -> Unit = { velocity ->
nestedScrollDispatcher.coroutineScope.launch {
scrollLogic.onDragStopped(velocity)
}
}

val draggableGesturesNode = delegate(
DraggableNode(
draggableState,
orientation = orientation,
enabled = enabled,
interactionSource = interactionSource,
reverseDirection = false,
startDragImmediately = startDragImmediately,
onDragStopped = onDragStopped,
canDrag = CanDragCalculation,
onDragStarted = NoOpOnDragStarted
)
)

val mouseWheelScrollNode = delegate(MouseWheelScrollNode(scrollLogic, enabled))

fun update(
orientation: Orientation,
enabled: Boolean,
interactionSource: MutableInteractionSource?,
) {

// update draggable node
draggableGesturesNode.update(
draggableState,
orientation = orientation,
enabled = enabled,
interactionSource = interactionSource,
reverseDirection = false,
startDragImmediately = startDragImmediately,
onDragStarted = NoOpOnDragStarted,
onDragStopped = onDragStopped,
canDrag = CanDragCalculation
)

mouseWheelScrollNode.enabled = enabled
}
}

private val CanDragCalculation: (PointerInputChange) -> Boolean =
{ down -> down.type != PointerType.Mouse }

Expand Down Expand Up @@ -752,7 +728,14 @@ internal class ScrollingLogic(
.reverseIfNeeded().toOffset()
}

suspend fun onDragStopped(initialVelocity: Velocity) {
suspend fun onScrollStopped(
initialVelocity: Velocity,
source: NestedScrollSource
) {
if (source == Wheel && !flingBehavior.shouldBeTriggeredByMouseWheel) {
return
}

// Self started flinging, set
registerNestedFling(true)

Expand Down Expand Up @@ -915,14 +898,27 @@ internal interface ScrollableDefaultFlingBehavior : FlingBehavior {
fun updateDensity(density: Density) = Unit
}

/**
* TODO Move it to public interface
* Currently, default [FlingBehavior] is not triggered at all to avoid unexpected effects
* during regular scrolling. However, custom one must be triggered because it's used not
* only for "inertia", but also for snapping in [androidx.compose.foundation.pager.Pager] or
* [androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior].
*/
private val FlingBehavior.shouldBeTriggeredByMouseWheel
get() = this !is ScrollableDefaultFlingBehavior

/**
* This method returns [ScrollableDefaultFlingBehavior] whose density will be managed by the
* [ScrollableElement] because it's not created inside [Composable] context.
* This is different from [rememberFlingBehavior] which creates [FlingBehavior] whose density
* This is different from [rememberPlatformDefaultFlingBehavior] which creates [FlingBehavior] whose density
* depends on [LocalDensity] and is automatically resolved.
*/
internal expect fun platformDefaultFlingBehavior(): ScrollableDefaultFlingBehavior

@Composable
internal expect fun rememberPlatformDefaultFlingBehavior(): FlingBehavior

internal class DefaultFlingBehavior(
var flingDecay: DecayAnimationSpec<Float>,
private val motionDurationScale: MotionDurationScale = DefaultScrollMotionDurationScale
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ internal actual fun platformDefaultFlingBehavior(): ScrollableDefaultFlingBehavi
)

@Composable
internal actual fun rememberFlingBehavior(): FlingBehavior {
internal actual fun rememberPlatformDefaultFlingBehavior(): FlingBehavior {
val flingSpec = rememberSplineBasedDecay<Float>()
return remember(flingSpec) {
DefaultFlingBehavior(flingSpec)
}
}
}
Loading

0 comments on commit 9a39a79

Please sign in to comment.