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