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.support.annotation.Nullable;
22import android.util.DisplayMetrics;
23import android.util.Log;
24import android.view.View;
25import android.view.animation.DecelerateInterpolator;
26import 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 */
38public 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
95    public LinearSmoothScroller(Context context) {
96        MILLISECONDS_PER_PX = calculateSpeedPerPixel(context.getResources().getDisplayMetrics());
97    }
98
99    /**
100     * {@inheritDoc}
101     */
102    @Override
103    protected void onStart() {
104
105    }
106
107    /**
108     * {@inheritDoc}
109     */
110    @Override
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
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
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     */
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     */
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     */
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     */
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     */
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     */
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
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     */
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     */
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     */
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
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