Home | History | Annotate | Download | only in widget
      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 
     17 package android.support.v7.widget;
     18 
     19 import android.content.Context;
     20 import android.graphics.PointF;
     21 import android.util.DisplayMetrics;
     22 import android.util.Log;
     23 import android.view.View;
     24 import android.view.animation.DecelerateInterpolator;
     25 import 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  */
     33 abstract 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