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