Skip to content

Commit 71c87ef

Browse files
ASalaveisvastven
andauthored
Improve fling gestures on iOS (#2851)
Move `WebVelocityTracker1D` to the skiko source set and rename it to the `PointerVelocityTracker1D`. Add `preventReversedPointerMovements` option that prevents velocity tracker from returning velocity with the opposite direction to the general scroll direction. Fixes https://youtrack.jetbrains.com/issue/CMP-9297/Fling-gestures-not-working-correctly-in-LazyColumn-on-iOS. ## Release Notes ### Fixes - iOS - Fix the scrolling inertia issue when performing short scroll gestures. - Fix an issue where a fling may occur unexpectedly when lifting a finger. --------- Co-authored-by: Vendula Švastalová <vendula.svastalova@jetbrains.com>
1 parent 39a7eb7 commit 71c87ef

3 files changed

Lines changed: 318 additions & 321 deletions

File tree

compose/ui/ui/src/iosMain/kotlin/androidx/compose/ui/input/pointer/util/PlatformVelocityTracker.ios.kt

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,21 +26,22 @@ import androidx.compose.ui.util.fastForEach
2626
internal actual fun PlatformVelocityTracker(): PlatformVelocityTracker = UIKitVelocityTracker()
2727

2828
private const val AssumePointerMoveStoppedMilliseconds: Int = 40
29-
private const val MinimumGestureDurationMilliseconds: Int = 50
29+
private const val MinimumGestureDurationSincePointerStop: Int = 50
30+
private const val MinimumGestureDurationMilliseconds: Int = 100
3031

3132
private class UIKitVelocityTracker: PlatformVelocityTracker {
32-
@OptIn(ExperimentalVelocityTrackerApi::class)
33-
private val strategy =
34-
VelocityTracker1D.Strategy.Lsq2 // non-differential, Lsq2 1D velocity tracker
35-
private val yVelocityTracker = VelocityTracker1D(strategy = strategy)
36-
private val xVelocityTracker = VelocityTracker1D(strategy = strategy)
33+
private val xVelocityTracker = PointerVelocityTracker1D(preventOppositeVelocity = true)
34+
private val yVelocityTracker = PointerVelocityTracker1D(preventOppositeVelocity = true)
3735
private var lastMoveEventTimeStamp = 0L
36+
private var lastPointerStartEventTimeStamp = 0L
3837
private var lastPointerStopEventTimeStamp = 0L
3938

4039
override fun addPointerInputChange(event: PointerInputChange, offset: Offset) {
4140
// If this is ACTION_DOWN: Reset the tracking.
4241
if (event.changedToDownIgnoreConsumed()) {
4342
resetTracking()
43+
lastPointerStartEventTimeStamp = event.uptimeMillis
44+
lastMoveEventTimeStamp = event.uptimeMillis
4445
}
4546

4647
// If this is not ACTION_UP event: Add events to the tracker as per the platform implementation.
@@ -60,7 +61,8 @@ private class UIKitVelocityTracker: PlatformVelocityTracker {
6061
}
6162

6263
if (event.changedToUpIgnoreConsumed() &&
63-
event.uptimeMillis - lastPointerStopEventTimeStamp < MinimumGestureDurationMilliseconds
64+
event.uptimeMillis - lastPointerStartEventTimeStamp > MinimumGestureDurationMilliseconds &&
65+
event.uptimeMillis - lastPointerStopEventTimeStamp < MinimumGestureDurationSincePointerStop
6466
) {
6567
resetTracking()
6668
}
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
/*
2+
* Copyright 2026 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.ui.input.pointer.util
18+
19+
import androidx.compose.ui.internal.checkPrecondition
20+
import kotlin.math.abs
21+
import kotlin.math.sign
22+
import kotlin.math.sqrt
23+
24+
private const val AssumePointerMoveStoppedMilliseconds: Int = 40
25+
private const val HistorySize: Int = 20
26+
private const val HorizonMilliseconds: Int = 100
27+
28+
internal class PointerVelocityTracker1D(
29+
// whether the data points added to the tracker represent differential values
30+
// (i.e. change in the tracked object's displacement since the previous data point).
31+
// If false, it means that the data points added to the tracker will be considered as absolute
32+
// values (e.g. positional values).
33+
val isDataDifferential: Boolean = false,
34+
// The velocity tracking strategy that this instance uses for all velocity calculations.
35+
private val strategy: Strategy = Strategy.Lsq2,
36+
// Prevents getting the velocity value opposite to the general scroll direction of the pointer
37+
// movement.
38+
private val preventOppositeVelocity: Boolean = false,
39+
) {
40+
41+
init {
42+
if (isDataDifferential && strategy.equals(Strategy.Lsq2)) {
43+
throw IllegalStateException("Lsq2 not (yet) supported for differential axes")
44+
}
45+
}
46+
47+
private val minSampleSize: Int =
48+
when (strategy) {
49+
Strategy.Impulse -> 2
50+
Strategy.Lsq2 -> 3
51+
}
52+
53+
/**
54+
* A strategy used for velocity calculation. Each strategy has a different philosophy that could
55+
* result in notably different velocities than the others, so make careful choice or change of
56+
* strategy whenever you want to make one.
57+
*/
58+
internal enum class Strategy {
59+
/**
60+
* Least squares strategy. Polynomial fit at degree 2. Note that the implementation of this
61+
* strategy currently supports only non-differential data points.
62+
*/
63+
Lsq2,
64+
65+
/**
66+
* Impulse velocity tracking strategy, that calculates velocity using the mathematical
67+
* relationship between kinetic energy and velocity.
68+
*/
69+
Impulse,
70+
}
71+
72+
// Circular buffer; current sample at index.
73+
private val samples: Array<DataPointAtTime?> = arrayOfNulls(HistorySize)
74+
private var index: Int = 0
75+
76+
// Reusable arrays to avoid allocation inside calculateVelocity.
77+
private val reusableDataPointsArray = FloatArray(HistorySize)
78+
private val reusableTimeArray = FloatArray(HistorySize)
79+
80+
// Reusable array to minimize allocations inside calculateLeastSquaresVelocity.
81+
private val reusableVelocityCoefficients = FloatArray(3)
82+
83+
/**
84+
* Adds a data point for velocity calculation at a given time, [timeMillis]. The data ponit
85+
* represents an amount of a change in position (for differential data points), or an absolute
86+
* position (for non-differential data points). Whether or not the tracker handles differential
87+
* data points is decided by [isDataDifferential], which is set once and finally during the
88+
* construction of the tracker.
89+
*
90+
* Use the same units for the data points provided. For example, having some data points in `cm`
91+
* and some in `m` will result in incorrect velocity calculations, as this method (and the
92+
* tracker) has no knowledge of the units used.
93+
*/
94+
fun addDataPoint(timeMillis: Long, dataPoint: Float) {
95+
index = (index + 1) % HistorySize
96+
samples.set(index, timeMillis, dataPoint)
97+
}
98+
99+
/**
100+
* Computes the estimated velocity at the time of the last provided data point.
101+
*
102+
* The units of velocity will be `units/second`, where `units` is the units of the data points
103+
* provided via [addDataPoint].
104+
*
105+
* This can be expensive. Only call this when you need the velocity.
106+
*/
107+
fun calculateVelocity(): Float {
108+
val dataPoints = reusableDataPointsArray
109+
val time = reusableTimeArray
110+
var sampleCount = 0
111+
var index: Int = index
112+
113+
// The sample at index is our newest sample. If it is null, we have no samples so return.
114+
val newestSample: DataPointAtTime = samples[index] ?: return 0f
115+
116+
var previousSample: DataPointAtTime = newestSample
117+
118+
// Starting with the most recent PointAtTime sample, iterate backwards while
119+
// the samples represent continuous motion.
120+
do {
121+
val sample: DataPointAtTime = samples[index] ?: break
122+
123+
val age: Float = (newestSample.time - sample.time).toFloat()
124+
val delta: Float = abs(sample.time - previousSample.time).toFloat()
125+
previousSample =
126+
if (strategy == Strategy.Lsq2 || isDataDifferential) {
127+
sample
128+
} else {
129+
newestSample
130+
}
131+
if (age > HorizonMilliseconds || delta > AssumePointerMoveStoppedMilliseconds) {
132+
break
133+
}
134+
135+
dataPoints[sampleCount] = sample.dataPoint
136+
time[sampleCount] = -age
137+
index = (if (index == 0) HistorySize else index) - 1
138+
139+
sampleCount += 1
140+
} while (sampleCount < HistorySize)
141+
142+
sampleCount = adjustDataPointsIfNeeded(sampleCount)
143+
144+
if (sampleCount >= minSampleSize) {
145+
// Choose computation logic based on strategy.
146+
val velocity = when (strategy) {
147+
Strategy.Impulse -> {
148+
calculateImpulseVelocity(dataPoints, time, sampleCount, isDataDifferential)
149+
}
150+
Strategy.Lsq2 -> {
151+
calculateLeastSquaresVelocity(dataPoints, time, sampleCount)
152+
}
153+
} * 1000 // Multiply by "1000" to convert from units/ms to units/s
154+
155+
if (preventOppositeVelocity) {
156+
if (dataPoints[sampleCount - 1] < dataPoints[0] && velocity < 0) {
157+
return 0f
158+
}
159+
if (dataPoints[sampleCount - 1] > dataPoints[0] && velocity > 0) {
160+
return 0f
161+
}
162+
}
163+
return velocity
164+
}
165+
166+
// We're unable to make a velocity estimate but we did have at least one
167+
// valid pointer position.
168+
return 0f
169+
}
170+
171+
/**
172+
* Adjusts the data points in the reusable arrays if needed, based on a particular strategy and
173+
* the provided sample count. This is primarily used to fix cases when the Lsq2 returns an opposite
174+
* direction of velocity for a small sample count.
175+
*
176+
* If the selected strategy is not `Strategy.Lsq2`, the method returns the original sample
177+
* count without any modification. For `Strategy.Lsq2`, it ensures that there are at least
178+
* three data points by modifying the reusable arrays as necessary.
179+
*
180+
* @param sampleCount The number of data points currently available for velocity calculation.
181+
* @return The updated sample count after adjustments, if performed. If no adjustments are
182+
* needed, the original sample count is returned.
183+
*/
184+
private fun adjustDataPointsIfNeeded(sampleCount: Int): Int {
185+
if (strategy != Strategy.Lsq2) return sampleCount
186+
if (sampleCount > 3) return sampleCount
187+
if (sampleCount < 2) return sampleCount
188+
189+
val firstPoint = reusableDataPointsArray[0]
190+
val firstTime = reusableTimeArray[0]
191+
192+
val lastPoint = reusableDataPointsArray[sampleCount - 1]
193+
val lastTime = reusableTimeArray[sampleCount - 1]
194+
195+
reusableDataPointsArray[1] = (firstPoint + 2 * lastPoint) / 3f
196+
reusableTimeArray[1] = (2 * firstTime + lastTime) / 3f
197+
198+
reusableDataPointsArray[2] = lastPoint
199+
reusableTimeArray[2] = lastTime
200+
201+
return 3
202+
}
203+
204+
/**
205+
* Computes the estimated velocity at the time of the last provided data point.
206+
*
207+
* The method allows specifying the maximum absolute value for the calculated velocity. If the
208+
* absolute value of the calculated velocity exceeds the specified maximum, the return value
209+
* will be clamped down to the maximum. For example, if the absolute maximum velocity is
210+
* specified as "20", a calculated velocity of "25" will be returned as "20", and a velocity of
211+
* "-30" will be returned as "-20".
212+
*
213+
* @param maximumVelocity the absolute value of the maximum velocity to be returned in
214+
* units/second, where `units` is the units of the positions provided to this VelocityTracker.
215+
*/
216+
fun calculateVelocity(maximumVelocity: Float): Float {
217+
checkPrecondition(maximumVelocity > 0f) {
218+
"maximumVelocity should be a positive value. You specified=$maximumVelocity"
219+
}
220+
val velocity = calculateVelocity()
221+
222+
return if (velocity == 0.0f || velocity.isNaN()) {
223+
0.0f
224+
} else if (velocity > 0) {
225+
velocity.coerceAtMost(maximumVelocity)
226+
} else {
227+
velocity.coerceAtLeast(-maximumVelocity)
228+
}
229+
}
230+
231+
/** Clears data points added by [addDataPoint]. */
232+
fun resetTracking() {
233+
samples.fill(element = null)
234+
index = 0
235+
}
236+
237+
/**
238+
* Calculates velocity based on [Strategy.Lsq2]. The provided [time] entries are in "ms", and
239+
* should be provided in reverse chronological order. The returned velocity is in "units/ms",
240+
* where "units" is unit of the [dataPoints].
241+
*/
242+
private fun calculateLeastSquaresVelocity(
243+
dataPoints: FloatArray,
244+
time: FloatArray,
245+
sampleCount: Int,
246+
): Float {
247+
// The 2nd coefficient is the derivative of the quadratic polynomial at
248+
// x = 0, and that happens to be the last timestamp that we end up
249+
// passing to polyFitLeastSquares.
250+
return try {
251+
polyFitLeastSquares(time, dataPoints, sampleCount, 2, reusableVelocityCoefficients)[1]
252+
} catch (_: IllegalArgumentException) {
253+
0f
254+
}
255+
}
256+
}
257+
258+
private fun Array<DataPointAtTime?>.set(index: Int, time: Long, dataPoint: Float) {
259+
val currentEntry = this[index]
260+
if (currentEntry == null) {
261+
this[index] = DataPointAtTime(time, dataPoint)
262+
} else {
263+
currentEntry.time = time
264+
currentEntry.dataPoint = dataPoint
265+
}
266+
}
267+
268+
private fun calculateImpulseVelocity(
269+
dataPoints: FloatArray,
270+
time: FloatArray,
271+
sampleCount: Int,
272+
isDataDifferential: Boolean,
273+
): Float {
274+
var work = 0f
275+
val start = sampleCount - 1
276+
var nextTime = time[start]
277+
for (i in start downTo 1) {
278+
val currentTime = nextTime
279+
nextTime = time[i - 1]
280+
if (currentTime == nextTime) {
281+
continue
282+
}
283+
val dataPointsDelta =
284+
if (isDataDifferential) -dataPoints[i - 1] else dataPoints[i] - dataPoints[i - 1]
285+
val vCurr = dataPointsDelta / (currentTime - nextTime)
286+
val vPrev = kineticEnergyToVelocity(work)
287+
work += (vCurr - vPrev) * abs(vCurr)
288+
if (i == start) {
289+
work = (work * 0.5f)
290+
}
291+
}
292+
return kineticEnergyToVelocity(work)
293+
}
294+
@Suppress("NOTHING_TO_INLINE")
295+
private inline fun kineticEnergyToVelocity(kineticEnergy: Float): Float {
296+
return sign(kineticEnergy) * sqrt(2 * abs(kineticEnergy))
297+
}
298+
299+
private typealias TempMatrix = Array<FloatArray>
300+
301+
@Suppress("NOTHING_TO_INLINE")
302+
private inline operator fun TempMatrix.get(row: Int, col: Int): Float = this[row][col]
303+
304+
@Suppress("NOTHING_TO_INLINE")
305+
private inline operator fun TempMatrix.set(row: Int, col: Int, value: Float) {
306+
this[row][col] = value
307+
}

0 commit comments

Comments
 (0)