|
| 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