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.graphics.PointF;
20import android.support.annotation.NonNull;
21import android.support.annotation.Nullable;
22import android.view.View;
23
24/**
25 * Implementation of the {@link SnapHelper} supporting snapping in either vertical or horizontal
26 * orientation.
27 * <p>
28 * The implementation will snap the center of the target child view to the center of
29 * the attached {@link RecyclerView}. If you intend to change this behavior then override
30 * {@link SnapHelper#calculateDistanceToFinalSnap}.
31 */
32public class LinearSnapHelper extends SnapHelper {
33
34    private static final float INVALID_DISTANCE = 1f;
35
36    // Orientation helpers are lazily created per LayoutManager.
37    @Nullable
38    private OrientationHelper mVerticalHelper;
39    @Nullable
40    private OrientationHelper mHorizontalHelper;
41
42    @Override
43    public int[] calculateDistanceToFinalSnap(
44            @NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView) {
45        int[] out = new int[2];
46        if (layoutManager.canScrollHorizontally()) {
47            out[0] = distanceToCenter(layoutManager, targetView,
48                    getHorizontalHelper(layoutManager));
49        } else {
50            out[0] = 0;
51        }
52
53        if (layoutManager.canScrollVertically()) {
54            out[1] = distanceToCenter(layoutManager, targetView,
55                    getVerticalHelper(layoutManager));
56        } else {
57            out[1] = 0;
58        }
59        return out;
60    }
61
62    @Override
63    public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX,
64            int velocityY) {
65        if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
66            return RecyclerView.NO_POSITION;
67        }
68
69        final int itemCount = layoutManager.getItemCount();
70        if (itemCount == 0) {
71            return RecyclerView.NO_POSITION;
72        }
73
74        final View currentView = findSnapView(layoutManager);
75        if (currentView == null) {
76            return RecyclerView.NO_POSITION;
77        }
78
79        final int currentPosition = layoutManager.getPosition(currentView);
80        if (currentPosition == RecyclerView.NO_POSITION) {
81            return RecyclerView.NO_POSITION;
82        }
83
84        RecyclerView.SmoothScroller.ScrollVectorProvider vectorProvider =
85                (RecyclerView.SmoothScroller.ScrollVectorProvider) layoutManager;
86        // deltaJumps sign comes from the velocity which may not match the order of children in
87        // the LayoutManager. To overcome this, we ask for a vector from the LayoutManager to
88        // get the direction.
89        PointF vectorForEnd = vectorProvider.computeScrollVectorForPosition(itemCount - 1);
90        if (vectorForEnd == null) {
91            // cannot get a vector for the given position.
92            return RecyclerView.NO_POSITION;
93        }
94
95        int vDeltaJump, hDeltaJump;
96        if (layoutManager.canScrollHorizontally()) {
97            hDeltaJump = estimateNextPositionDiffForFling(layoutManager,
98                    getHorizontalHelper(layoutManager), velocityX, 0);
99            if (vectorForEnd.x < 0) {
100                hDeltaJump = -hDeltaJump;
101            }
102        } else {
103            hDeltaJump = 0;
104        }
105        if (layoutManager.canScrollVertically()) {
106            vDeltaJump = estimateNextPositionDiffForFling(layoutManager,
107                    getVerticalHelper(layoutManager), 0, velocityY);
108            if (vectorForEnd.y < 0) {
109                vDeltaJump = -vDeltaJump;
110            }
111        } else {
112            vDeltaJump = 0;
113        }
114
115        int deltaJump = layoutManager.canScrollVertically() ? vDeltaJump : hDeltaJump;
116        if (deltaJump == 0) {
117            return RecyclerView.NO_POSITION;
118        }
119
120        int targetPos = currentPosition + deltaJump;
121        if (targetPos < 0) {
122            targetPos = 0;
123        }
124        if (targetPos >= itemCount) {
125            targetPos = itemCount - 1;
126        }
127        return targetPos;
128    }
129
130    @Override
131    public View findSnapView(RecyclerView.LayoutManager layoutManager) {
132        if (layoutManager.canScrollVertically()) {
133            return findCenterView(layoutManager, getVerticalHelper(layoutManager));
134        } else if (layoutManager.canScrollHorizontally()) {
135            return findCenterView(layoutManager, getHorizontalHelper(layoutManager));
136        }
137        return null;
138    }
139
140    private int distanceToCenter(@NonNull RecyclerView.LayoutManager layoutManager,
141            @NonNull View targetView, OrientationHelper helper) {
142        final int childCenter = helper.getDecoratedStart(targetView)
143                + (helper.getDecoratedMeasurement(targetView) / 2);
144        final int containerCenter;
145        if (layoutManager.getClipToPadding()) {
146            containerCenter = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;
147        } else {
148            containerCenter = helper.getEnd() / 2;
149        }
150        return childCenter - containerCenter;
151    }
152
153    /**
154     * Estimates a position to which SnapHelper will try to scroll to in response to a fling.
155     *
156     * @param layoutManager The {@link RecyclerView.LayoutManager} associated with the attached
157     *                      {@link RecyclerView}.
158     * @param helper        The {@link OrientationHelper} that is created from the LayoutManager.
159     * @param velocityX     The velocity on the x axis.
160     * @param velocityY     The velocity on the y axis.
161     *
162     * @return The diff between the target scroll position and the current position.
163     */
164    private int estimateNextPositionDiffForFling(RecyclerView.LayoutManager layoutManager,
165            OrientationHelper helper, int velocityX, int velocityY) {
166        int[] distances = calculateScrollDistance(velocityX, velocityY);
167        float distancePerChild = computeDistancePerChild(layoutManager, helper);
168        if (distancePerChild <= 0) {
169            return 0;
170        }
171        int distance =
172                Math.abs(distances[0]) > Math.abs(distances[1]) ? distances[0] : distances[1];
173        return (int) Math.round(distance / distancePerChild);
174    }
175
176    /**
177     * Return the child view that is currently closest to the center of this parent.
178     *
179     * @param layoutManager The {@link RecyclerView.LayoutManager} associated with the attached
180     *                      {@link RecyclerView}.
181     * @param helper The relevant {@link OrientationHelper} for the attached {@link RecyclerView}.
182     *
183     * @return the child view that is currently closest to the center of this parent.
184     */
185    @Nullable
186    private View findCenterView(RecyclerView.LayoutManager layoutManager,
187            OrientationHelper helper) {
188        int childCount = layoutManager.getChildCount();
189        if (childCount == 0) {
190            return null;
191        }
192
193        View closestChild = null;
194        final int center;
195        if (layoutManager.getClipToPadding()) {
196            center = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;
197        } else {
198            center = helper.getEnd() / 2;
199        }
200        int absClosest = Integer.MAX_VALUE;
201
202        for (int i = 0; i < childCount; i++) {
203            final View child = layoutManager.getChildAt(i);
204            int childCenter = helper.getDecoratedStart(child)
205                    + (helper.getDecoratedMeasurement(child) / 2);
206            int absDistance = Math.abs(childCenter - center);
207
208            /** if child center is closer than previous closest, set it as closest  **/
209            if (absDistance < absClosest) {
210                absClosest = absDistance;
211                closestChild = child;
212            }
213        }
214        return closestChild;
215    }
216
217    /**
218     * Computes an average pixel value to pass a single child.
219     * <p>
220     * Returns a negative value if it cannot be calculated.
221     *
222     * @param layoutManager The {@link RecyclerView.LayoutManager} associated with the attached
223     *                      {@link RecyclerView}.
224     * @param helper        The relevant {@link OrientationHelper} for the attached
225     *                      {@link RecyclerView.LayoutManager}.
226     *
227     * @return A float value that is the average number of pixels needed to scroll by one view in
228     * the relevant direction.
229     */
230    private float computeDistancePerChild(RecyclerView.LayoutManager layoutManager,
231            OrientationHelper helper) {
232        View minPosView = null;
233        View maxPosView = null;
234        int minPos = Integer.MAX_VALUE;
235        int maxPos = Integer.MIN_VALUE;
236        int childCount = layoutManager.getChildCount();
237        if (childCount == 0) {
238            return INVALID_DISTANCE;
239        }
240
241        for (int i = 0; i < childCount; i++) {
242            View child = layoutManager.getChildAt(i);
243            final int pos = layoutManager.getPosition(child);
244            if (pos == RecyclerView.NO_POSITION) {
245                continue;
246            }
247            if (pos < minPos) {
248                minPos = pos;
249                minPosView = child;
250            }
251            if (pos > maxPos) {
252                maxPos = pos;
253                maxPosView = child;
254            }
255        }
256        if (minPosView == null || maxPosView == null) {
257            return INVALID_DISTANCE;
258        }
259        int start = Math.min(helper.getDecoratedStart(minPosView),
260                helper.getDecoratedStart(maxPosView));
261        int end = Math.max(helper.getDecoratedEnd(minPosView),
262                helper.getDecoratedEnd(maxPosView));
263        int distance = end - start;
264        if (distance == 0) {
265            return INVALID_DISTANCE;
266        }
267        return 1f * distance / ((maxPos - minPos) + 1);
268    }
269
270    @NonNull
271    private OrientationHelper getVerticalHelper(@NonNull RecyclerView.LayoutManager layoutManager) {
272        if (mVerticalHelper == null || mVerticalHelper.mLayoutManager != layoutManager) {
273            mVerticalHelper = OrientationHelper.createVerticalHelper(layoutManager);
274        }
275        return mVerticalHelper;
276    }
277
278    @NonNull
279    private OrientationHelper getHorizontalHelper(
280            @NonNull RecyclerView.LayoutManager layoutManager) {
281        if (mHorizontalHelper == null || mHorizontalHelper.mLayoutManager != layoutManager) {
282            mHorizontalHelper = OrientationHelper.createHorizontalHelper(layoutManager);
283        }
284        return mHorizontalHelper;
285    }
286}
287