1 /* 2 * Copyright 2018 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.widget; 18 19 import android.content.Context; 20 import android.graphics.PointF; 21 import android.util.DisplayMetrics; 22 import android.view.View; 23 import android.view.animation.DecelerateInterpolator; 24 import android.view.animation.LinearInterpolator; 25 26 /** 27 * {@link RecyclerView.SmoothScroller} implementation which uses a {@link LinearInterpolator} until 28 * the target position becomes a child of the RecyclerView and then uses a 29 * {@link DecelerateInterpolator} to slowly approach to target position. 30 * <p> 31 * If the {@link RecyclerView.LayoutManager} you are using does not implement the 32 * {@link RecyclerView.SmoothScroller.ScrollVectorProvider} interface, then you must override the 33 * {@link #computeScrollVectorForPosition(int)} method. All the LayoutManagers bundled with 34 * the support library implement this interface. 35 */ 36 public class LinearSmoothScroller extends RecyclerView.SmoothScroller { 37 38 private static final String TAG = "LinearSmoothScroller"; 39 40 private static final boolean DEBUG = false; 41 42 private static final float MILLISECONDS_PER_INCH = 25f; 43 44 private static final int TARGET_SEEK_SCROLL_DISTANCE_PX = 10000; 45 46 /** 47 * Align child view's left or top with parent view's left or top 48 * 49 * @see #calculateDtToFit(int, int, int, int, int) 50 * @see #calculateDxToMakeVisible(android.view.View, int) 51 * @see #calculateDyToMakeVisible(android.view.View, int) 52 */ 53 public static final int SNAP_TO_START = -1; 54 55 /** 56 * Align child view's right or bottom with parent view's right or bottom 57 * 58 * @see #calculateDtToFit(int, int, int, int, int) 59 * @see #calculateDxToMakeVisible(android.view.View, int) 60 * @see #calculateDyToMakeVisible(android.view.View, int) 61 */ 62 public static final int SNAP_TO_END = 1; 63 64 /** 65 * <p>Decides if the child should be snapped from start or end, depending on where it 66 * currently is in relation to its parent.</p> 67 * <p>For instance, if the view is virtually on the left of RecyclerView, using 68 * {@code SNAP_TO_ANY} is the same as using {@code SNAP_TO_START}</p> 69 * 70 * @see #calculateDtToFit(int, int, int, int, int) 71 * @see #calculateDxToMakeVisible(android.view.View, int) 72 * @see #calculateDyToMakeVisible(android.view.View, int) 73 */ 74 public static final int SNAP_TO_ANY = 0; 75 76 // Trigger a scroll to a further distance than TARGET_SEEK_SCROLL_DISTANCE_PX so that if target 77 // view is not laid out until interim target position is reached, we can detect the case before 78 // scrolling slows down and reschedule another interim target scroll 79 private static final float TARGET_SEEK_EXTRA_SCROLL_RATIO = 1.2f; 80 81 protected final LinearInterpolator mLinearInterpolator = new LinearInterpolator(); 82 83 protected final DecelerateInterpolator mDecelerateInterpolator = new DecelerateInterpolator(); 84 85 protected PointF mTargetVector; 86 87 private final float MILLISECONDS_PER_PX; 88 89 // Temporary variables to keep track of the interim scroll target. These values do not 90 // point to a real item position, rather point to an estimated location pixels. 91 protected int mInterimTargetDx = 0, mInterimTargetDy = 0; 92 LinearSmoothScroller(Context context)93 public LinearSmoothScroller(Context context) { 94 MILLISECONDS_PER_PX = calculateSpeedPerPixel(context.getResources().getDisplayMetrics()); 95 } 96 97 /** 98 * {@inheritDoc} 99 */ 100 @Override onStart()101 protected void onStart() { 102 103 } 104 105 /** 106 * {@inheritDoc} 107 */ 108 @Override onTargetFound(View targetView, RecyclerView.State state, Action action)109 protected void onTargetFound(View targetView, RecyclerView.State state, Action action) { 110 final int dx = calculateDxToMakeVisible(targetView, getHorizontalSnapPreference()); 111 final int dy = calculateDyToMakeVisible(targetView, getVerticalSnapPreference()); 112 final int distance = (int) Math.sqrt(dx * dx + dy * dy); 113 final int time = calculateTimeForDeceleration(distance); 114 if (time > 0) { 115 action.update(-dx, -dy, time, mDecelerateInterpolator); 116 } 117 } 118 119 /** 120 * {@inheritDoc} 121 */ 122 @Override onSeekTargetStep(int dx, int dy, RecyclerView.State state, Action action)123 protected void onSeekTargetStep(int dx, int dy, RecyclerView.State state, Action action) { 124 // TODO(b/72745539): Is there ever a time when onSeekTargetStep should be called when 125 // getChildCount returns 0? Should this logic be extracted out of this method such that 126 // this method is not called if getChildCount() returns 0? 127 if (getChildCount() == 0) { 128 stop(); 129 return; 130 } 131 //noinspection PointlessBooleanExpression 132 if (DEBUG && mTargetVector != null 133 && ((mTargetVector.x * dx < 0 || mTargetVector.y * dy < 0))) { 134 throw new IllegalStateException("Scroll happened in the opposite direction" 135 + " of the target. Some calculations are wrong"); 136 } 137 mInterimTargetDx = clampApplyScroll(mInterimTargetDx, dx); 138 mInterimTargetDy = clampApplyScroll(mInterimTargetDy, dy); 139 140 if (mInterimTargetDx == 0 && mInterimTargetDy == 0) { 141 updateActionForInterimTarget(action); 142 } // everything is valid, keep going 143 144 } 145 146 /** 147 * {@inheritDoc} 148 */ 149 @Override onStop()150 protected void onStop() { 151 mInterimTargetDx = mInterimTargetDy = 0; 152 mTargetVector = null; 153 } 154 155 /** 156 * Calculates the scroll speed. 157 * 158 * @param displayMetrics DisplayMetrics to be used for real dimension calculations 159 * @return The time (in ms) it should take for each pixel. For instance, if returned value is 160 * 2 ms, it means scrolling 1000 pixels with LinearInterpolation should take 2 seconds. 161 */ calculateSpeedPerPixel(DisplayMetrics displayMetrics)162 protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) { 163 return MILLISECONDS_PER_INCH / displayMetrics.densityDpi; 164 } 165 166 /** 167 * <p>Calculates the time for deceleration so that transition from LinearInterpolator to 168 * DecelerateInterpolator looks smooth.</p> 169 * 170 * @param dx Distance to scroll 171 * @return Time for DecelerateInterpolator to smoothly traverse the distance when transitioning 172 * from LinearInterpolation 173 */ calculateTimeForDeceleration(int dx)174 protected int calculateTimeForDeceleration(int dx) { 175 // we want to cover same area with the linear interpolator for the first 10% of the 176 // interpolation. After that, deceleration will take control. 177 // area under curve (1-(1-x)^2) can be calculated as (1 - x/3) * x * x 178 // which gives 0.100028 when x = .3356 179 // this is why we divide linear scrolling time with .3356 180 return (int) Math.ceil(calculateTimeForScrolling(dx) / .3356); 181 } 182 183 /** 184 * Calculates the time it should take to scroll the given distance (in pixels) 185 * 186 * @param dx Distance in pixels that we want to scroll 187 * @return Time in milliseconds 188 * @see #calculateSpeedPerPixel(android.util.DisplayMetrics) 189 */ calculateTimeForScrolling(int dx)190 protected int calculateTimeForScrolling(int dx) { 191 // In a case where dx is very small, rounding may return 0 although dx > 0. 192 // To avoid that issue, ceil the result so that if dx > 0, we'll always return positive 193 // time. 194 return (int) Math.ceil(Math.abs(dx) * MILLISECONDS_PER_PX); 195 } 196 197 /** 198 * When scrolling towards a child view, this method defines whether we should align the left 199 * or the right edge of the child with the parent RecyclerView. 200 * 201 * @return SNAP_TO_START, SNAP_TO_END or SNAP_TO_ANY; depending on the current target vector 202 * @see #SNAP_TO_START 203 * @see #SNAP_TO_END 204 * @see #SNAP_TO_ANY 205 */ getHorizontalSnapPreference()206 protected int getHorizontalSnapPreference() { 207 return mTargetVector == null || mTargetVector.x == 0 ? SNAP_TO_ANY : 208 mTargetVector.x > 0 ? SNAP_TO_END : SNAP_TO_START; 209 } 210 211 /** 212 * When scrolling towards a child view, this method defines whether we should align the top 213 * or the bottom edge of the child with the parent RecyclerView. 214 * 215 * @return SNAP_TO_START, SNAP_TO_END or SNAP_TO_ANY; depending on the current target vector 216 * @see #SNAP_TO_START 217 * @see #SNAP_TO_END 218 * @see #SNAP_TO_ANY 219 */ getVerticalSnapPreference()220 protected int getVerticalSnapPreference() { 221 return mTargetVector == null || mTargetVector.y == 0 ? SNAP_TO_ANY : 222 mTargetVector.y > 0 ? SNAP_TO_END : SNAP_TO_START; 223 } 224 225 /** 226 * When the target scroll position is not a child of the RecyclerView, this method calculates 227 * a direction vector towards that child and triggers a smooth scroll. 228 * 229 * @see #computeScrollVectorForPosition(int) 230 */ updateActionForInterimTarget(Action action)231 protected void updateActionForInterimTarget(Action action) { 232 // find an interim target position 233 PointF scrollVector = computeScrollVectorForPosition(getTargetPosition()); 234 if (scrollVector == null || (scrollVector.x == 0 && scrollVector.y == 0)) { 235 final int target = getTargetPosition(); 236 action.jumpTo(target); 237 stop(); 238 return; 239 } 240 normalize(scrollVector); 241 mTargetVector = scrollVector; 242 243 mInterimTargetDx = (int) (TARGET_SEEK_SCROLL_DISTANCE_PX * scrollVector.x); 244 mInterimTargetDy = (int) (TARGET_SEEK_SCROLL_DISTANCE_PX * scrollVector.y); 245 final int time = calculateTimeForScrolling(TARGET_SEEK_SCROLL_DISTANCE_PX); 246 // To avoid UI hiccups, trigger a smooth scroll to a distance little further than the 247 // interim target. Since we track the distance travelled in onSeekTargetStep callback, it 248 // won't actually scroll more than what we need. 249 action.update((int) (mInterimTargetDx * TARGET_SEEK_EXTRA_SCROLL_RATIO), 250 (int) (mInterimTargetDy * TARGET_SEEK_EXTRA_SCROLL_RATIO), 251 (int) (time * TARGET_SEEK_EXTRA_SCROLL_RATIO), mLinearInterpolator); 252 } 253 clampApplyScroll(int tmpDt, int dt)254 private int clampApplyScroll(int tmpDt, int dt) { 255 final int before = tmpDt; 256 tmpDt -= dt; 257 if (before * tmpDt <= 0) { // changed sign, reached 0 or was 0, reset 258 return 0; 259 } 260 return tmpDt; 261 } 262 263 /** 264 * Helper method for {@link #calculateDxToMakeVisible(android.view.View, int)} and 265 * {@link #calculateDyToMakeVisible(android.view.View, int)} 266 */ calculateDtToFit(int viewStart, int viewEnd, int boxStart, int boxEnd, int snapPreference)267 public int calculateDtToFit(int viewStart, int viewEnd, int boxStart, int boxEnd, int 268 snapPreference) { 269 switch (snapPreference) { 270 case SNAP_TO_START: 271 return boxStart - viewStart; 272 case SNAP_TO_END: 273 return boxEnd - viewEnd; 274 case SNAP_TO_ANY: 275 final int dtStart = boxStart - viewStart; 276 if (dtStart > 0) { 277 return dtStart; 278 } 279 final int dtEnd = boxEnd - viewEnd; 280 if (dtEnd < 0) { 281 return dtEnd; 282 } 283 break; 284 default: 285 throw new IllegalArgumentException("snap preference should be one of the" 286 + " constants defined in SmoothScroller, starting with SNAP_"); 287 } 288 return 0; 289 } 290 291 /** 292 * Calculates the vertical scroll amount necessary to make the given view fully visible 293 * inside the RecyclerView. 294 * 295 * @param view The view which we want to make fully visible 296 * @param snapPreference The edge which the view should snap to when entering the visible 297 * area. One of {@link #SNAP_TO_START}, {@link #SNAP_TO_END} or 298 * {@link #SNAP_TO_ANY}. 299 * @return The vertical scroll amount necessary to make the view visible with the given 300 * snap preference. 301 */ calculateDyToMakeVisible(View view, int snapPreference)302 public int calculateDyToMakeVisible(View view, int snapPreference) { 303 final RecyclerView.LayoutManager layoutManager = getLayoutManager(); 304 if (layoutManager == null || !layoutManager.canScrollVertically()) { 305 return 0; 306 } 307 final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) 308 view.getLayoutParams(); 309 final int top = layoutManager.getDecoratedTop(view) - params.topMargin; 310 final int bottom = layoutManager.getDecoratedBottom(view) + params.bottomMargin; 311 final int start = layoutManager.getPaddingTop(); 312 final int end = layoutManager.getHeight() - layoutManager.getPaddingBottom(); 313 return calculateDtToFit(top, bottom, start, end, snapPreference); 314 } 315 316 /** 317 * Calculates the horizontal scroll amount necessary to make the given view fully visible 318 * inside the RecyclerView. 319 * 320 * @param view The view which we want to make fully visible 321 * @param snapPreference The edge which the view should snap to when entering the visible 322 * area. One of {@link #SNAP_TO_START}, {@link #SNAP_TO_END} or 323 * {@link #SNAP_TO_END} 324 * @return The vertical scroll amount necessary to make the view visible with the given 325 * snap preference. 326 */ calculateDxToMakeVisible(View view, int snapPreference)327 public int calculateDxToMakeVisible(View view, int snapPreference) { 328 final RecyclerView.LayoutManager layoutManager = getLayoutManager(); 329 if (layoutManager == null || !layoutManager.canScrollHorizontally()) { 330 return 0; 331 } 332 final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) 333 view.getLayoutParams(); 334 final int left = layoutManager.getDecoratedLeft(view) - params.leftMargin; 335 final int right = layoutManager.getDecoratedRight(view) + params.rightMargin; 336 final int start = layoutManager.getPaddingLeft(); 337 final int end = layoutManager.getWidth() - layoutManager.getPaddingRight(); 338 return calculateDtToFit(left, right, start, end, snapPreference); 339 } 340 } 341