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