• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright 2018 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.widget;
18 
19 import android.util.DisplayMetrics;
20 import android.view.View;
21 import android.view.animation.DecelerateInterpolator;
22 import android.widget.Scroller;
23 
24 import androidx.annotation.NonNull;
25 import androidx.annotation.Nullable;
26 
27 /**
28  * Class intended to support snapping for a {@link RecyclerView}.
29  * <p>
30  * SnapHelper tries to handle fling as well but for this to work properly, the
31  * {@link RecyclerView.LayoutManager} must implement the {@link RecyclerView.SmoothScroller.ScrollVectorProvider} interface or
32  * you should override {@link #onFling(int, int)} and handle fling manually.
33  */
34 public abstract class SnapHelper extends RecyclerView.OnFlingListener {
35 
36     static final float MILLISECONDS_PER_INCH = 100f;
37 
38     RecyclerView mRecyclerView;
39     private Scroller mGravityScroller;
40 
41     // Handles the snap on scroll case.
42     private final RecyclerView.OnScrollListener mScrollListener =
43             new RecyclerView.OnScrollListener() {
44                 boolean mScrolled = false;
45 
46                 @Override
47                 public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
48                     super.onScrollStateChanged(recyclerView, newState);
49                     if (newState == RecyclerView.SCROLL_STATE_IDLE && mScrolled) {
50                         mScrolled = false;
51                         snapToTargetExistingView();
52                     }
53                 }
54 
55                 @Override
56                 public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
57                     if (dx != 0 || dy != 0) {
58                         mScrolled = true;
59                     }
60                 }
61             };
62 
63     @Override
onFling(int velocityX, int velocityY)64     public boolean onFling(int velocityX, int velocityY) {
65         RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
66         if (layoutManager == null) {
67             return false;
68         }
69         RecyclerView.Adapter adapter = mRecyclerView.getAdapter();
70         if (adapter == null) {
71             return false;
72         }
73         int minFlingVelocity = mRecyclerView.getMinFlingVelocity();
74         return (Math.abs(velocityY) > minFlingVelocity || Math.abs(velocityX) > minFlingVelocity)
75                 && snapFromFling(layoutManager, velocityX, velocityY);
76     }
77 
78     /**
79      * Attaches the {@link SnapHelper} to the provided RecyclerView, by calling
80      * {@link RecyclerView#setOnFlingListener(RecyclerView.OnFlingListener)}.
81      * You can call this method with {@code null} to detach it from the current RecyclerView.
82      *
83      * @param recyclerView The RecyclerView instance to which you want to add this helper or
84      *                     {@code null} if you want to remove SnapHelper from the current
85      *                     RecyclerView.
86      *
87      * @throws IllegalArgumentException if there is already a {@link RecyclerView.OnFlingListener}
88      * attached to the provided {@link RecyclerView}.
89      *
90      */
attachToRecyclerView(@ullable RecyclerView recyclerView)91     public void attachToRecyclerView(@Nullable RecyclerView recyclerView)
92             throws IllegalStateException {
93         if (mRecyclerView == recyclerView) {
94             return; // nothing to do
95         }
96         if (mRecyclerView != null) {
97             destroyCallbacks();
98         }
99         mRecyclerView = recyclerView;
100         if (mRecyclerView != null) {
101             setupCallbacks();
102             mGravityScroller = new Scroller(mRecyclerView.getContext(),
103                     new DecelerateInterpolator());
104             snapToTargetExistingView();
105         }
106     }
107 
108     /**
109      * Called when an instance of a {@link RecyclerView} is attached.
110      */
setupCallbacks()111     private void setupCallbacks() throws IllegalStateException {
112         if (mRecyclerView.getOnFlingListener() != null) {
113             throw new IllegalStateException("An instance of OnFlingListener already set.");
114         }
115         mRecyclerView.addOnScrollListener(mScrollListener);
116         mRecyclerView.setOnFlingListener(this);
117     }
118 
119     /**
120      * Called when the instance of a {@link RecyclerView} is detached.
121      */
destroyCallbacks()122     private void destroyCallbacks() {
123         mRecyclerView.removeOnScrollListener(mScrollListener);
124         mRecyclerView.setOnFlingListener(null);
125     }
126 
127     /**
128      * Calculated the estimated scroll distance in each direction given velocities on both axes.
129      *
130      * @param velocityX     Fling velocity on the horizontal axis.
131      * @param velocityY     Fling velocity on the vertical axis.
132      *
133      * @return array holding the calculated distances in x and y directions
134      * respectively.
135      */
calculateScrollDistance(int velocityX, int velocityY)136     public int[] calculateScrollDistance(int velocityX, int velocityY) {
137         int[] outDist = new int[2];
138         mGravityScroller.fling(0, 0, velocityX, velocityY,
139                 Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
140         outDist[0] = mGravityScroller.getFinalX();
141         outDist[1] = mGravityScroller.getFinalY();
142         return outDist;
143     }
144 
145     /**
146      * Helper method to facilitate for snapping triggered by a fling.
147      *
148      * @param layoutManager The {@link RecyclerView.LayoutManager} associated with the attached
149      *                      {@link RecyclerView}.
150      * @param velocityX     Fling velocity on the horizontal axis.
151      * @param velocityY     Fling velocity on the vertical axis.
152      *
153      * @return true if it is handled, false otherwise.
154      */
snapFromFling(@onNull RecyclerView.LayoutManager layoutManager, int velocityX, int velocityY)155     private boolean snapFromFling(@NonNull RecyclerView.LayoutManager layoutManager, int velocityX,
156             int velocityY) {
157         if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
158             return false;
159         }
160 
161         RecyclerView.SmoothScroller smoothScroller = createScroller(layoutManager);
162         if (smoothScroller == null) {
163             return false;
164         }
165 
166         int targetPosition = findTargetSnapPosition(layoutManager, velocityX, velocityY);
167         if (targetPosition == RecyclerView.NO_POSITION) {
168             return false;
169         }
170 
171         smoothScroller.setTargetPosition(targetPosition);
172         layoutManager.startSmoothScroll(smoothScroller);
173         return true;
174     }
175 
176     /**
177      * Snaps to a target view which currently exists in the attached {@link RecyclerView}. This
178      * method is used to snap the view when the {@link RecyclerView} is first attached; when
179      * snapping was triggered by a scroll and when the fling is at its final stages.
180      */
snapToTargetExistingView()181     void snapToTargetExistingView() {
182         if (mRecyclerView == null) {
183             return;
184         }
185         RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
186         if (layoutManager == null) {
187             return;
188         }
189         View snapView = findSnapView(layoutManager);
190         if (snapView == null) {
191             return;
192         }
193         int[] snapDistance = calculateDistanceToFinalSnap(layoutManager, snapView);
194         if (snapDistance[0] != 0 || snapDistance[1] != 0) {
195             mRecyclerView.smoothScrollBy(snapDistance[0], snapDistance[1]);
196         }
197     }
198 
199     /**
200      * Creates a scroller to be used in the snapping implementation.
201      *
202      * @param layoutManager     The {@link RecyclerView.LayoutManager} associated with the attached
203      *                          {@link RecyclerView}.
204      *
205      * @return a {@link RecyclerView.SmoothScroller} which will handle the scrolling.
206      */
207     @Nullable
createScroller(RecyclerView.LayoutManager layoutManager)208     protected RecyclerView.SmoothScroller createScroller(RecyclerView.LayoutManager layoutManager) {
209         return createSnapScroller(layoutManager);
210     }
211 
212     /**
213      * Creates a scroller to be used in the snapping implementation.
214      *
215      * @param layoutManager     The {@link RecyclerView.LayoutManager} associated with the attached
216      *                          {@link RecyclerView}.
217      *
218      * @return a {@link LinearSmoothScroller} which will handle the scrolling.
219      * @deprecated use {@link #createScroller(RecyclerView.LayoutManager)} instead.
220      */
221     @Nullable
222     @Deprecated
createSnapScroller(RecyclerView.LayoutManager layoutManager)223     protected LinearSmoothScroller createSnapScroller(RecyclerView.LayoutManager layoutManager) {
224         if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
225             return null;
226         }
227         return new LinearSmoothScroller(mRecyclerView.getContext()) {
228             @Override
229             protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
230                 if (mRecyclerView == null) {
231                     // The associated RecyclerView has been removed so there is no action to take.
232                     return;
233                 }
234                 int[] snapDistances = calculateDistanceToFinalSnap(mRecyclerView.getLayoutManager(),
235                         targetView);
236                 final int dx = snapDistances[0];
237                 final int dy = snapDistances[1];
238                 final int time = calculateTimeForDeceleration(Math.max(Math.abs(dx), Math.abs(dy)));
239                 if (time > 0) {
240                     action.update(dx, dy, time, mDecelerateInterpolator);
241                 }
242             }
243 
244             @Override
245             protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
246                 return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;
247             }
248         };
249     }
250 
251     /**
252      * Override this method to snap to a particular point within the target view or the container
253      * view on any axis.
254      * <p>
255      * This method is called when the {@link SnapHelper} has intercepted a fling and it needs
256      * to know the exact distance required to scroll by in order to snap to the target view.
257      *
258      * @param layoutManager the {@link RecyclerView.LayoutManager} associated with the attached
259      *                      {@link RecyclerView}
260      * @param targetView the target view that is chosen as the view to snap
261      *
262      * @return the output coordinates the put the result into. out[0] is the distance
263      * on horizontal axis and out[1] is the distance on vertical axis.
264      */
265     @SuppressWarnings("WeakerAccess")
266     @Nullable
267     public abstract int[] calculateDistanceToFinalSnap(@NonNull RecyclerView.LayoutManager layoutManager,
268             @NonNull View targetView);
269 
270     /**
271      * Override this method to provide a particular target view for snapping.
272      * <p>
273      * This method is called when the {@link SnapHelper} is ready to start snapping and requires
274      * a target view to snap to. It will be explicitly called when the scroll state becomes idle
275      * after a scroll. It will also be called when the {@link SnapHelper} is preparing to snap
276      * after a fling and requires a reference view from the current set of child views.
277      * <p>
278      * If this method returns {@code null}, SnapHelper will not snap to any view.
279      *
280      * @param layoutManager the {@link RecyclerView.LayoutManager} associated with the attached
281      *                      {@link RecyclerView}
282      *
283      * @return the target view to which to snap on fling or end of scroll
284      */
285     @SuppressWarnings("WeakerAccess")
286     @Nullable
287     public abstract View findSnapView(RecyclerView.LayoutManager layoutManager);
288 
289     /**
290      * Override to provide a particular adapter target position for snapping.
291      *
292      * @param layoutManager the {@link RecyclerView.LayoutManager} associated with the attached
293      *                      {@link RecyclerView}
294      * @param velocityX fling velocity on the horizontal axis
295      * @param velocityY fling velocity on the vertical axis
296      *
297      * @return the target adapter position to you want to snap or {@link RecyclerView#NO_POSITION}
298      *         if no snapping should happen
299      */
300     public abstract int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX,
301             int velocityY);
302 }
303