1 /*
<lambda>null2 * Copyright 2024 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.compose.foundation.gestures
18
19 import androidx.compose.animation.core.AnimationState
20 import androidx.compose.animation.core.AnimationVector1D
21 import androidx.compose.animation.core.LinearEasing
22 import androidx.compose.animation.core.animateTo
23 import androidx.compose.animation.core.copy
24 import androidx.compose.animation.core.tween
25 import androidx.compose.foundation.MutatePriority
26 import androidx.compose.runtime.withFrameNanos
27 import androidx.compose.ui.geometry.Offset
28 import androidx.compose.ui.input.nestedscroll.NestedScrollSource
29 import androidx.compose.ui.input.pointer.PointerEvent
30 import androidx.compose.ui.input.pointer.PointerEventPass
31 import androidx.compose.ui.input.pointer.PointerEventType
32 import androidx.compose.ui.input.pointer.util.VelocityTracker1D
33 import androidx.compose.ui.unit.Density
34 import androidx.compose.ui.unit.IntSize
35 import androidx.compose.ui.unit.Velocity
36 import androidx.compose.ui.unit.dp
37 import androidx.compose.ui.util.fastAny
38 import androidx.compose.ui.util.fastForEach
39 import kotlin.math.abs
40 import kotlin.math.roundToInt
41 import kotlin.math.sign
42 import kotlinx.coroutines.CoroutineScope
43 import kotlinx.coroutines.Job
44 import kotlinx.coroutines.channels.Channel
45 import kotlinx.coroutines.coroutineScope
46 import kotlinx.coroutines.isActive
47 import kotlinx.coroutines.launch
48 import kotlinx.coroutines.supervisorScope
49 import kotlinx.coroutines.withTimeoutOrNull
50
51 internal class MouseWheelScrollingLogic(
52 private val scrollingLogic: ScrollingLogic,
53 private val mouseWheelScrollConfig: ScrollConfig,
54 private val onScrollStopped: suspend (velocity: Velocity) -> Unit,
55 private var density: Density,
56 ) {
57 fun updateDensity(density: Density) {
58 this.density = density
59 }
60
61 fun onPointerEvent(pointerEvent: PointerEvent, pass: PointerEventPass, bounds: IntSize) {
62 if (pass == PointerEventPass.Main && pointerEvent.type == PointerEventType.Scroll) {
63 if (!pointerEvent.isConsumed) {
64 val consumed = onMouseWheel(pointerEvent, bounds)
65 if (consumed) {
66 pointerEvent.consume()
67 }
68 }
69 }
70 }
71
72 private inline val PointerEvent.isConsumed: Boolean
73 get() = changes.fastAny { it.isConsumed }
74
75 private fun PointerEvent.consume() = changes.fastForEach { it.consume() }
76
77 private data class MouseWheelScrollDelta(
78 val value: Offset,
79 val timeMillis: Long,
80 val shouldApplyImmediately: Boolean
81 ) {
82 operator fun plus(other: MouseWheelScrollDelta) =
83 MouseWheelScrollDelta(
84 value = value + other.value,
85
86 // Pick time from last one
87 timeMillis = maxOf(timeMillis, other.timeMillis),
88
89 // Ignore [other.shouldApplyImmediately] to avoid false-positive
90 // [isPreciseWheelScroll]
91 // detection during animation
92 shouldApplyImmediately = shouldApplyImmediately
93 )
94 }
95
96 private val channel = Channel<MouseWheelScrollDelta>(capacity = Channel.UNLIMITED)
97 private var isScrolling = false
98
99 private var receivingMouseWheelEventsJob: Job? = null
100
101 fun startReceivingMouseWheelEvents(coroutineScope: CoroutineScope) {
102 if (receivingMouseWheelEventsJob == null) {
103 receivingMouseWheelEventsJob =
104 coroutineScope.launch {
105 try {
106 while (coroutineContext.isActive) {
107 val scrollDelta = channel.receive()
108 val threshold = with(density) { AnimationThreshold.toPx() }
109 val speed = with(density) { AnimationSpeed.toPx() }
110 scrollingLogic.dispatchMouseWheelScroll(scrollDelta, threshold, speed)
111 }
112 } finally {
113 receivingMouseWheelEventsJob = null
114 }
115 }
116 }
117 }
118
119 private suspend fun ScrollingLogic.userScroll(block: suspend NestedScrollScope.() -> Unit) {
120 isScrolling = true
121 // Run it in supervisorScope to ignore cancellations from scrolls with higher MutatePriority
122 supervisorScope { scroll(MutatePriority.UserInput, block) }
123 isScrolling = false
124 }
125
126 private fun onMouseWheel(pointerEvent: PointerEvent, bounds: IntSize): Boolean {
127 val scrollDelta =
128 with(mouseWheelScrollConfig) {
129 with(density) { calculateMouseWheelScroll(pointerEvent, bounds) }
130 }
131 return if (scrollingLogic.canConsumeDelta(scrollDelta)) {
132 channel
133 .trySend(
134 MouseWheelScrollDelta(
135 value = scrollDelta,
136 timeMillis = pointerEvent.changes.first().uptimeMillis,
137 shouldApplyImmediately = !mouseWheelScrollConfig.isSmoothScrollingEnabled
138
139 // In case of high-resolution wheel, such as a freely rotating wheel
140 // with
141 // no notches or trackpads, delta should apply immediately, without any
142 // delays.
143 || mouseWheelScrollConfig.isPreciseWheelScroll(pointerEvent)
144 )
145 )
146 .isSuccess
147 } else isScrolling
148 }
149
150 private fun Channel<MouseWheelScrollDelta>.sumOrNull(): MouseWheelScrollDelta? {
151 var sum: MouseWheelScrollDelta? = null
152 for (i in untilNull { tryReceive().getOrNull() }) {
153 sum = if (sum == null) i else sum + i
154 }
155 return sum
156 }
157
158 /**
159 * Replacement of regular [Channel.receive] that schedules an invalidation each frame. It avoids
160 * entering an idle state while waiting for [ScrollProgressTimeout]. It's important for tests
161 * that attempt to trigger another scroll after a mouse wheel event.
162 */
163 private suspend fun Channel<MouseWheelScrollDelta>.busyReceive() = coroutineScope {
164 val job = launch {
165 while (coroutineContext.isActive) {
166 withFrameNanos {}
167 }
168 }
169 try {
170 receive()
171 } finally {
172 job.cancel()
173 }
174 }
175
176 private fun <E> untilNull(builderAction: () -> E?) =
177 sequence<E> {
178 do {
179 val element = builderAction()?.also { yield(it) }
180 } while (element != null)
181 }
182
183 private fun ScrollingLogic.canConsumeDelta(scrollDelta: Offset): Boolean {
184 val delta = scrollDelta.reverseIfNeeded().toFloat() // Use only current axis
185 return if (delta == 0f) {
186 false // It means that it's for another axis and cannot be consumed
187 } else if (delta > 0f) {
188 scrollableState.canScrollForward
189 } else {
190 scrollableState.canScrollBackward
191 }
192 }
193
194 private val velocityTracker = MouseWheelVelocityTracker()
195
196 private fun trackVelocity(scrollDelta: MouseWheelScrollDelta) {
197 velocityTracker.addDelta(scrollDelta.timeMillis, scrollDelta.value)
198 }
199
200 private suspend fun ScrollingLogic.dispatchMouseWheelScroll(
201 scrollDelta: MouseWheelScrollDelta,
202 threshold: Float, // px
203 speed: Float, // px / ms
204 ) {
205 var targetScrollDelta = scrollDelta
206 trackVelocity(scrollDelta)
207 // Sum delta from all pending events to avoid multiple animation restarts.
208 channel.sumOrNull()?.let {
209 trackVelocity(it)
210 targetScrollDelta += it
211 }
212 var targetValue = targetScrollDelta.value.reverseIfNeeded().toFloat()
213 if (targetValue.isLowScrollingDelta()) {
214 return
215 }
216 var animationState = AnimationState(0f)
217
218 /*
219 * TODO Handle real down/up events from touchpad to set isScrollInProgress correctly.
220 * Touchpads emit just multiple mouse wheel events, so detecting start and end of this
221 * "gesture" is not straight forward.
222 * Ideally it should be resolved by catching real touches from input device instead of
223 * waiting the next event with timeout before resetting progress flag.
224 */
225 suspend fun waitNextScrollDelta(timeoutMillis: Long): Boolean {
226 if (timeoutMillis < 0) return false
227 return withTimeoutOrNull(timeoutMillis) { channel.busyReceive() }
228 ?.let {
229 // Keep this value unchanged during animation
230 // Currently, [isPreciseWheelScroll] might be unstable in case if
231 // a precise value is almost equal regular one.
232 val previousDeltaShouldApplyImmediately =
233 targetScrollDelta.shouldApplyImmediately
234 targetScrollDelta =
235 it.copy(shouldApplyImmediately = previousDeltaShouldApplyImmediately)
236 targetValue = targetScrollDelta.value.reverseIfNeeded().toFloat()
237 animationState = AnimationState(0f) // Reset previous animation leftover
238 trackVelocity(it)
239
240 !targetValue.isLowScrollingDelta()
241 } ?: false
242 }
243
244 userScroll {
245 var requiredAnimation = true
246 while (requiredAnimation) {
247 requiredAnimation = false
248 val targetValueLeftover = targetValue - animationState.value
249 if (
250 targetScrollDelta.shouldApplyImmediately || abs(targetValueLeftover) < threshold
251 ) {
252 dispatchMouseWheelScroll(targetValueLeftover)
253 requiredAnimation = waitNextScrollDelta(ScrollProgressTimeout)
254 } else {
255 // Animation will start only on the next frame,
256 // so apply threshold immediately to avoid delays.
257 val instantDelta = sign(targetValueLeftover) * threshold
258 dispatchMouseWheelScroll(instantDelta)
259 animationState =
260 animationState.copy(value = animationState.value + instantDelta)
261
262 val durationMillis =
263 (abs(targetValue - animationState.value) / speed)
264 .roundToInt()
265 .coerceAtMost(MaxAnimationDuration)
266 animateMouseWheelScroll(animationState, targetValue, durationMillis) { lastValue
267 ->
268 // Sum delta from all pending events to avoid multiple animation restarts.
269 val nextScrollDelta = channel.sumOrNull()
270 if (nextScrollDelta != null) {
271 trackVelocity(nextScrollDelta)
272 targetScrollDelta += nextScrollDelta
273 targetValue = targetScrollDelta.value.reverseIfNeeded().toFloat()
274
275 requiredAnimation = !(targetValue - lastValue).isLowScrollingDelta()
276 }
277 nextScrollDelta != null
278 }
279 if (!requiredAnimation) {
280 // If it's completed, wait the next event with timeout before resetting
281 // progress flag
282 requiredAnimation =
283 waitNextScrollDelta(ScrollProgressTimeout - durationMillis)
284 }
285 }
286 }
287 }
288
289 var velocity = velocityTracker.calculateVelocity()
290 if (velocity == Velocity.Zero) {
291 // In case of single data point use animation speed and delta direction
292 val velocityPxInMs = minOf(abs(targetValue) / MaxAnimationDuration, speed)
293 velocity = (sign(targetValue).reverseIfNeeded() * velocityPxInMs * 1000).toVelocity()
294 }
295 onScrollStopped(velocity)
296 }
297
298 private suspend fun NestedScrollScope.animateMouseWheelScroll(
299 animationState: AnimationState<Float, AnimationVector1D>,
300 targetValue: Float,
301 durationMillis: Int,
302 shouldCancelAnimation: (lastValue: Float) -> Boolean
303 ) {
304 var lastValue = animationState.value
305 animationState.animateTo(
306 targetValue,
307 animationSpec = tween(durationMillis = durationMillis, easing = LinearEasing),
308 sequentialAnimation = true
309 ) {
310 val delta = value - lastValue
311 if (!delta.isLowScrollingDelta()) {
312 val consumedDelta = dispatchMouseWheelScroll(delta)
313 if (!(delta - consumedDelta).isLowScrollingDelta()) {
314 cancelAnimation()
315 return@animateTo
316 }
317 lastValue += delta
318 }
319 if (shouldCancelAnimation(lastValue)) {
320 cancelAnimation()
321 }
322 }
323 }
324
325 private fun NestedScrollScope.dispatchMouseWheelScroll(delta: Float) =
326 with(scrollingLogic) {
327 val offset = delta.reverseIfNeeded().toOffset()
328 val consumed =
329 scrollBy(
330 offset,
331 NestedScrollSource.UserInput,
332 )
333 consumed.reverseIfNeeded().toFloat()
334 }
335 }
336
337 private class MouseWheelVelocityTracker {
338 private val xVelocityTracker = VelocityTracker1D(isDataDifferential = true)
339 private val yVelocityTracker = VelocityTracker1D(isDataDifferential = true)
340
addDeltanull341 fun addDelta(timeMillis: Long, delta: Offset) {
342 xVelocityTracker.addDataPoint(timeMillis, delta.x)
343 yVelocityTracker.addDataPoint(timeMillis, delta.y)
344 }
345
calculateVelocitynull346 fun calculateVelocity(): Velocity {
347 val velocityX = xVelocityTracker.calculateVelocity(Float.MAX_VALUE)
348 val velocityY = yVelocityTracker.calculateVelocity(Float.MAX_VALUE)
349 return Velocity(velocityX, velocityY)
350 }
351 }
352
353 /*
354 * Returns true, if the value is too low for visible change in scroll (consumed delta, animation-based change, etc),
355 * false otherwise
356 */
Floatnull357 private fun Float.isLowScrollingDelta(): Boolean = isNaN() || abs(this) < 0.5f
358
359 private val AnimationThreshold = 6.dp // (AnimationSpeed * MaxAnimationDuration) / (1000ms / 60Hz)
360 private val AnimationSpeed = 1.dp // dp / ms
361 private const val MaxAnimationDuration = 100 // ms
362 private const val ScrollProgressTimeout = 50L // ms
363