Home | History | Annotate | Download | only in widget
      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 
     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