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