ViewAutoScroller.java

/*
 * Copyright 2017 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.recyclerview.selection;

import static androidx.core.util.Preconditions.checkArgument;
import static androidx.core.util.Preconditions.checkState;
import static androidx.recyclerview.selection.Shared.DEBUG;
import static androidx.recyclerview.selection.Shared.VERBOSE;

import android.graphics.Point;
import android.util.Log;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.core.view.ViewCompat;
import androidx.recyclerview.widget.RecyclerView;

/**
 * Provides auto-scrolling upon request when user's interaction with the application
 * introduces a natural intent to scroll. Used by BandSelectionHelper and GestureSelectionHelper,
 * to provide auto scrolling when user is performing selection operations.
 */
final class ViewAutoScroller extends AutoScroller {

    private static final String TAG = "ViewAutoScroller";

    // ratio used to calculate the top/bottom hotspot region; used with view height
    private static final float DEFAULT_SCROLL_THRESHOLD_RATIO = 0.125f;
    private static final int MAX_SCROLL_STEP = 70;

    private final float mScrollThresholdRatio;

    private final ScrollHost mHost;
    private final Runnable mRunner;

    private @Nullable Point mOrigin;
    private @Nullable Point mLastLocation;
    private boolean mPassedInitialMotionThreshold;

    ViewAutoScroller(@NonNull ScrollHost scrollHost) {
        this(scrollHost, DEFAULT_SCROLL_THRESHOLD_RATIO);
    }

    @VisibleForTesting
    ViewAutoScroller(@NonNull ScrollHost scrollHost, float scrollThresholdRatio) {

        checkArgument(scrollHost != null);

        mHost = scrollHost;
        mScrollThresholdRatio = scrollThresholdRatio;

        mRunner = new Runnable() {
            @Override
            public void run() {
                runScroll();
            }
        };
    }

    @Override
    public void reset() {
        mHost.removeCallback(mRunner);
        mOrigin = null;
        mLastLocation = null;
        mPassedInitialMotionThreshold = false;
    }

    @Override
    public void scroll(@NonNull Point location) {
        mLastLocation = location;

        // See #aboveMotionThreshold for details on how we track initial location.
        if (mOrigin == null) {
            mOrigin = location;
            if (VERBOSE) Log.v(TAG, "Origin @ " + mOrigin);
        }

        if (VERBOSE) Log.v(TAG, "Current location @ " + mLastLocation);

        mHost.runAtNextFrame(mRunner);
    }

    /**
     * Attempts to smooth-scroll the view at the given UI frame. Application should be
     * responsible to do any clean up (such as unsubscribing scrollListeners) after the run has
     * finished, and re-run this method on the next UI frame if applicable.
     */
    @SuppressWarnings("WeakerAccess") /* synthetic access */
    void runScroll() {
        if (DEBUG) checkState(mLastLocation != null);

        if (VERBOSE) Log.v(TAG, "Running in background using event location @ " + mLastLocation);

        // Compute the number of pixels the pointer's y-coordinate is past the view.
        // Negative values mean the pointer is at or before the top of the view, and
        // positive values mean that the pointer is at or after the bottom of the view. Note
        // that top/bottom threshold is added here so that the view still scrolls when the
        // pointer are in these buffer pixels.
        int pixelsPastView = 0;

        final int verticalThreshold = (int) (mHost.getViewHeight()
                * mScrollThresholdRatio);

        if (mLastLocation.y <= verticalThreshold) {
            pixelsPastView = mLastLocation.y - verticalThreshold;
        } else if (mLastLocation.y >= mHost.getViewHeight()
                - verticalThreshold) {
            pixelsPastView = mLastLocation.y - mHost.getViewHeight()
                    + verticalThreshold;
        }

        if (pixelsPastView == 0) {
            // If the operation that started the scrolling is no longer inactive, or if it is active
            // but not at the edge of the view, no scrolling is necessary.
            return;
        }

        // We're in one of the endzones. Now determine if there's enough of a difference
        // from the orgin to take any action. Basically if a user has somehow initiated
        // selection, but is hovering at or near their initial contact point, we don't
        // scroll. This avoids a situation where the user initiates selection in an "endzone"
        // only to have scrolling start automatically.
        if (!mPassedInitialMotionThreshold && !aboveMotionThreshold(mLastLocation)) {
            if (VERBOSE) Log.v(TAG, "Ignoring event below motion threshold.");
            return;
        }
        mPassedInitialMotionThreshold = true;

        if (pixelsPastView > verticalThreshold) {
            pixelsPastView = verticalThreshold;
        }

        // Compute the number of pixels to scroll, and scroll that many pixels.
        final int numPixels = computeScrollDistance(pixelsPastView);
        mHost.scrollBy(numPixels);

        // Replace any existing scheduled jobs with the latest and greatest..
        mHost.removeCallback(mRunner);
        mHost.runAtNextFrame(mRunner);
    }

    private boolean aboveMotionThreshold(@NonNull Point location) {
        // We reuse the scroll threshold to calculate a much smaller area
        // in which we ignore motion initially.
        int motionThreshold =
                (int) ((mHost.getViewHeight() * mScrollThresholdRatio)
                        * (mScrollThresholdRatio * 2));
        return Math.abs(mOrigin.y - location.y) >= motionThreshold;
    }

    /**
     * Computes the number of pixels to scroll based on how far the pointer is past the end
     * of the region. Roughly based on ItemTouchHelper's algorithm for computing the number of
     * pixels to scroll when an item is dragged to the end of a view.
     * @return
     */
    @VisibleForTesting
    int computeScrollDistance(int pixelsPastView) {
        final int topBottomThreshold =
                (int) (mHost.getViewHeight() * mScrollThresholdRatio);

        final int direction = (int) Math.signum(pixelsPastView);
        final int absPastView = Math.abs(pixelsPastView);

        // Calculate the ratio of how far out of the view the pointer currently resides to
        // the top/bottom scrolling hotspot of the view.
        final float outOfBoundsRatio = Math.min(
                1.0f, (float) absPastView / topBottomThreshold);
        // Interpolate this ratio and use it to compute the maximum scroll that should be
        // possible for this step.
        final int cappedScrollStep =
                (int) (direction * MAX_SCROLL_STEP * smoothOutOfBoundsRatio(outOfBoundsRatio));

        // If the final number of pixels to scroll ends up being 0, the view should still
        // scroll at least one pixel.
        return cappedScrollStep != 0 ? cappedScrollStep : direction;
    }

    /**
     * Interpolates the given out of bounds ratio on a curve which starts at (0,0) and ends
     * at (1,1) and quickly approaches 1 near the start of that interval. This ensures that
     * drags that are at the edge or barely past the edge of the threshold does little to no
     * scrolling, while drags that are near the edge of the view does a lot of
     * scrolling. The equation y=x^10 is used, but this could also be tweaked if
     * needed.
     * @param ratio A ratio which is in the range [0, 1].
     * @return A "smoothed" value, also in the range [0, 1].
     */
    private float smoothOutOfBoundsRatio(float ratio) {
        return (float) Math.pow(ratio, 10);
    }

    /**
     * Used by to calculate the proper amount of pixels to scroll given time passed
     * since scroll started, and to properly scroll / proper listener clean up if necessary.
     *
     * Callback used by scroller to perform UI tasks, such as scrolling and rerunning at next UI
     * cycle.
     */
    abstract static class ScrollHost {
        /**
         * @return height of the view.
         */
        abstract int getViewHeight();

        /**
         * @param dy distance to scroll.
         */
        abstract void scrollBy(int dy);

        /**
         * @param r schedule runnable to be run at next convenient time.
         */
        abstract void runAtNextFrame(@NonNull Runnable r);

        /**
         * @param r remove runnable from being run.
         */
        abstract void removeCallback(@NonNull Runnable r);
    }

    static ScrollHost createScrollHost(final RecyclerView recyclerView) {
        return new RuntimeHost(recyclerView);
    }

    /**
     * Tracks location of last surface contact as reported by RecyclerView.
     */
    private static final class RuntimeHost extends ScrollHost {

        private final RecyclerView mRecyclerView;

        RuntimeHost(@NonNull RecyclerView recyclerView) {
            mRecyclerView = recyclerView;
        }

        @Override
        void runAtNextFrame(@NonNull Runnable r) {
            ViewCompat.postOnAnimation(mRecyclerView, r);
        }

        @Override
        void removeCallback(@NonNull Runnable r) {
            mRecyclerView.removeCallbacks(r);
        }

        @Override
        void scrollBy(int dy) {
            if (VERBOSE) Log.v(TAG, "Scrolling view by: " + dy);
            mRecyclerView.scrollBy(0, dy);
        }

        @Override
        int getViewHeight() {
            return mRecyclerView.getHeight();
        }
    }
}