Loading core/java/android/widget/AbsListView.java +45 −4 Original line number Diff line number Diff line Loading @@ -916,6 +916,8 @@ public abstract class AbsListView extends AdapterView<ListAdapter> implements Te } } private DifferentialMotionFlingHelper mDifferentialMotionFlingHelper; public AbsListView(Context context) { super(context); setupDeviceConfigProperties(); Loading Loading @@ -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; Loading @@ -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 Loading Loading @@ -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(); Loading Loading @@ -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; } } } core/java/android/widget/DifferentialMotionFlingHelper.java 0 → 100644 +249 −0 Original line number Diff line number Diff line /* * 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); } } core/java/android/widget/ScrollView.java +44 −4 Original line number Diff line number Diff line Loading @@ -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}. Loading Loading @@ -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(); Loading Loading @@ -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; Loading @@ -971,6 +986,7 @@ public class ScrollView extends FrameLayout { absorbed = true; } newScrollY = 0; shouldAttemptFling = false; } else if (newScrollY > range) { if (canOverscroll) { mEdgeGlowBottom.onPullDistance( Loading @@ -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) { Loading Loading @@ -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; } } } core/tests/coretests/src/android/widget/DifferentialMotionFlingHelperTest.java 0 → 100644 +186 −0 Original line number Diff line number Diff line /* * 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; } } } core/tests/coretests/src/android/widget/MotionEventUtils.java 0 → 100644 +69 −0 File added.Preview size limit exceeded, changes collapsed. Show changes Loading
core/java/android/widget/AbsListView.java +45 −4 Original line number Diff line number Diff line Loading @@ -916,6 +916,8 @@ public abstract class AbsListView extends AdapterView<ListAdapter> implements Te } } private DifferentialMotionFlingHelper mDifferentialMotionFlingHelper; public AbsListView(Context context) { super(context); setupDeviceConfigProperties(); Loading Loading @@ -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; Loading @@ -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 Loading Loading @@ -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(); Loading Loading @@ -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; } } }
core/java/android/widget/DifferentialMotionFlingHelper.java 0 → 100644 +249 −0 Original line number Diff line number Diff line /* * 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); } }
core/java/android/widget/ScrollView.java +44 −4 Original line number Diff line number Diff line Loading @@ -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}. Loading Loading @@ -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(); Loading Loading @@ -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; Loading @@ -971,6 +986,7 @@ public class ScrollView extends FrameLayout { absorbed = true; } newScrollY = 0; shouldAttemptFling = false; } else if (newScrollY > range) { if (canOverscroll) { mEdgeGlowBottom.onPullDistance( Loading @@ -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) { Loading Loading @@ -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; } } }
core/tests/coretests/src/android/widget/DifferentialMotionFlingHelperTest.java 0 → 100644 +186 −0 Original line number Diff line number Diff line /* * 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; } } }
core/tests/coretests/src/android/widget/MotionEventUtils.java 0 → 100644 +69 −0 File added.Preview size limit exceeded, changes collapsed. Show changes