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