Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 55fe69e0 authored by Yeabkal Wubshit's avatar Yeabkal Wubshit
Browse files

Differential motion fling for platform widgets

This change adds a DifferentialMotionFlingHelper class to control
flinging from differential motion, and uses it in AbsListView and
ScrollView.

The helper class is currently hidden. Unless we have a strong incentive
to open up the API for developers, we will keep the API hidden.

Demo video: http://shortn/_0quHhjPtCV

Interaction with stretch effect is also demonstrated in the demo.

Bug: 293332089
Test: manual, DifferentialMotionFlingHelperTest
Test: no CTS regression for ScrollViewTest, ListViewTest, GridViewTest
Change-Id: I7acd1aa63da4c64d72cca3c8ac6e10dea1f5bd0b
parent 134966d7
Loading
Loading
Loading
Loading
+45 −4
Original line number Diff line number Diff line
@@ -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;
        }
    }
}
+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);
    }
}
+44 −4
Original line number Diff line number Diff line
@@ -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;
        }
    }
}
+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;
        }
    }
}
+69 −0

File added.

Preview size limit exceeded, changes collapsed.