Create Fallback Velocity Tracking for AXIS_SCROLL
This change adds a fallback AXIS_SCROLL velocity tracking logic, to
support the axis' velocity tracking for pre-Android-U versions.
To be able to fully offload the velocity tracking work for this AXIS to
the fallback tracker, we also add compat APIs for some VelocityTracker
APIs.
RelNote: adds more compat APIs for Velocity Tracker
Bug: 294895104
Test: manual, unit tests
Change-Id: I327530551bbae8e4594d1b081ce3277bc60efe57
diff --git a/core/core/api/current.txt b/core/core/api/current.txt
index 7ff5c2a..423faf9 100644
--- a/core/core/api/current.txt
+++ b/core/core/api/current.txt
@@ -2885,11 +2885,16 @@
}
public final class VelocityTrackerCompat {
+ method public static void addMovement(android.view.VelocityTracker, android.view.MotionEvent);
+ method public static void clear(android.view.VelocityTracker);
+ method public static void computeCurrentVelocity(android.view.VelocityTracker, int);
+ method public static void computeCurrentVelocity(android.view.VelocityTracker, int, float);
method public static float getAxisVelocity(android.view.VelocityTracker, int);
method public static float getAxisVelocity(android.view.VelocityTracker, int, int);
method @Deprecated public static float getXVelocity(android.view.VelocityTracker!, int);
method @Deprecated public static float getYVelocity(android.view.VelocityTracker!, int);
method public static boolean isAxisSupported(android.view.VelocityTracker, int);
+ method public static void recycle(android.view.VelocityTracker);
}
public class ViewCompat {
diff --git a/core/core/api/restricted_current.txt b/core/core/api/restricted_current.txt
index 7be35d5..8372c33 100644
--- a/core/core/api/restricted_current.txt
+++ b/core/core/api/restricted_current.txt
@@ -3334,11 +3334,16 @@
}
public final class VelocityTrackerCompat {
+ method public static void addMovement(android.view.VelocityTracker, android.view.MotionEvent);
+ method public static void clear(android.view.VelocityTracker);
+ method public static void computeCurrentVelocity(android.view.VelocityTracker, int);
+ method public static void computeCurrentVelocity(android.view.VelocityTracker, int, float);
method public static float getAxisVelocity(android.view.VelocityTracker, @androidx.core.view.VelocityTrackerCompat.VelocityTrackableMotionEventAxis int);
method public static float getAxisVelocity(android.view.VelocityTracker, @androidx.core.view.VelocityTrackerCompat.VelocityTrackableMotionEventAxis int, int);
method @Deprecated public static float getXVelocity(android.view.VelocityTracker!, int);
method @Deprecated public static float getYVelocity(android.view.VelocityTracker!, int);
method public static boolean isAxisSupported(android.view.VelocityTracker, @androidx.core.view.VelocityTrackerCompat.VelocityTrackableMotionEventAxis int);
+ method public static void recycle(android.view.VelocityTracker);
}
@IntDef({android.view.MotionEvent.AXIS_X, android.view.MotionEvent.AXIS_Y, android.view.MotionEvent.AXIS_SCROLL}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) public static @interface VelocityTrackerCompat.VelocityTrackableMotionEventAxis {
diff --git a/core/core/src/androidTest/java/androidx/core/view/VelocityTrackerCompatTest.java b/core/core/src/androidTest/java/androidx/core/view/VelocityTrackerCompatTest.java
index bb8d29a..8365528 100644
--- a/core/core/src/androidTest/java/androidx/core/view/VelocityTrackerCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/view/VelocityTrackerCompatTest.java
@@ -24,6 +24,7 @@
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue;
import android.os.Build;
@@ -60,7 +61,7 @@
* is added to a tracker. For velocities to be non-zero, we should generally have 2/3 movements,
* so 4 is a good value to use.
*/
- private static final int NUM_MOVEMENTS = 4;
+ private static final int NUM_MOVEMENTS = 8;
private VelocityTracker mPlanarTracker;
private VelocityTracker mScrollTracker;
@@ -97,8 +98,14 @@
addScrollMotionEvent(2, time, scrollPointer2);
}
- mPlanarTracker.computeCurrentVelocity(1000);
- mScrollTracker.computeCurrentVelocity(1000);
+ // Assert that all velocity is 0 before compute is called.
+ assertEquals(0, VelocityTrackerCompat.getAxisVelocity(mPlanarTracker, AXIS_X), 0);
+ assertEquals(0, VelocityTrackerCompat.getAxisVelocity(mPlanarTracker, AXIS_Y), 0);
+ assertEquals(
+ 0, VelocityTrackerCompat.getAxisVelocity(mScrollTracker, AXIS_SCROLL), 0);
+
+ VelocityTrackerCompat.computeCurrentVelocity(mPlanarTracker, 1000);
+ VelocityTrackerCompat.computeCurrentVelocity(mScrollTracker, 1000);
}
@Test
@@ -108,15 +115,12 @@
}
@Test
- public void testIsAxisSupported_nonPlanarAxes() {
- if (Build.VERSION.SDK_INT >= 34) {
- assertTrue(
- VelocityTrackerCompat.isAxisSupported(VelocityTracker.obtain(), AXIS_SCROLL));
- } else {
- assertFalse(
- VelocityTrackerCompat.isAxisSupported(VelocityTracker.obtain(), AXIS_SCROLL));
- }
+ public void testIsAxisSupported_axisScroll() {
+ assertTrue(VelocityTrackerCompat.isAxisSupported(VelocityTracker.obtain(), AXIS_SCROLL));
+ }
+ @Test
+ public void testIsAxisSupported_nonPlanarAxes() {
// Check against an axis that has not yet been supported at any Android version.
assertFalse(VelocityTrackerCompat.isAxisSupported(VelocityTracker.obtain(), AXIS_BRAKE));
}
@@ -167,14 +171,18 @@
@Test
public void testGetAxisVelocity_axisScroll_noPointerId() {
- float compatScrollVelocity =
- VelocityTrackerCompat.getAxisVelocity(mScrollTracker, AXIS_SCROLL);
+ float compatVelocity = VelocityTrackerCompat.getAxisVelocity(mScrollTracker, AXIS_SCROLL);
- if (Build.VERSION.SDK_INT >= 34) {
- assertEquals(mScrollTracker.getAxisVelocity(AXIS_SCROLL), compatScrollVelocity, 0);
- } else {
- assertEquals(0, compatScrollVelocity, 0);
- }
+ assertEquals(SCROLL_VEL_POINTER_ID_1 * 1000, compatVelocity, 0);
+
+ compatVelocity = VelocityTrackerCompat.getAxisVelocity(mScrollTracker, AXIS_SCROLL);
+
+ assertEquals(SCROLL_VEL_POINTER_ID_1 * 1000, compatVelocity, 0);
+
+ VelocityTrackerCompat.clear(mScrollTracker);
+ compatVelocity = VelocityTrackerCompat.getAxisVelocity(mScrollTracker, AXIS_SCROLL);
+
+ assertEquals(0, compatVelocity, 0);
}
@Test
@@ -183,6 +191,7 @@
VelocityTrackerCompat.getAxisVelocity(mScrollTracker, AXIS_SCROLL, 2);
if (Build.VERSION.SDK_INT >= 34) {
+ assertNotEquals(0, compatScrollVelocity);
assertEquals(mScrollTracker.getAxisVelocity(AXIS_SCROLL, 2), compatScrollVelocity, 0);
} else {
assertEquals(0, compatScrollVelocity, 0);
@@ -192,7 +201,7 @@
private void addPlanarMotionEvent(int pointerId, long time, float x, float y) {
MotionEvent ev = MotionEvent.obtain(0L, time, MotionEvent.ACTION_MOVE, x, y, 0);
- mPlanarTracker.addMovement(ev);
+ VelocityTrackerCompat.addMovement(mPlanarTracker, ev);
ev.recycle();
}
private void addScrollMotionEvent(int pointerId, long time, float scrollAmount) {
@@ -216,7 +225,7 @@
0 /* edgeFlags */,
InputDevice.SOURCE_ROTARY_ENCODER,
0 /* flags */);
- mScrollTracker.addMovement(ev);
+ VelocityTrackerCompat.addMovement(mScrollTracker, ev);
ev.recycle();
}
}
diff --git a/core/core/src/androidTest/java/androidx/core/view/VelocityTrackerFallbackTest.java b/core/core/src/androidTest/java/androidx/core/view/VelocityTrackerFallbackTest.java
new file mode 100644
index 0000000..11ec1a4
--- /dev/null
+++ b/core/core/src/androidTest/java/androidx/core/view/VelocityTrackerFallbackTest.java
@@ -0,0 +1,261 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.view;
+
+import static androidx.core.view.MotionEventCompat.AXIS_SCROLL;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.view.InputDevice;
+import android.view.MotionEvent;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit tests for {@link VelocityTrackerFallback}. */
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class VelocityTrackerFallbackTest {
+
+ private static final float TOLERANCE = 0.05f; // 5% tolerance
+
+ private long mTime;
+ private long mLastTime;
+ private float mScrollAmount;
+ private float mVelocity;
+ private float mAcceleration;
+
+ private VelocityTrackerFallback mTracker;
+
+ @Before
+ public void setup() {
+ mTracker = new VelocityTrackerFallback();
+ }
+
+ @Test
+ public void testLinearMovement() {
+ mVelocity = 3.0f;
+ move(100, 10);
+
+ mTracker.computeCurrentVelocity(/* units= */ 1);
+
+ assertVelocityWithTolerance(mTracker.getAxisVelocity(AXIS_SCROLL), mVelocity);
+ }
+
+ @Test
+ public void testAcceleratingMovement() {
+ mVelocity = 3.0f;
+ mAcceleration = 2.0f;
+ move(200, 10);
+
+ mTracker.computeCurrentVelocity(/* units= */ 1);
+
+ assertVelocityWithTolerance(mTracker.getAxisVelocity(AXIS_SCROLL), mVelocity);
+ }
+
+ @Test
+ public void testDeceleratingMovement() {
+ mVelocity = 7.0f;
+ mAcceleration = -2.0f;
+ move(200, 10);
+
+ mTracker.computeCurrentVelocity(/* units= */ 1);
+
+ assertVelocityWithTolerance(mTracker.getAxisVelocity(AXIS_SCROLL), mVelocity);
+ }
+
+ @Test
+ public void testNegativeVelocity() {
+ mVelocity = -3.0f;
+ move(100, 10);
+
+ mTracker.computeCurrentVelocity(/* units= */ 1);
+
+ assertVelocityWithTolerance(mTracker.getAxisVelocity(AXIS_SCROLL), mVelocity);
+ }
+
+ @Test
+ public void testZeroVelocity() {
+ mVelocity = 0f;
+ move(100, 10);
+
+ mTracker.computeCurrentVelocity(/* units= */ 1);
+
+ assertVelocityWithTolerance(mTracker.getAxisVelocity(AXIS_SCROLL), mVelocity);
+ }
+
+ @Test
+ public void testVelocityDataExpiration() {
+ mVelocity = 20f;
+ move(100, 10);
+ mVelocity = 200f;
+ mTime += 10000;
+ move(10, 10);
+
+ mTracker.computeCurrentVelocity(/* units= */ 1);
+
+ assertThat(mTracker.getAxisVelocity(AXIS_SCROLL)).isEqualTo(200);
+ }
+
+ @Test
+ public void testOneDataPoint() {
+ mVelocity = 30f;
+ move(100, 100);
+
+ mTracker.computeCurrentVelocity(/* units= */ 1);
+
+ assertThat(mTracker.getAxisVelocity(AXIS_SCROLL)).isZero();
+ }
+
+ @Test
+ public void testTwoDataPoints() {
+ mVelocity = 30f;
+ move(100, 30);
+
+ mTracker.computeCurrentVelocity(/* units= */ 1);
+
+ assertVelocityWithTolerance(mTracker.getAxisVelocity(AXIS_SCROLL), mVelocity);
+ }
+
+ @Test
+ public void testPointerMovementStopped() {
+ mVelocity = 30f;
+ move(50, 2);
+ // Add one last event, where the time-step (50ms) exceeds 40ms, which is the pointer stopped
+ // assumption duration.
+ move(50, 50);
+
+ mTracker.computeCurrentVelocity(/* units= */ 1);
+
+ assertThat(mTracker.getAxisVelocity(AXIS_SCROLL)).isZero();
+ }
+
+ @Test
+ public void testUnits() {
+ mVelocity = 3.0f;
+ move(100, 10);
+
+ mTracker.computeCurrentVelocity(/* units= */ 100);
+
+ assertVelocityWithTolerance(mTracker.getAxisVelocity(AXIS_SCROLL), 100 * mVelocity);
+ }
+
+ @Test
+ public void testMaxVelocity() {
+ mVelocity = 3.0f;
+ move(100, 10);
+
+ mTracker.computeCurrentVelocity(/* units= */ 1, /* maxVelocity= */ 2.4f);
+
+ assertThat(mTracker.getAxisVelocity(AXIS_SCROLL)).isEqualTo(2.4f);
+ }
+
+ @Test
+ public void testUnitsWithMaxVelocity() {
+ mVelocity = 3.0f;
+ move(100, 10);
+
+ mTracker.computeCurrentVelocity(/* units= */ 100, /* maxVelocity= */ 75f);
+
+ assertThat(mTracker.getAxisVelocity(AXIS_SCROLL)).isEqualTo(75f);
+ }
+
+ @Test
+ public void testNoMovement() {
+ assertThat(new VelocityTrackerFallback().getAxisVelocity(AXIS_SCROLL)).isZero();
+ }
+
+ @Test
+ public void testTwoMovementsWithSameTime() {
+ addMovement(/* scrollAmount= */ 2, /* time= */ 10);
+ addMovement(/* scrollAmount= */ 200, /* time= */ 10);
+
+ mTracker.computeCurrentVelocity(/* units= */ 1);
+
+ assertThat(mTracker.getAxisVelocity(AXIS_SCROLL)).isZero();
+ }
+
+ @Test
+ public void testMoreDataPointsThanHistorySize() {
+ mVelocity = 7.0f;
+ mAcceleration = 2.0f;
+ move(300, 10); // 30 data points
+
+ mTracker.computeCurrentVelocity(/* units= */ 1);
+
+ assertVelocityWithTolerance(mTracker.getAxisVelocity(AXIS_SCROLL), mVelocity);
+ }
+
+ private void move(long duration, long step) {
+ addMovement();
+ while (duration > 0) {
+ duration -= step;
+ mTime += step;
+
+ mScrollAmount = (mAcceleration / 2 * step + mVelocity) * step;
+ mVelocity += mAcceleration * step;
+ addMovement();
+ }
+ }
+
+ private void addMovement() {
+ if (mTime <= mLastTime) {
+ return;
+ }
+ addMovement(mScrollAmount, mTime);
+ mLastTime = mTime;
+ }
+
+ private void addMovement(float scrollAmount, long time) {
+ MotionEvent.PointerProperties[] props =
+ new MotionEvent.PointerProperties[] {new MotionEvent.PointerProperties()};
+ props[0].id = 0;
+
+ MotionEvent.PointerCoords[] coords =
+ new MotionEvent.PointerCoords[] {new MotionEvent.PointerCoords()};
+ coords[0].setAxisValue(MotionEvent.AXIS_SCROLL, scrollAmount);
+
+ MotionEvent ev =
+ MotionEvent.obtain(
+ /* downTime= */ 0,
+ time,
+ MotionEvent.ACTION_SCROLL,
+ /* pointerCount= */ 1,
+ props,
+ coords,
+ /* metaState= */ 0,
+ /* buttonState= */ 0,
+ /* xPrecision= */ 0,
+ /* yPrecision= */ 0,
+ /* deviceId= */ 1,
+ /* edgeFlags= */ 0,
+ InputDevice.SOURCE_ROTARY_ENCODER,
+ /* flags= */ 0);
+ mTracker.addMovement(ev);
+ ev.recycle();
+ }
+
+ private void assertVelocityWithTolerance(float actualVelocity, float expectedVelocity) {
+ assertThat(actualVelocity)
+ .isWithin(Math.abs(TOLERANCE * expectedVelocity))
+ .of(expectedVelocity);
+ }
+}
diff --git a/core/core/src/main/java/androidx/core/view/VelocityTrackerCompat.java b/core/core/src/main/java/androidx/core/view/VelocityTrackerCompat.java
index 23fb371..4bfec10 100644
--- a/core/core/src/main/java/androidx/core/view/VelocityTrackerCompat.java
+++ b/core/core/src/main/java/androidx/core/view/VelocityTrackerCompat.java
@@ -21,16 +21,21 @@
import static java.lang.annotation.RetentionPolicy.SOURCE;
import android.os.Build;
+import android.view.InputDevice;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import androidx.annotation.DoNotInline;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.RestrictTo;
import java.lang.annotation.Retention;
+import java.util.Collections;
+import java.util.Map;
+import java.util.WeakHashMap;
/** Helper for accessing features in {@link VelocityTracker}. */
public final class VelocityTrackerCompat {
@@ -42,6 +47,37 @@
MotionEvent.AXIS_SCROLL
})
public @interface VelocityTrackableMotionEventAxis {}
+
+ /**
+ * Mapping of platform velocity trackers to their respective fallback.
+ *
+ * <p>This mapping is used to provide a consistent add/clear/getVelocity experience for axes
+ * that may not be supported at a given Android version. Clients can continue to call the
+ * compat's add/clear/compute/getVelocity with the platform tracker instances, and this class
+ * will assign a "fallback" tracker instance for each unique platform tracker instance to
+ * consistently run these operations just as they would run on the platorm instances.
+ *
+ * <p>Since the compat APIs have been provided statically, we will use a singleton compat
+ * instance to manage the mappings whenever we need a "fallback" handling for velocity.
+ *
+ * <p>High level flow for a compat velocity logic for a platform-unsupported axis "A" looks
+ * as follows:
+ * [1]. add(platformTracker, event):
+ * [a] Create fallback tracker, and associate it with "platformTracker`.
+ * [b] Add `event` to the fallback tracker.
+ * [2]. computeCurrentVelocity(platformTracker, event):
+ * [a] If there is no associated fallback tracker for `platformTracker`, exit.
+ * [b] If there's a fallback, compute current velocity for the fallback.
+ * [3]. getAxisVelocity(platformTracker, axis):
+ * [a] If there is no associated fallback tracker for `platformTracker`, exit.
+ * [b] If there's a fallback, return the velocity from the fallback.
+ * [4]. clear/recycle(platformTracker)
+ * [a] Remove any association between `platformTracker` and a fallback tracker.
+ *
+ */
+ private static Map<VelocityTracker, VelocityTrackerFallback> sFallbackTrackers =
+ Collections.synchronizedMap(new WeakHashMap<>());
+
/**
* Call {@link VelocityTracker#getXVelocity(int)}.
* If running on a pre-{@link Build.VERSION_CODES#HONEYCOMB} device,
@@ -87,7 +123,9 @@
if (Build.VERSION.SDK_INT >= 34) {
return Api34Impl.isAxisSupported(tracker, axis);
}
- return axis == MotionEvent.AXIS_X || axis == MotionEvent.AXIS_Y;
+ return axis == MotionEvent.AXIS_SCROLL // Supported via VelocityTrackerFallback.
+ || axis == MotionEvent.AXIS_X // Supported by platform at all API levels.
+ || axis == MotionEvent.AXIS_Y; // Supported by platform at all API levels.
}
/**
@@ -107,12 +145,22 @@
if (Build.VERSION.SDK_INT >= 34) {
return Api34Impl.getAxisVelocity(tracker, axis);
}
+
+ // For X and Y axes, use the `get*Velocity` APIs that existed at all API levels.
if (axis == MotionEvent.AXIS_X) {
return tracker.getXVelocity();
}
if (axis == MotionEvent.AXIS_Y) {
return tracker.getYVelocity();
}
+
+ // For any other axis before API 34, use the corresponding VelocityTrackerFallback, if any,
+ // to determine the velocity.
+ VelocityTrackerFallback fallback = getFallbackTrackerOrNull(tracker);
+ if (fallback != null) {
+ return fallback.getAxisVelocity(axis);
+ }
+
return 0;
}
@@ -125,8 +173,9 @@
* supported since the introduction of this class, the following axes can be candidates for this
* method:
* <ul>
- * <li> {@link MotionEvent#AXIS_SCROLL}: supported starting
- * {@link Build.VERSION_CODES#UPSIDE_DOWN_CAKE}
+ * <li> {@link MotionEvent#AXIS_SCROLL}: supported via the platform starting
+ * {@link Build.VERSION_CODES#UPSIDE_DOWN_CAKE}. Supported via a fallback logic at all
+ * platform levels for the active pointer only.
* </ul>
*
* <p>Before accessing velocities of an axis using this method, check that your
@@ -155,7 +204,92 @@
return tracker.getYVelocity(pointerId);
}
return 0;
+ }
+ /** Reset the velocity tracker back to its initial state. */
+ public static void clear(@NonNull VelocityTracker tracker) {
+ tracker.clear();
+ removeFallbackForTracker(tracker);
+ }
+
+ /**
+ * Return a VelocityTracker object back to be re-used by others. You must not touch the object
+ * after calling this function. That is, don't call any methods on it, or pass it as an input to
+ * any of this class' compat APIs, as the instance is no longer valid for velocity tracking.
+ */
+ public static void recycle(@NonNull VelocityTracker tracker) {
+ tracker.recycle();
+ removeFallbackForTracker(tracker);
+ }
+
+ /**
+ * Compute the current velocity based on the points that have been
+ * collected. Only call this when you actually want to retrieve velocity
+ * information, as it is relatively expensive. You can then retrieve
+ * the velocity with {@link #getAxisVelocity(VelocityTracker, int)} ()}.
+ *
+ * @param tracker The {@link VelocityTracker} for which to compute velocity.
+ * @param units The units you would like the velocity in. A value of 1
+ * provides units per millisecond, 1000 provides units per second, etc.
+ * Note that the units referred to here are the same units with which motion is reported. For
+ * axes X and Y, the units are pixels.
+ * @param maxVelocity The maximum velocity that can be computed by this method.
+ * This value must be declared in the same unit as the units parameter. This value
+ * must be positive.
+ */
+ public static void computeCurrentVelocity(
+ @NonNull VelocityTracker tracker, int units, float maxVelocity) {
+ tracker.computeCurrentVelocity(units, maxVelocity);
+ VelocityTrackerFallback fallback = getFallbackTrackerOrNull(tracker);
+ if (fallback != null) {
+ fallback.computeCurrentVelocity(units, maxVelocity);
+ }
+ }
+
+ /**
+ * Equivalent to invoking {@link #computeCurrentVelocity(VelocityTracker, int, float)} with a
+ * maximum velocity of Float.MAX_VALUE.
+ */
+ public static void computeCurrentVelocity(@NonNull VelocityTracker tracker, int units) {
+ VelocityTrackerCompat.computeCurrentVelocity(tracker, units, Float.MAX_VALUE);
+ }
+
+ /**
+ * Add a user's movement to the tracker.
+ *
+ * <p>For pointer events, you should call this for the initial
+ * {@link MotionEvent#ACTION_DOWN}, the following
+ * {@link MotionEvent#ACTION_MOVE} events that you receive, and the final
+ * {@link MotionEvent#ACTION_UP}. You can, however, call this
+ * for whichever events you desire.
+ *
+ * @param tracker The {@link VelocityTracker} to add the movement to.
+ * @param event The MotionEvent you received and would like to track.
+ */
+ public static void addMovement(@NonNull VelocityTracker tracker, @NonNull MotionEvent event) {
+ tracker.addMovement(event);
+ if (Build.VERSION.SDK_INT >= 34) {
+ // For API levels 34 and above, we currently do not support any compat logic.
+ return;
+ }
+
+ if (event.getSource() == InputDevice.SOURCE_ROTARY_ENCODER) {
+ // We support compat logic for AXIS_SCROLL.
+ // Initialize the compat instance if needed.
+ if (!sFallbackTrackers.containsKey(tracker)) {
+ sFallbackTrackers.put(tracker, new VelocityTrackerFallback());
+ }
+ sFallbackTrackers.get(tracker).addMovement(event);
+ }
+ }
+
+ private static void removeFallbackForTracker(VelocityTracker tracker) {
+ sFallbackTrackers.remove(tracker);
+ }
+
+ @Nullable
+ private static VelocityTrackerFallback getFallbackTrackerOrNull(VelocityTracker tracker) {
+ return sFallbackTrackers.get(tracker);
}
@RequiresApi(34)
diff --git a/core/core/src/main/java/androidx/core/view/VelocityTrackerFallback.java b/core/core/src/main/java/androidx/core/view/VelocityTrackerFallback.java
new file mode 100644
index 0000000..3743472
--- /dev/null
+++ b/core/core/src/main/java/androidx/core/view/VelocityTrackerFallback.java
@@ -0,0 +1,182 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.core.view;
+
+import static androidx.core.view.MotionEventCompat.AXIS_SCROLL;
+
+import android.view.MotionEvent;
+
+import androidx.annotation.NonNull;
+
+/**
+ * A fallback implementation of {@link android.view.VelocityTracker}. The methods its provide
+ * mirror the platform's implementation.
+ *
+ * <p>It will be used to provide velocity tracking logic for certain axes that may not be
+ * supported at different API levels, so that {@link VelocityTrackerCompat} can provide compat
+ * service to its clients.
+ *
+ * <p>Currently, it supports AXIS_SCROLL with the default pointer ID.
+ */
+class VelocityTrackerFallback {
+ private static final long RANGE_MS = 100L;
+ private static final int HISTORY_SIZE = 20;
+ /**
+ * If there's no data beyond this period of time, we assume that the previous chain of motion
+ * from the pointer has stopped, and we handle subsequent data points separately.
+ */
+ private static final long ASSUME_POINTER_STOPPED_MS = 40L;
+
+ private final float[] mMovements = new float[HISTORY_SIZE];
+ private final long[] mEventTimes = new long[HISTORY_SIZE];
+
+ /** Cached value of the last computed velocity, for a O(1) get operation. */
+ private float mLastComputedVelocity = 0f;
+
+ /** Number of data points that are potential velocity calculation candidates. */
+ private int mDataPointsBufferSize = 0;
+ /**
+ * The last index in the circular buffer where a data point was added. Irrelevant if {@code
+ * dataPointsBufferSize} == 0.
+ */
+ private int mDataPointsBufferLastUsedIndex = 0;
+
+ /** Adds a motion for velocity tracking. */
+ void addMovement(@NonNull MotionEvent event) {
+ long eventTime = event.getEventTime();
+ if (mDataPointsBufferSize != 0
+ && (eventTime - mEventTimes[mDataPointsBufferLastUsedIndex]
+ > ASSUME_POINTER_STOPPED_MS)) {
+ // There has been at least `ASSUME_POINTER_STOPPED_MS` since the last recorded event.
+ // When this happens, consider that the pointer has stopped until this new event. Thus,
+ // clear all past events.
+ clear();
+ }
+
+ mDataPointsBufferLastUsedIndex = (mDataPointsBufferLastUsedIndex + 1) % HISTORY_SIZE;
+ // We do not need to increase size if the size is already `HISTORY_SIZE`, since we always
+ // will have at most `HISTORY_SIZE` data points stored, due to the circular buffer.
+ if (mDataPointsBufferSize != HISTORY_SIZE) {
+ mDataPointsBufferSize += 1;
+ }
+
+ mMovements[mDataPointsBufferLastUsedIndex] = event.getAxisValue(AXIS_SCROLL);
+ mEventTimes[mDataPointsBufferLastUsedIndex] = eventTime;
+ }
+
+ /** Same as {@link #computeCurrentVelocity} with {@link Float#MAX_VALUE} as the max velocity. */
+ void computeCurrentVelocity(int units) {
+ computeCurrentVelocity(units, Float.MAX_VALUE);
+ }
+
+ /** Computes the current velocity with the given unit and max velocity. */
+ void computeCurrentVelocity(int units, float maxVelocity) {
+ mLastComputedVelocity = getCurrentVelocity() * units;
+
+ // Fix the velocity as per the max velocity
+ // (i.e. clamp it between [-maxVelocity, maxVelocity])
+ if (mLastComputedVelocity < -Math.abs(maxVelocity)) {
+ mLastComputedVelocity = -Math.abs(maxVelocity);
+ } else if (mLastComputedVelocity > Math.abs(maxVelocity)) {
+ mLastComputedVelocity = Math.abs(maxVelocity);
+ }
+ }
+
+ /** Returns the computed velocity for the given {@code axis}. */
+ float getAxisVelocity(int axis) {
+ if (axis != AXIS_SCROLL) {
+ return 0;
+ }
+ return mLastComputedVelocity;
+ }
+
+ private void clear() {
+ mDataPointsBufferSize = 0;
+ mLastComputedVelocity = 0;
+ }
+
+ private float getCurrentVelocity() {
+ // At least 2 data points needed to get Impulse velocity.
+ if (mDataPointsBufferSize < 2) {
+ return 0f;
+ }
+
+ // The first valid index that contains a data point that should be part of the velocity
+ // calculation, as long as it's within `RANGE_MS` from the latest data point.
+ int firstValidIndex =
+ (mDataPointsBufferLastUsedIndex + HISTORY_SIZE - (mDataPointsBufferSize - 1))
+ % HISTORY_SIZE;
+ long lastEventTime = mEventTimes[mDataPointsBufferLastUsedIndex];
+ while (lastEventTime - mEventTimes[firstValidIndex] > RANGE_MS) {
+ // Decrementing the size is equivalent to practically "removing" this data point.
+ mDataPointsBufferSize--;
+ // Increment the `firstValidIndex`, since we just found out that the current
+ // `firstValidIndex` is not valid (not within `RANGE_MS`).
+ firstValidIndex = (firstValidIndex + 1) % HISTORY_SIZE;
+ }
+
+ // At least 2 data points needed to get Impulse velocity.
+ if (mDataPointsBufferSize < 2) {
+ return 0;
+ }
+
+ if (mDataPointsBufferSize == 2) {
+ int lastIndex = (firstValidIndex + 1) % HISTORY_SIZE;
+ if (mEventTimes[firstValidIndex] == mEventTimes[lastIndex]) {
+ return 0f;
+ }
+ return mMovements[lastIndex] / (mEventTimes[lastIndex] - mEventTimes[firstValidIndex]);
+ }
+
+ float work = 0;
+ int numDataPointsProcessed = 0;
+ // Loop from the `firstValidIndex`, to the "second to last" valid index. We need to go only
+ // to the "second to last" element, since the body of the loop checks against the next data
+ // point, so we cannot go all the way to the end.
+ for (int i = 0; i < mDataPointsBufferSize - 1; i++) {
+ int currentIndex = i + firstValidIndex;
+ long eventTime = mEventTimes[currentIndex % HISTORY_SIZE];
+ int nextIndex = (currentIndex + 1) % HISTORY_SIZE;
+
+ // Duplicate timestamp. Skip this data point.
+ if (mEventTimes[nextIndex] == eventTime) {
+ continue;
+ }
+
+ numDataPointsProcessed++;
+ float vPrev = kineticEnergyToVelocity(work);
+ float delta = mMovements[nextIndex];
+ float vCurr = delta / (mEventTimes[nextIndex] - eventTime);
+
+ work += (vCurr - vPrev) * Math.abs(vCurr);
+
+ // Note that we are intentionally checking against `numDataPointsProcessed`, instead of
+ // just checking `i` against `firstValidIndex`. This is to cover cases where there are
+ // multiple data points that have the same timestamp as the one at `firstValidIndex`.
+ if (numDataPointsProcessed == 1) {
+ work = work * 0.5f;
+ }
+ }
+
+ return kineticEnergyToVelocity(work);
+ }
+
+ /** Based on the formula: Kinetic Energy = (0.5 * mass * velocity^2), with mass = 1. */
+ private static float kineticEnergyToVelocity(float work) {
+ return (work < 0 ? -1.0f : 1.0f) * (float) Math.sqrt(2f * Math.abs(work));
+ }
+}
diff --git a/recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/DifferentialMotionFlingHelper.java b/recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/DifferentialMotionFlingHelper.java
index f7abfd5..1fae9e2 100644
--- a/recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/DifferentialMotionFlingHelper.java
+++ b/recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/DifferentialMotionFlingHelper.java
@@ -233,8 +233,8 @@
}
private static float getCurrentVelocity(VelocityTracker vt, MotionEvent event, int axis) {
- vt.addMovement(event);
- vt.computeCurrentVelocity(1000);
+ VelocityTrackerCompat.addMovement(vt, event);
+ VelocityTrackerCompat.computeCurrentVelocity(vt, 1000);
return VelocityTrackerCompat.getAxisVelocity(vt, axis);
}
}