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