Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Trigger fling callbacks on mouse wheel scroll #1100

Merged
merged 7 commits into from
Feb 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading