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 if (time > 0) { 112 action.update(-dx, -dy, time, mDecelerateInterpolator); 113 } 114 } 115 116 /** 117 * {@inheritDoc} 118 */ 119 @Override 120 protected void onSeekTargetStep(int dx, int dy, RecyclerView.State state, Action action) { 121 if (getChildCount() == 0) { 122 stop(); 123 return; 124 } 125 //noinspection PointlessBooleanExpression 126 if (DEBUG && mTargetVector != null 127 && ((mTargetVector.x * dx < 0 || mTargetVector.y * dy < 0))) { 128 throw new IllegalStateException("Scroll happened in the opposite direction" 129 + " of the target. Some calculations are wrong"); 130 } 131 mInterimTargetDx = clampApplyScroll(mInterimTargetDx, dx); 132 mInterimTargetDy = clampApplyScroll(mInterimTargetDy, dy); 133 134 if (mInterimTargetDx == 0 && mInterimTargetDy == 0) { 135 updateActionForInterimTarget(action); 136 } // everything is valid, keep going 137 138 } 139 140 /** 141 * {@inheritDoc} 142 */ 143 @Override 144 protected void onStop() { 145 mInterimTargetDx = mInterimTargetDy = 0; 146 mTargetVector = null; 147 } 148 149 /** 150 * Calculates the scroll speed. 151 * 152 * @param displayMetrics DisplayMetrics to be used for real dimension calculations 153 * @return The time (in ms) it should take for each pixel. For instance, if returned value is 154 * 2 ms, it means scrolling 1000 pixels with LinearInterpolation should take 2 seconds. 155 */ 156 protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) { 157 return MILLISECONDS_PER_INCH / displayMetrics.densityDpi; 158 } 159 160 /** 161 * <p>Calculates the time for deceleration so that transition from LinearInterpolator to 162 * DecelerateInterpolator looks smooth.</p> 163 * 164 * @param dx Distance to scroll 165 * @return Time for DecelerateInterpolator to smoothly traverse the distance when transitioning 166 * from LinearInterpolation 167 */ 168 protected int calculateTimeForDeceleration(int dx) { 169 // we want to cover same area with the linear interpolator for the first 10% of the 170 // interpolation. After that, deceleration will take control. 171 // area under curve (1-(1-x)^2) can be calculated as (1 - x/3) * x * x 172 // which gives 0.100028 when x = .3356 173 // this is why we divide linear scrolling time with .3356 174 return (int) Math.ceil(calculateTimeForScrolling(dx) / .3356); 175 } 176 177 /** 178 * Calculates the time it should take to scroll the given distance (in pixels) 179 * 180 * @param dx Distance in pixels that we want to scroll 181 * @return Time in milliseconds 182 * @see #calculateSpeedPerPixel(android.util.DisplayMetrics) 183 */ 184 protected int calculateTimeForScrolling(int dx) { 185 // In a case where dx is very small, rounding may return 0 although dx > 0. 186 // To avoid that issue, ceil the result so that if dx > 0, we'll always return positive 187 // time. 188 return (int) Math.ceil(Math.abs(dx) * MILLISECONDS_PER_PX); 189 } 190 191 /** 192 * When scrolling towards a child view, this method defines whether we should align the left 193 * or the right edge of the child with the parent RecyclerView. 194 * 195 * @return SNAP_TO_START, SNAP_TO_END or SNAP_TO_ANY; depending on the current target vector 196 * @see #SNAP_TO_START 197 * @see #SNAP_TO_END 198 * @see #SNAP_TO_ANY 199 */ 200 protected int getHorizontalSnapPreference() { 201 return mTargetVector == null || mTargetVector.x == 0 ? SNAP_TO_ANY : 202 mTargetVector.x > 0 ? SNAP_TO_END : SNAP_TO_START; 203 } 204 205 /** 206 * When scrolling towards a child view, this method defines whether we should align the top 207 * or the bottom edge of the child with the parent RecyclerView. 208 * 209 * @return SNAP_TO_START, SNAP_TO_END or SNAP_TO_ANY; depending on the current target vector 210 * @see #SNAP_TO_START 211 * @see #SNAP_TO_END 212 * @see #SNAP_TO_ANY 213 */ 214 protected int getVerticalSnapPreference() { 215 return mTargetVector == null || mTargetVector.y == 0 ? SNAP_TO_ANY : 216 mTargetVector.y > 0 ? SNAP_TO_END : SNAP_TO_START; 217 } 218 219 /** 220 * When the target scroll position is not a child of the RecyclerView, this method calculates 221 * a direction vector towards that child and triggers a smooth scroll. 222 * 223 * @see #computeScrollVectorForPosition(int) 224 */ 225 protected void updateActionForInterimTarget(Action action) { 226 // find an interim target position 227 PointF scrollVector = computeScrollVectorForPosition(getTargetPosition()); 228 if (scrollVector == null || (scrollVector.x == 0 && scrollVector.y == 0)) { 229 Log.e(TAG, "To support smooth scrolling, you should override \n" 230 + "LayoutManager#computeScrollVectorForPosition.\n" 231 + "Falling back to instant scroll"); 232 final int target = getTargetPosition(); 233 action.jumpTo(target); 234 stop(); 235 return; 236 } 237 normalize(scrollVector); 238 mTargetVector = scrollVector; 239 240 mInterimTargetDx = (int) (TARGET_SEEK_SCROLL_DISTANCE_PX * scrollVector.x); 241 mInterimTargetDy = (int) (TARGET_SEEK_SCROLL_DISTANCE_PX * scrollVector.y); 242 final int time = calculateTimeForScrolling(TARGET_SEEK_SCROLL_DISTANCE_PX); 243 // To avoid UI hiccups, trigger a smooth scroll to a distance little further than the 244 // interim target. Since we track the distance travelled in onSeekTargetStep callback, it 245 // won't actually scroll more than what we need. 246 action.update((int) (mInterimTargetDx * TARGET_SEEK_EXTRA_SCROLL_RATIO) 247 , (int) (mInterimTargetDy * TARGET_SEEK_EXTRA_SCROLL_RATIO) 248 , (int) (time * TARGET_SEEK_EXTRA_SCROLL_RATIO), mLinearInterpolator); 249 } 250 251 private int clampApplyScroll(int tmpDt, int dt) { 252 final int before = tmpDt; 253 tmpDt -= dt; 254 if (before * tmpDt <= 0) { // changed sign, reached 0 or was 0, reset 255 return 0; 256 } 257 return tmpDt; 258 } 259 260 /** 261 * Helper method for {@link #calculateDxToMakeVisible(android.view.View, int)} and 262 * {@link #calculateDyToMakeVisible(android.view.View, int)} 263 */ 264 public int calculateDtToFit(int viewStart, int viewEnd, int boxStart, int boxEnd, int 265 snapPreference) { 266 switch (snapPreference) { 267 case SNAP_TO_START: 268 return boxStart - viewStart; 269 case SNAP_TO_END: 270 return boxEnd - viewEnd; 271 case SNAP_TO_ANY: 272 final int dtStart = boxStart - viewStart; 273 if (dtStart > 0) { 274 return dtStart; 275 } 276 final int dtEnd = boxEnd - viewEnd; 277 if (dtEnd < 0) { 278 return dtEnd; 279 } 280 break; 281 default: 282 throw new IllegalArgumentException("snap preference should be one of the" 283 + " constants defined in SmoothScroller, starting with SNAP_"); 284 } 285 return 0; 286 } 287 288 /** 289 * Calculates the vertical scroll amount necessary to make the given view fully visible 290 * inside the RecyclerView. 291 * 292 * @param view The view which we want to make fully visible 293 * @param snapPreference The edge which the view should snap to when entering the visible 294 * area. One of {@link #SNAP_TO_START}, {@link #SNAP_TO_END} or 295 * {@link #SNAP_TO_ANY}. 296 * @return The vertical scroll amount necessary to make the view visible with the given 297 * snap preference. 298 */ 299 public int calculateDyToMakeVisible(View view, int snapPreference) { 300 final RecyclerView.LayoutManager layoutManager = getLayoutManager(); 301 if (layoutManager == null || !layoutManager.canScrollVertically()) { 302 return 0; 303 } 304 final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) 305 view.getLayoutParams(); 306 final int top = layoutManager.getDecoratedTop(view) - params.topMargin; 307 final int bottom = layoutManager.getDecoratedBottom(view) + params.bottomMargin; 308 final int start = layoutManager.getPaddingTop(); 309 final int end = layoutManager.getHeight() - layoutManager.getPaddingBottom(); 310 return calculateDtToFit(top, bottom, start, end, snapPreference); 311 } 312 313 /** 314 * Calculates the horizontal scroll amount necessary to make the given view fully visible 315 * inside the RecyclerView. 316 * 317 * @param view The view which we want to make fully visible 318 * @param snapPreference The edge which the view should snap to when entering the visible 319 * area. One of {@link #SNAP_TO_START}, {@link #SNAP_TO_END} or 320 * {@link #SNAP_TO_END} 321 * @return The vertical scroll amount necessary to make the view visible with the given 322 * snap preference. 323 */ 324 public int calculateDxToMakeVisible(View view, int snapPreference) { 325 final RecyclerView.LayoutManager layoutManager = getLayoutManager(); 326 if (layoutManager == null || !layoutManager.canScrollHorizontally()) { 327 return 0; 328 } 329 final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) 330 view.getLayoutParams(); 331 final int left = layoutManager.getDecoratedLeft(view) - params.leftMargin; 332 final int right = layoutManager.getDecoratedRight(view) + params.rightMargin; 333 final int start = layoutManager.getPaddingLeft(); 334 final int end = layoutManager.getWidth() - layoutManager.getPaddingRight(); 335 return calculateDtToFit(left, right, start, end, snapPreference); 336 } 337 338 abstract public PointF computeScrollVectorForPosition(int targetPosition); 339} 340