1/*
2 * Copyright 2017 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package androidx.recyclerview.selection;
18
19import static androidx.core.util.Preconditions.checkArgument;
20import static androidx.core.util.Preconditions.checkState;
21import static androidx.recyclerview.selection.Shared.DEBUG;
22import static androidx.recyclerview.selection.Shared.VERBOSE;
23
24import android.graphics.Point;
25import android.util.Log;
26
27import androidx.annotation.NonNull;
28import androidx.annotation.Nullable;
29import androidx.annotation.VisibleForTesting;
30import androidx.core.view.ViewCompat;
31import androidx.recyclerview.widget.RecyclerView;
32
33/**
34 * Provides auto-scrolling upon request when user's interaction with the application
35 * introduces a natural intent to scroll. Used by BandSelectionHelper and GestureSelectionHelper,
36 * to provide auto scrolling when user is performing selection operations.
37 */
38final class ViewAutoScroller extends AutoScroller {
39
40    private static final String TAG = "ViewAutoScroller";
41
42    // ratio used to calculate the top/bottom hotspot region; used with view height
43    private static final float DEFAULT_SCROLL_THRESHOLD_RATIO = 0.125f;
44    private static final int MAX_SCROLL_STEP = 70;
45
46    private final float mScrollThresholdRatio;
47
48    private final ScrollHost mHost;
49    private final Runnable mRunner;
50
51    private @Nullable Point mOrigin;
52    private @Nullable Point mLastLocation;
53    private boolean mPassedInitialMotionThreshold;
54
55    ViewAutoScroller(@NonNull ScrollHost scrollHost) {
56        this(scrollHost, DEFAULT_SCROLL_THRESHOLD_RATIO);
57    }
58
59    @VisibleForTesting
60    ViewAutoScroller(@NonNull ScrollHost scrollHost, float scrollThresholdRatio) {
61
62        checkArgument(scrollHost != null);
63
64        mHost = scrollHost;
65        mScrollThresholdRatio = scrollThresholdRatio;
66
67        mRunner = new Runnable() {
68            @Override
69            public void run() {
70                runScroll();
71            }
72        };
73    }
74
75    @Override
76    public void reset() {
77        mHost.removeCallback(mRunner);
78        mOrigin = null;
79        mLastLocation = null;
80        mPassedInitialMotionThreshold = false;
81    }
82
83    @Override
84    public void scroll(@NonNull Point location) {
85        mLastLocation = location;
86
87        // See #aboveMotionThreshold for details on how we track initial location.
88        if (mOrigin == null) {
89            mOrigin = location;
90            if (VERBOSE) Log.v(TAG, "Origin @ " + mOrigin);
91        }
92
93        if (VERBOSE) Log.v(TAG, "Current location @ " + mLastLocation);
94
95        mHost.runAtNextFrame(mRunner);
96    }
97
98    /**
99     * Attempts to smooth-scroll the view at the given UI frame. Application should be
100     * responsible to do any clean up (such as unsubscribing scrollListeners) after the run has
101     * finished, and re-run this method on the next UI frame if applicable.
102     */
103    private void runScroll() {
104        if (DEBUG) checkState(mLastLocation != null);
105
106        if (VERBOSE) Log.v(TAG, "Running in background using event location @ " + mLastLocation);
107
108        // Compute the number of pixels the pointer's y-coordinate is past the view.
109        // Negative values mean the pointer is at or before the top of the view, and
110        // positive values mean that the pointer is at or after the bottom of the view. Note
111        // that top/bottom threshold is added here so that the view still scrolls when the
112        // pointer are in these buffer pixels.
113        int pixelsPastView = 0;
114
115        final int verticalThreshold = (int) (mHost.getViewHeight()
116                * mScrollThresholdRatio);
117
118        if (mLastLocation.y <= verticalThreshold) {
119            pixelsPastView = mLastLocation.y - verticalThreshold;
120        } else if (mLastLocation.y >= mHost.getViewHeight()
121                - verticalThreshold) {
122            pixelsPastView = mLastLocation.y - mHost.getViewHeight()
123                    + verticalThreshold;
124        }
125
126        if (pixelsPastView == 0) {
127            // If the operation that started the scrolling is no longer inactive, or if it is active
128            // but not at the edge of the view, no scrolling is necessary.
129            return;
130        }
131
132        // We're in one of the endzones. Now determine if there's enough of a difference
133        // from the orgin to take any action. Basically if a user has somehow initiated
134        // selection, but is hovering at or near their initial contact point, we don't
135        // scroll. This avoids a situation where the user initiates selection in an "endzone"
136        // only to have scrolling start automatically.
137        if (!mPassedInitialMotionThreshold && !aboveMotionThreshold(mLastLocation)) {
138            if (VERBOSE) Log.v(TAG, "Ignoring event below motion threshold.");
139            return;
140        }
141        mPassedInitialMotionThreshold = true;
142
143        if (pixelsPastView > verticalThreshold) {
144            pixelsPastView = verticalThreshold;
145        }
146
147        // Compute the number of pixels to scroll, and scroll that many pixels.
148        final int numPixels = computeScrollDistance(pixelsPastView);
149        mHost.scrollBy(numPixels);
150
151        // Replace any existing scheduled jobs with the latest and greatest..
152        mHost.removeCallback(mRunner);
153        mHost.runAtNextFrame(mRunner);
154    }
155
156    private boolean aboveMotionThreshold(@NonNull Point location) {
157        // We reuse the scroll threshold to calculate a much smaller area
158        // in which we ignore motion initially.
159        int motionThreshold =
160                (int) ((mHost.getViewHeight() * mScrollThresholdRatio)
161                        * (mScrollThresholdRatio * 2));
162        return Math.abs(mOrigin.y - location.y) >= motionThreshold;
163    }
164
165    /**
166     * Computes the number of pixels to scroll based on how far the pointer is past the end
167     * of the region. Roughly based on ItemTouchHelper's algorithm for computing the number of
168     * pixels to scroll when an item is dragged to the end of a view.
169     * @return
170     */
171    @VisibleForTesting
172    int computeScrollDistance(int pixelsPastView) {
173        final int topBottomThreshold =
174                (int) (mHost.getViewHeight() * mScrollThresholdRatio);
175
176        final int direction = (int) Math.signum(pixelsPastView);
177        final int absPastView = Math.abs(pixelsPastView);
178
179        // Calculate the ratio of how far out of the view the pointer currently resides to
180        // the top/bottom scrolling hotspot of the view.
181        final float outOfBoundsRatio = Math.min(
182                1.0f, (float) absPastView / topBottomThreshold);
183        // Interpolate this ratio and use it to compute the maximum scroll that should be
184        // possible for this step.
185        final int cappedScrollStep =
186                (int) (direction * MAX_SCROLL_STEP * smoothOutOfBoundsRatio(outOfBoundsRatio));
187
188        // If the final number of pixels to scroll ends up being 0, the view should still
189        // scroll at least one pixel.
190        return cappedScrollStep != 0 ? cappedScrollStep : direction;
191    }
192
193    /**
194     * Interpolates the given out of bounds ratio on a curve which starts at (0,0) and ends
195     * at (1,1) and quickly approaches 1 near the start of that interval. This ensures that
196     * drags that are at the edge or barely past the edge of the threshold does little to no
197     * scrolling, while drags that are near the edge of the view does a lot of
198     * scrolling. The equation y=x^10 is used, but this could also be tweaked if
199     * needed.
200     * @param ratio A ratio which is in the range [0, 1].
201     * @return A "smoothed" value, also in the range [0, 1].
202     */
203    private float smoothOutOfBoundsRatio(float ratio) {
204        return (float) Math.pow(ratio, 10);
205    }
206
207    /**
208     * Used by to calculate the proper amount of pixels to scroll given time passed
209     * since scroll started, and to properly scroll / proper listener clean up if necessary.
210     *
211     * Callback used by scroller to perform UI tasks, such as scrolling and rerunning at next UI
212     * cycle.
213     */
214    abstract static class ScrollHost {
215        /**
216         * @return height of the view.
217         */
218        abstract int getViewHeight();
219
220        /**
221         * @param dy distance to scroll.
222         */
223        abstract void scrollBy(int dy);
224
225        /**
226         * @param r schedule runnable to be run at next convenient time.
227         */
228        abstract void runAtNextFrame(@NonNull Runnable r);
229
230        /**
231         * @param r remove runnable from being run.
232         */
233        abstract void removeCallback(@NonNull Runnable r);
234    }
235
236    static ScrollHost createScrollHost(final RecyclerView recyclerView) {
237        return new RuntimeHost(recyclerView);
238    }
239
240    /**
241     * Tracks location of last surface contact as reported by RecyclerView.
242     */
243    private static final class RuntimeHost extends ScrollHost {
244
245        private final RecyclerView mRecyclerView;
246
247        RuntimeHost(@NonNull RecyclerView recyclerView) {
248            mRecyclerView = recyclerView;
249        }
250
251        @Override
252        void runAtNextFrame(@NonNull Runnable r) {
253            ViewCompat.postOnAnimation(mRecyclerView, r);
254        }
255
256        @Override
257        void removeCallback(@NonNull Runnable r) {
258            mRecyclerView.removeCallbacks(r);
259        }
260
261        @Override
262        void scrollBy(int dy) {
263            if (VERBOSE) Log.v(TAG, "Scrolling view by: " + dy);
264            mRecyclerView.scrollBy(0, dy);
265        }
266
267        @Override
268        int getViewHeight() {
269            return mRecyclerView.getHeight();
270        }
271    }
272}
273