Home | History | Annotate | Download | only in selection
      1 /*
      2  * Copyright 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 androidx.recyclerview.selection;
     18 
     19 import static androidx.core.util.Preconditions.checkArgument;
     20 import static androidx.core.util.Preconditions.checkState;
     21 import static androidx.recyclerview.selection.Shared.DEBUG;
     22 import static androidx.recyclerview.selection.Shared.VERBOSE;
     23 
     24 import android.graphics.Point;
     25 import android.util.Log;
     26 
     27 import androidx.annotation.NonNull;
     28 import androidx.annotation.Nullable;
     29 import androidx.annotation.VisibleForTesting;
     30 import androidx.core.view.ViewCompat;
     31 import androidx.recyclerview.widget.RecyclerView;
     32 
     33 /**
     34  * Provides auto-scrolling upon request when user's interaction with the application
     35  * introduces a natural intent to scroll. Used by BandSelectionHelper and GestureSelectionHelper,
     36  * to provide auto scrolling when user is performing selection operations.
     37  */
     38 final class ViewAutoScroller extends AutoScroller {
     39 
     40     private static final String TAG = "ViewAutoScroller";
     41 
     42     // ratio used to calculate the top/bottom hotspot region; used with view height
     43     private static final float DEFAULT_SCROLL_THRESHOLD_RATIO = 0.125f;
     44     private static final int MAX_SCROLL_STEP = 70;
     45 
     46     private final float mScrollThresholdRatio;
     47 
     48     private final ScrollHost mHost;
     49     private final Runnable mRunner;
     50 
     51     private @Nullable Point mOrigin;
     52     private @Nullable Point mLastLocation;
     53     private boolean mPassedInitialMotionThreshold;
     54 
     55     ViewAutoScroller(@NonNull ScrollHost scrollHost) {
     56         this(scrollHost, DEFAULT_SCROLL_THRESHOLD_RATIO);
     57     }
     58 
     59     @VisibleForTesting
     60     ViewAutoScroller(@NonNull ScrollHost scrollHost, float scrollThresholdRatio) {
     61 
     62         checkArgument(scrollHost != null);
     63 
     64         mHost = scrollHost;
     65         mScrollThresholdRatio = scrollThresholdRatio;
     66 
     67         mRunner = new Runnable() {
     68             @Override
     69             public void run() {
     70                 runScroll();
     71             }
     72         };
     73     }
     74 
     75     @Override
     76     public void reset() {
     77         mHost.removeCallback(mRunner);
     78         mOrigin = null;
     79         mLastLocation = null;
     80         mPassedInitialMotionThreshold = false;
     81     }
     82 
     83     @Override
     84     public void scroll(@NonNull Point location) {
     85         mLastLocation = location;
     86 
     87         // See #aboveMotionThreshold for details on how we track initial location.
     88         if (mOrigin == null) {
     89             mOrigin = location;
     90             if (VERBOSE) Log.v(TAG, "Origin @ " + mOrigin);
     91         }
     92 
     93         if (VERBOSE) Log.v(TAG, "Current location @ " + mLastLocation);
     94 
     95         mHost.runAtNextFrame(mRunner);
     96     }
     97 
     98     /**
     99      * Attempts to smooth-scroll the view at the given UI frame. Application should be
    100      * responsible to do any clean up (such as unsubscribing scrollListeners) after the run has
    101      * finished, and re-run this method on the next UI frame if applicable.
    102      */
    103     private void runScroll() {
    104         if (DEBUG) checkState(mLastLocation != null);
    105 
    106         if (VERBOSE) Log.v(TAG, "Running in background using event location @ " + mLastLocation);
    107 
    108         // Compute the number of pixels the pointer's y-coordinate is past the view.
    109         // Negative values mean the pointer is at or before the top of the view, and
    110         // positive values mean that the pointer is at or after the bottom of the view. Note
    111         // that top/bottom threshold is added here so that the view still scrolls when the
    112         // pointer are in these buffer pixels.
    113         int pixelsPastView = 0;
    114 
    115         final int verticalThreshold = (int) (mHost.getViewHeight()
    116                 * mScrollThresholdRatio);
    117 
    118         if (mLastLocation.y <= verticalThreshold) {
    119             pixelsPastView = mLastLocation.y - verticalThreshold;
    120         } else if (mLastLocation.y >= mHost.getViewHeight()
    121                 - verticalThreshold) {
    122             pixelsPastView = mLastLocation.y - mHost.getViewHeight()
    123                     + verticalThreshold;
    124         }
    125 
    126         if (pixelsPastView == 0) {
    127             // If the operation that started the scrolling is no longer inactive, or if it is active
    128             // but not at the edge of the view, no scrolling is necessary.
    129             return;
    130         }
    131 
    132         // We're in one of the endzones. Now determine if there's enough of a difference
    133         // from the orgin to take any action. Basically if a user has somehow initiated
    134         // selection, but is hovering at or near their initial contact point, we don't
    135         // scroll. This avoids a situation where the user initiates selection in an "endzone"
    136         // only to have scrolling start automatically.
    137         if (!mPassedInitialMotionThreshold && !aboveMotionThreshold(mLastLocation)) {
    138             if (VERBOSE) Log.v(TAG, "Ignoring event below motion threshold.");
    139             return;
    140         }
    141         mPassedInitialMotionThreshold = true;
    142 
    143         if (pixelsPastView > verticalThreshold) {
    144             pixelsPastView = verticalThreshold;
    145         }
    146 
    147         // Compute the number of pixels to scroll, and scroll that many pixels.
    148         final int numPixels = computeScrollDistance(pixelsPastView);
    149         mHost.scrollBy(numPixels);
    150 
    151         // Replace any existing scheduled jobs with the latest and greatest..
    152         mHost.removeCallback(mRunner);
    153         mHost.runAtNextFrame(mRunner);
    154     }
    155 
    156     private boolean aboveMotionThreshold(@NonNull Point location) {
    157         // We reuse the scroll threshold to calculate a much smaller area
    158         // in which we ignore motion initially.
    159         int motionThreshold =
    160                 (int) ((mHost.getViewHeight() * mScrollThresholdRatio)
    161                         * (mScrollThresholdRatio * 2));
    162         return Math.abs(mOrigin.y - location.y) >= motionThreshold;
    163     }
    164 
    165     /**
    166      * Computes the number of pixels to scroll based on how far the pointer is past the end
    167      * of the region. Roughly based on ItemTouchHelper's algorithm for computing the number of
    168      * pixels to scroll when an item is dragged to the end of a view.
    169      * @return
    170      */
    171     @VisibleForTesting
    172     int computeScrollDistance(int pixelsPastView) {
    173         final int topBottomThreshold =
    174                 (int) (mHost.getViewHeight() * mScrollThresholdRatio);
    175 
    176         final int direction = (int) Math.signum(pixelsPastView);
    177         final int absPastView = Math.abs(pixelsPastView);
    178 
    179         // Calculate the ratio of how far out of the view the pointer currently resides to
    180         // the top/bottom scrolling hotspot of the view.
    181         final float outOfBoundsRatio = Math.min(
    182                 1.0f, (float) absPastView / topBottomThreshold);
    183         // Interpolate this ratio and use it to compute the maximum scroll that should be
    184         // possible for this step.
    185         final int cappedScrollStep =
    186                 (int) (direction * MAX_SCROLL_STEP * smoothOutOfBoundsRatio(outOfBoundsRatio));
    187 
    188         // If the final number of pixels to scroll ends up being 0, the view should still
    189         // scroll at least one pixel.
    190         return cappedScrollStep != 0 ? cappedScrollStep : direction;
    191     }
    192 
    193     /**
    194      * Interpolates the given out of bounds ratio on a curve which starts at (0,0) and ends
    195      * at (1,1) and quickly approaches 1 near the start of that interval. This ensures that
    196      * drags that are at the edge or barely past the edge of the threshold does little to no
    197      * scrolling, while drags that are near the edge of the view does a lot of
    198      * scrolling. The equation y=x^10 is used, but this could also be tweaked if
    199      * needed.
    200      * @param ratio A ratio which is in the range [0, 1].
    201      * @return A "smoothed" value, also in the range [0, 1].
    202      */
    203     private float smoothOutOfBoundsRatio(float ratio) {
    204         return (float) Math.pow(ratio, 10);
    205     }
    206 
    207     /**
    208      * Used by to calculate the proper amount of pixels to scroll given time passed
    209      * since scroll started, and to properly scroll / proper listener clean up if necessary.
    210      *
    211      * Callback used by scroller to perform UI tasks, such as scrolling and rerunning at next UI
    212      * cycle.
    213      */
    214     abstract static class ScrollHost {
    215         /**
    216          * @return height of the view.
    217          */
    218         abstract int getViewHeight();
    219 
    220         /**
    221          * @param dy distance to scroll.
    222          */
    223         abstract void scrollBy(int dy);
    224 
    225         /**
    226          * @param r schedule runnable to be run at next convenient time.
    227          */
    228         abstract void runAtNextFrame(@NonNull Runnable r);
    229 
    230         /**
    231          * @param r remove runnable from being run.
    232          */
    233         abstract void removeCallback(@NonNull Runnable r);
    234     }
    235 
    236     static ScrollHost createScrollHost(final RecyclerView recyclerView) {
    237         return new RuntimeHost(recyclerView);
    238     }
    239 
    240     /**
    241      * Tracks location of last surface contact as reported by RecyclerView.
    242      */
    243     private static final class RuntimeHost extends ScrollHost {
    244 
    245         private final RecyclerView mRecyclerView;
    246 
    247         RuntimeHost(@NonNull RecyclerView recyclerView) {
    248             mRecyclerView = recyclerView;
    249         }
    250 
    251         @Override
    252         void runAtNextFrame(@NonNull Runnable r) {
    253             ViewCompat.postOnAnimation(mRecyclerView, r);
    254         }
    255 
    256         @Override
    257         void removeCallback(@NonNull Runnable r) {
    258             mRecyclerView.removeCallbacks(r);
    259         }
    260 
    261         @Override
    262         void scrollBy(int dy) {
    263             if (VERBOSE) Log.v(TAG, "Scrolling view by: " + dy);
    264             mRecyclerView.scrollBy(0, dy);
    265         }
    266 
    267         @Override
    268         int getViewHeight() {
    269             return mRecyclerView.getHeight();
    270         }
    271     }
    272 }
    273