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