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