• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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