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.util.Log; 26 27 import androidx.annotation.NonNull; 28 import androidx.annotation.Nullable; 29 import androidx.annotation.VisibleForTesting; 30 import androidx.core.view.ViewCompat; 31 import 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 */ 38 final 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