summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--core/java/android/widget/AbsListView.java49
-rw-r--r--core/java/android/widget/DifferentialMotionFlingHelper.java249
-rw-r--r--core/java/android/widget/ScrollView.java48
-rw-r--r--core/tests/coretests/src/android/widget/DifferentialMotionFlingHelperTest.java186
-rw-r--r--core/tests/coretests/src/android/widget/MotionEventUtils.java69
5 files changed, 593 insertions, 8 deletions
diff --git a/core/java/android/widget/AbsListView.java b/core/java/android/widget/AbsListView.java
index 6ad1960cbda9..03364b66b42e 100644
--- a/core/java/android/widget/AbsListView.java
+++ b/core/java/android/widget/AbsListView.java
@@ -916,6 +916,8 @@ public abstract class AbsListView extends AdapterView<ListAdapter> implements Te
}
}
+ private DifferentialMotionFlingHelper mDifferentialMotionFlingHelper;
+
public AbsListView(Context context) {
super(context);
setupDeviceConfigProperties();
@@ -4488,17 +4490,22 @@ public abstract class AbsListView extends AdapterView<ListAdapter> implements Te
public boolean onGenericMotionEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_SCROLL:
- final float axisValue;
+ final int axis;
if (event.isFromSource(InputDevice.SOURCE_CLASS_POINTER)) {
- axisValue = event.getAxisValue(MotionEvent.AXIS_VSCROLL);
+ axis = MotionEvent.AXIS_VSCROLL;
} else if (event.isFromSource(InputDevice.SOURCE_ROTARY_ENCODER)) {
- axisValue = event.getAxisValue(MotionEvent.AXIS_SCROLL);
+ axis = MotionEvent.AXIS_SCROLL;
} else {
- axisValue = 0;
+ axis = -1;
}
+ final float axisValue = (axis == -1) ? 0 : event.getAxisValue(axis);
final int delta = Math.round(axisValue * mVerticalScrollFactor);
if (delta != 0) {
+ // Tracks whether or not we should attempt fling for this event.
+ // Fling should not be attempted if the view is already at the limit of scroll,
+ // since it conflicts with EdgeEffect.
+ boolean shouldAttemptFling = true;
// If we're moving down, we want the top item. If we're moving up, bottom item.
final int motionIndex = delta > 0 ? 0 : getChildCount() - 1;
@@ -4511,6 +4518,10 @@ public abstract class AbsListView extends AdapterView<ListAdapter> implements Te
final int overscrollMode = getOverScrollMode();
if (!trackMotionScroll(delta, delta)) {
+ if (shouldAttemptFling) {
+ initDifferentialFlingHelperIfNotExists();
+ mDifferentialMotionFlingHelper.onMotionEvent(event, axis);
+ }
return true;
} else if (!event.isFromSource(InputDevice.SOURCE_MOUSE) && motionView != null
&& (overscrollMode == OVER_SCROLL_ALWAYS
@@ -4677,6 +4688,14 @@ public abstract class AbsListView extends AdapterView<ListAdapter> implements Te
}
}
+ private void initDifferentialFlingHelperIfNotExists() {
+ if (mDifferentialMotionFlingHelper == null) {
+ mDifferentialMotionFlingHelper =
+ new DifferentialMotionFlingHelper(
+ mContext, new DifferentialFlingTarget());
+ }
+ }
+
private void recycleVelocityTracker() {
if (mVelocityTracker != null) {
mVelocityTracker.recycle();
@@ -8197,4 +8216,26 @@ public abstract class AbsListView extends AdapterView<ListAdapter> implements Te
}
}
}
+
+ private class DifferentialFlingTarget
+ implements DifferentialMotionFlingHelper.DifferentialMotionFlingTarget {
+ @Override
+ public boolean startDifferentialMotionFling(float velocity) {
+ stopDifferentialMotionFling();
+ fling((int) velocity);
+ return true;
+ }
+
+ @Override
+ public void stopDifferentialMotionFling() {
+ if (mFlingRunnable != null) {
+ mFlingRunnable.endFling();
+ }
+ }
+
+ @Override
+ public float getScaledScrollFactor() {
+ return -mVerticalScrollFactor;
+ }
+ }
}
diff --git a/core/java/android/widget/DifferentialMotionFlingHelper.java b/core/java/android/widget/DifferentialMotionFlingHelper.java
new file mode 100644
index 000000000000..95d24ec31209
--- /dev/null
+++ b/core/java/android/widget/DifferentialMotionFlingHelper.java
@@ -0,0 +1,249 @@
+/*
+ * 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 android.widget;
+
+import android.annotation.Nullable;
+import android.content.Context;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+import android.view.ViewConfiguration;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+/**
+ * Helper for controlling differential motion flings.
+ *
+ * <p><b>Differential motion</b> here refers to motions that report change in position instead of
+ * absolution position. For instance, differential data points of 2, -1, 5 represent: there was
+ * a movement by "2" units, then by "-1" units, then by "5" units. Examples of motions reported
+ * differentially include motions from {@link MotionEvent#AXIS_SCROLL}.
+ *
+ * <p>The client should call {@link #onMotionEvent} when a differential motion event happens on
+ * the target View (that is, the View on which we want to fling), and this class processes the event
+ * to orchestrate fling.
+ *
+ * <p>Note that this helper class currently works to control fling only in one direction at a time.
+ * As such, it works independently of horizontal/vertical orientations. It requests its client to
+ * start/stop fling, and it's up to the client to choose the fling direction based on its specific
+ * internal configurations and/or preferences.
+ *
+ * @hide
+ */
+public class DifferentialMotionFlingHelper {
+ private final Context mContext;
+ private final DifferentialMotionFlingTarget mTarget;
+
+ private final FlingVelocityThresholdCalculator mVelocityThresholdCalculator;
+ private final DifferentialVelocityProvider mVelocityProvider;
+
+ @Nullable private VelocityTracker mVelocityTracker;
+
+ private float mLastFlingVelocity;
+
+ private int mLastProcessedAxis = -1;
+ private int mLastProcessedSource = -1;
+ private int mLastProcessedDeviceId = -1;
+
+ // Initialize min and max to +infinity and 0, to effectively disable fling at start.
+ private final int[] mFlingVelocityThresholds = new int[] {Integer.MAX_VALUE, 0};
+
+ /** Interface to calculate the fling velocity thresholds. Helps fake during testing. */
+ @VisibleForTesting
+ public interface FlingVelocityThresholdCalculator {
+ /**
+ * Calculates the fling velocity thresholds (in pixels/second) and puts them in a provided
+ * store.
+ *
+ * @param context the context associated with the View that may be flung.
+ * @param store an at-least size-2 int array. The method will overwrite positions 0 and 1
+ * with the min and max fling velocities, respectively.
+ * @param event the event that may trigger fling.
+ * @param axis the axis being processed for the event.
+ */
+ void calculateFlingVelocityThresholds(
+ Context context, int[] store, MotionEvent event, int axis);
+ }
+
+ /**
+ * Interface to provide velocity. Helps fake during testing.
+ *
+ * <p>The client should call {@link #getCurrentVelocity(VelocityTracker, MotionEvent, int)} each
+ * time it wants to consider a {@link MotionEvent} towards the latest velocity, and the
+ * interface handles providing velocity that accounts for the latest and all past events.
+ */
+ @VisibleForTesting
+ public interface DifferentialVelocityProvider {
+ /**
+ * Returns the latest velocity.
+ *
+ * @param vt the {@link VelocityTracker} to be used to compute velocity.
+ * @param event the latest event to be considered in the velocity computations.
+ * @param axis the axis being processed for the event.
+ * @return the calculated, latest velocity.
+ */
+ float getCurrentVelocity(VelocityTracker vt, MotionEvent event, int axis);
+ }
+
+ /**
+ * Represents an entity that may be flung by a differential motion or an entity that initiates
+ * fling on a target View.
+ */
+ public interface DifferentialMotionFlingTarget {
+ /**
+ * Start flinging on the target View by a given velocity.
+ *
+ * @param velocity the fling velocity, in pixels/second.
+ * @return {@code true} if fling was successfully initiated, {@code false} otherwise.
+ */
+ boolean startDifferentialMotionFling(float velocity);
+
+ /** Stop any ongoing fling on the target View that is caused by a differential motion. */
+ void stopDifferentialMotionFling();
+
+ /**
+ * Returns the scaled scroll factor to be used for differential motions. This is the
+ * value that the raw {@link MotionEvent} values should be multiplied with to get pixels.
+ *
+ * <p>This usually is one of the values provided by {@link ViewConfiguration}. It is
+ * up to the client to choose and provide any value as per its internal configuration.
+ *
+ * @see ViewConfiguration#getScaledHorizontalScrollFactor()
+ * @see ViewConfiguration#getScaledVerticalScrollFactor()
+ */
+ float getScaledScrollFactor();
+ }
+
+ /** Constructs an instance for a given {@link DifferentialMotionFlingTarget}. */
+ public DifferentialMotionFlingHelper(
+ Context context,
+ DifferentialMotionFlingTarget target) {
+ this(context,
+ target,
+ DifferentialMotionFlingHelper::calculateFlingVelocityThresholds,
+ DifferentialMotionFlingHelper::getCurrentVelocity);
+ }
+
+ @VisibleForTesting
+ public DifferentialMotionFlingHelper(
+ Context context,
+ DifferentialMotionFlingTarget target,
+ FlingVelocityThresholdCalculator velocityThresholdCalculator,
+ DifferentialVelocityProvider velocityProvider) {
+ mContext = context;
+ mTarget = target;
+ mVelocityThresholdCalculator = velocityThresholdCalculator;
+ mVelocityProvider = velocityProvider;
+ }
+
+ /**
+ * Called to report when a differential motion happens on the View that's the target for fling.
+ *
+ * @param event the {@link MotionEvent} being reported.
+ * @param axis the axis being processed by the target View.
+ */
+ public void onMotionEvent(MotionEvent event, int axis) {
+ boolean flingParamsChanged = calculateFlingVelocityThresholds(event, axis);
+ if (mFlingVelocityThresholds[0] == Integer.MAX_VALUE) {
+ // Integer.MAX_VALUE means that the device does not support fling for the current
+ // configuration. Do not proceed any further.
+ recycleVelocityTracker();
+ return;
+ }
+
+ float scaledVelocity =
+ getCurrentVelocity(event, axis) * mTarget.getScaledScrollFactor();
+
+ float velocityDirection = Math.signum(scaledVelocity);
+ // Stop ongoing fling if there has been state changes affecting fling, or if the current
+ // velocity (if non-zero) is opposite of the velocity that last caused fling.
+ if (flingParamsChanged
+ || (velocityDirection != Math.signum(mLastFlingVelocity)
+ && velocityDirection != 0)) {
+ mTarget.stopDifferentialMotionFling();
+ }
+
+ if (Math.abs(scaledVelocity) < mFlingVelocityThresholds[0]) {
+ return;
+ }
+
+ // Clamp the scaled velocity between [-max, max].
+ // e.g. if max=100, and vel=200
+ // vel = max(-100, min(200, 100)) = max(-100, 100) = 100
+ // e.g. if max=100, and vel=-200
+ // vel = max(-100, min(-200, 100)) = max(-100, -200) = -100
+ scaledVelocity =
+ Math.max(
+ -mFlingVelocityThresholds[1],
+ Math.min(scaledVelocity, mFlingVelocityThresholds[1]));
+
+ boolean flung = mTarget.startDifferentialMotionFling(scaledVelocity);
+ mLastFlingVelocity = flung ? scaledVelocity : 0;
+ }
+
+ /**
+ * Calculates fling velocity thresholds based on the provided event and axis, and returns {@code
+ * true} if there has been a change of any params that may affect fling velocity thresholds.
+ */
+ private boolean calculateFlingVelocityThresholds(MotionEvent event, int axis) {
+ int source = event.getSource();
+ int deviceId = event.getDeviceId();
+ if (mLastProcessedSource != source
+ || mLastProcessedDeviceId != deviceId
+ || mLastProcessedAxis != axis) {
+ mVelocityThresholdCalculator.calculateFlingVelocityThresholds(
+ mContext, mFlingVelocityThresholds, event, axis);
+ // Save data about this processing so that we don't have to re-process fling thresholds
+ // for similar parameters.
+ mLastProcessedSource = source;
+ mLastProcessedDeviceId = deviceId;
+ mLastProcessedAxis = axis;
+ return true;
+ }
+ return false;
+ }
+
+ private static void calculateFlingVelocityThresholds(
+ Context context, int[] buffer, MotionEvent event, int axis) {
+ int source = event.getSource();
+ int deviceId = event.getDeviceId();
+
+ ViewConfiguration vc = ViewConfiguration.get(context);
+ buffer[0] = vc.getScaledMinimumFlingVelocity(deviceId, axis, source);
+ buffer[1] = vc.getScaledMaximumFlingVelocity(deviceId, axis, source);
+ }
+
+ private float getCurrentVelocity(MotionEvent event, int axis) {
+ if (mVelocityTracker == null) {
+ mVelocityTracker = VelocityTracker.obtain();
+ }
+
+ return mVelocityProvider.getCurrentVelocity(mVelocityTracker, event, axis);
+ }
+
+ private void recycleVelocityTracker() {
+ if (mVelocityTracker != null) {
+ mVelocityTracker.recycle();
+ mVelocityTracker = null;
+ }
+ }
+
+ private static float getCurrentVelocity(VelocityTracker vt, MotionEvent event, int axis) {
+ vt.addMovement(event);
+ vt.computeCurrentVelocity(1000);
+ return vt.getAxisVelocity(axis);
+ }
+}
diff --git a/core/java/android/widget/ScrollView.java b/core/java/android/widget/ScrollView.java
index cb5dbe6c5618..eeb6b43a89f2 100644
--- a/core/java/android/widget/ScrollView.java
+++ b/core/java/android/widget/ScrollView.java
@@ -204,6 +204,8 @@ public class ScrollView extends FrameLayout {
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553)
private StrictMode.Span mFlingStrictSpan = null;
+ private DifferentialMotionFlingHelper mDifferentialMotionFlingHelper;
+
/**
* Sentinel value for no current active pointer.
* Used by {@link #mActivePointerId}.
@@ -594,6 +596,14 @@ public class ScrollView extends FrameLayout {
}
}
+ private void initDifferentialFlingHelperIfNotExists() {
+ if (mDifferentialMotionFlingHelper == null) {
+ mDifferentialMotionFlingHelper =
+ new DifferentialMotionFlingHelper(
+ mContext, new DifferentialFlingTarget());
+ }
+ }
+
private void recycleVelocityTracker() {
if (mVelocityTracker != null) {
mVelocityTracker.recycle();
@@ -942,17 +952,22 @@ public class ScrollView extends FrameLayout {
public boolean onGenericMotionEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_SCROLL:
- final float axisValue;
+ final int axis;
if (event.isFromSource(InputDevice.SOURCE_CLASS_POINTER)) {
- axisValue = event.getAxisValue(MotionEvent.AXIS_VSCROLL);
+ axis = MotionEvent.AXIS_VSCROLL;
} else if (event.isFromSource(InputDevice.SOURCE_ROTARY_ENCODER)) {
- axisValue = event.getAxisValue(MotionEvent.AXIS_SCROLL);
+ axis = MotionEvent.AXIS_SCROLL;
} else {
- axisValue = 0;
+ axis = -1;
}
+ final float axisValue = (axis == -1) ? 0 : event.getAxisValue(axis);
final int delta = Math.round(axisValue * mVerticalScrollFactor);
if (delta != 0) {
+ // Tracks whether or not we should attempt fling for this event.
+ // Fling should not be attempted if the view is already at the limit of scroll,
+ // since it conflicts with EdgeEffect.
+ boolean shouldAttemptFling = true;
final int range = getScrollRange();
int oldScrollY = mScrollY;
int newScrollY = oldScrollY - delta;
@@ -971,6 +986,7 @@ public class ScrollView extends FrameLayout {
absorbed = true;
}
newScrollY = 0;
+ shouldAttemptFling = false;
} else if (newScrollY > range) {
if (canOverscroll) {
mEdgeGlowBottom.onPullDistance(
@@ -980,9 +996,14 @@ public class ScrollView extends FrameLayout {
absorbed = true;
}
newScrollY = range;
+ shouldAttemptFling = false;
}
if (newScrollY != oldScrollY) {
super.scrollTo(mScrollX, newScrollY);
+ if (shouldAttemptFling) {
+ initDifferentialFlingHelperIfNotExists();
+ mDifferentialMotionFlingHelper.onMotionEvent(event, axis);
+ }
return true;
}
if (absorbed) {
@@ -2126,4 +2147,23 @@ public class ScrollView extends FrameLayout {
};
}
+ private class DifferentialFlingTarget
+ implements DifferentialMotionFlingHelper.DifferentialMotionFlingTarget {
+ @Override
+ public boolean startDifferentialMotionFling(float velocity) {
+ stopDifferentialMotionFling();
+ fling((int) velocity);
+ return true;
+ }
+
+ @Override
+ public void stopDifferentialMotionFling() {
+ mScroller.abortAnimation();
+ }
+
+ @Override
+ public float getScaledScrollFactor() {
+ return -mVerticalScrollFactor;
+ }
+ }
}
diff --git a/core/tests/coretests/src/android/widget/DifferentialMotionFlingHelperTest.java b/core/tests/coretests/src/android/widget/DifferentialMotionFlingHelperTest.java
new file mode 100644
index 000000000000..51c8bc06e878
--- /dev/null
+++ b/core/tests/coretests/src/android/widget/DifferentialMotionFlingHelperTest.java
@@ -0,0 +1,186 @@
+/*
+ * 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 android.widget;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+
+import android.view.MotionEvent;
+
+import androidx.test.core.app.ApplicationProvider;
+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;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class DifferentialMotionFlingHelperTest {
+ private int mMinVelocity = 0;
+ private int mMaxVelocity = Integer.MAX_VALUE;
+ /** A fake velocity value that's going to be returned from the velocity provider. */
+ private float mVelocity;
+ private boolean mVelocityCalculated;
+
+ private final DifferentialMotionFlingHelper.DifferentialVelocityProvider mVelocityProvider =
+ (vt, event, axis) -> {
+ mVelocityCalculated = true;
+ return mVelocity;
+ };
+
+ private final DifferentialMotionFlingHelper.FlingVelocityThresholdCalculator
+ mVelocityThresholdCalculator =
+ (ctx, buffer, event, axis) -> {
+ buffer[0] = mMinVelocity;
+ buffer[1] = mMaxVelocity;
+ };
+
+ private final TestDifferentialMotionFlingTarget mFlingTarget =
+ new TestDifferentialMotionFlingTarget();
+
+ private DifferentialMotionFlingHelper mFlingHelper;
+
+ @Before
+ public void setUp() throws Exception {
+ mFlingHelper = new DifferentialMotionFlingHelper(
+ ApplicationProvider.getApplicationContext(),
+ mFlingTarget,
+ mVelocityThresholdCalculator,
+ mVelocityProvider);
+ }
+
+ @Test
+ public void deviceDoesNotSupportFling_noVelocityCalculated() {
+ mMinVelocity = Integer.MAX_VALUE;
+ mMaxVelocity = Integer.MIN_VALUE;
+
+ deliverEventWithVelocity(createPointerEvent(), MotionEvent.AXIS_VSCROLL, 60);
+
+ assertFalse(mVelocityCalculated);
+ }
+
+ @Test
+ public void flingVelocityOppositeToPrevious_stopsOngoingFling() {
+ deliverEventWithVelocity(createRotaryEncoderEvent(), MotionEvent.AXIS_SCROLL, 50);
+ deliverEventWithVelocity(createRotaryEncoderEvent(), MotionEvent.AXIS_SCROLL, -10);
+
+ // One stop on the initial event, and second stop due to opposite velocities.
+ assertEquals(2, mFlingTarget.mNumStops);
+ }
+
+ @Test
+ public void flingParamsChanged_stopsOngoingFling() {
+ deliverEventWithVelocity(createPointerEvent(), MotionEvent.AXIS_VSCROLL, 50);
+ deliverEventWithVelocity(createRotaryEncoderEvent(), MotionEvent.AXIS_SCROLL, 10);
+
+ // One stop on the initial event, and second stop due to changed axis/source.
+ assertEquals(2, mFlingTarget.mNumStops);
+ }
+
+ @Test
+ public void positiveFlingVelocityTooLow_doesNotGenerateFling() {
+ mMinVelocity = 50;
+ mMaxVelocity = 100;
+ deliverEventWithVelocity(createPointerEvent(), MotionEvent.AXIS_VSCROLL, 20);
+
+ assertEquals(0, mFlingTarget.mLastFlingVelocity, /* delta= */ 0);
+ }
+
+ @Test
+ public void negativeFlingVelocityTooLow_doesNotGenerateFling() {
+ mMinVelocity = 50;
+ mMaxVelocity = 100;
+ deliverEventWithVelocity(createPointerEvent(), MotionEvent.AXIS_VSCROLL, -20);
+
+ assertEquals(0, mFlingTarget.mLastFlingVelocity, /* delta= */ 0);
+ }
+
+ @Test
+ public void positiveFlingVelocityAboveMinimum_generateFlings() {
+ mMinVelocity = 50;
+ mMaxVelocity = 100;
+ deliverEventWithVelocity(createPointerEvent(), MotionEvent.AXIS_VSCROLL, 60);
+
+ assertEquals(60, mFlingTarget.mLastFlingVelocity, /* delta= */ 0);
+ }
+
+ @Test
+ public void negativeFlingVelocityAboveMinimum_generateFlings() {
+ mMinVelocity = 50;
+ mMaxVelocity = 100;
+ deliverEventWithVelocity(createPointerEvent(), MotionEvent.AXIS_VSCROLL, -60);
+
+ assertEquals(-60, mFlingTarget.mLastFlingVelocity, /* delta= */ 0);
+ }
+
+ @Test
+ public void positiveFlingVelocityAboveMaximum_velocityClamped() {
+ mMinVelocity = 50;
+ mMaxVelocity = 100;
+ deliverEventWithVelocity(createPointerEvent(), MotionEvent.AXIS_VSCROLL, 3000);
+
+ assertEquals(100, mFlingTarget.mLastFlingVelocity, /* delta= */ 0);
+ }
+
+ @Test
+ public void negativeFlingVelocityAboveMaximum_velocityClamped() {
+ mMinVelocity = 50;
+ mMaxVelocity = 100;
+ deliverEventWithVelocity(createPointerEvent(), MotionEvent.AXIS_VSCROLL, -3000);
+
+ assertEquals(-100, mFlingTarget.mLastFlingVelocity, /* delta= */ 0);
+ }
+
+ private MotionEvent createRotaryEncoderEvent() {
+ return MotionEventUtils.createRotaryEvent(-2);
+ }
+
+ private MotionEvent createPointerEvent() {
+ return MotionEventUtils.createGenericPointerEvent(/* hScroll= */ 0, /* vScroll= */ -1);
+
+ }
+
+ private void deliverEventWithVelocity(MotionEvent ev, int axis, float velocity) {
+ mVelocity = velocity;
+ mFlingHelper.onMotionEvent(ev, axis);
+ ev.recycle();
+ }
+
+ private static class TestDifferentialMotionFlingTarget
+ implements DifferentialMotionFlingHelper.DifferentialMotionFlingTarget {
+ float mLastFlingVelocity = 0;
+ int mNumStops = 0;
+
+ @Override
+ public boolean startDifferentialMotionFling(float velocity) {
+ mLastFlingVelocity = velocity;
+ return true;
+ }
+
+ @Override
+ public void stopDifferentialMotionFling() {
+ mNumStops++;
+ }
+
+ @Override
+ public float getScaledScrollFactor() {
+ return 1;
+ }
+ }
+}
diff --git a/core/tests/coretests/src/android/widget/MotionEventUtils.java b/core/tests/coretests/src/android/widget/MotionEventUtils.java
new file mode 100644
index 000000000000..275efa3efd19
--- /dev/null
+++ b/core/tests/coretests/src/android/widget/MotionEventUtils.java
@@ -0,0 +1,69 @@
+/*
+ * 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 android.widget;
+
+import static android.view.InputDevice.SOURCE_CLASS_POINTER;
+import static android.view.InputDevice.SOURCE_ROTARY_ENCODER;
+import static android.view.MotionEvent.ACTION_SCROLL;
+import static android.view.MotionEvent.AXIS_HSCROLL;
+import static android.view.MotionEvent.AXIS_SCROLL;
+import static android.view.MotionEvent.AXIS_VSCROLL;
+
+import android.view.MotionEvent;
+
+/** Test utilities for {@link MotionEvent}s. */
+public class MotionEventUtils {
+
+ /** Creates a test {@link MotionEvent} from a {@link SOURCE_ROTARY_ENCODER}. */
+ public static MotionEvent createRotaryEvent(float scroll) {
+ MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords();
+ coords.setAxisValue(AXIS_SCROLL, scroll);
+
+ return createGenericMotionEvent(SOURCE_ROTARY_ENCODER, ACTION_SCROLL, coords);
+ }
+
+ /** Creates a test {@link MotionEvent} from a {@link SOURCE_CLASS_POINTER}. */
+ public static MotionEvent createGenericPointerEvent(float hScroll, float vScroll) {
+ MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords();
+ coords.setAxisValue(AXIS_HSCROLL, hScroll);
+ coords.setAxisValue(AXIS_VSCROLL, vScroll);
+
+ return createGenericMotionEvent(SOURCE_CLASS_POINTER, ACTION_SCROLL, coords);
+ }
+
+ private static MotionEvent createGenericMotionEvent(
+ int source, int action, MotionEvent.PointerCoords coords) {
+ MotionEvent.PointerProperties props = new MotionEvent.PointerProperties();
+ props.id = 0;
+
+ return MotionEvent.obtain(
+ /* downTime= */ 0,
+ /* eventTime= */ 100,
+ action,
+ /* pointerCount= */ 1,
+ new MotionEvent.PointerProperties[] {props},
+ new MotionEvent.PointerCoords[] {coords},
+ /* metaState= */ 0,
+ /* buttonState= */ 0,
+ /* xPrecision= */ 0,
+ /* yPrecision= */ 0,
+ /* deviceId= */ 1,
+ /* edgeFlags= */ 0,
+ source,
+ /* flags= */ 0);
+ }
+}