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
17package androidx.car.widget;
18
19import android.content.Context;
20import android.view.View;
21
22import androidx.annotation.NonNull;
23import androidx.annotation.Nullable;
24import androidx.recyclerview.widget.LinearSnapHelper;
25import androidx.recyclerview.widget.OrientationHelper;
26import androidx.recyclerview.widget.RecyclerView;
27
28/**
29 * Extension of a {@link LinearSnapHelper} that will snap to the start of the target child view to
30 * the start of the attached {@link RecyclerView}. The start of the view is defined as the top
31 * if the RecyclerView is scrolling vertically; it is defined as the left (or right if RTL) if the
32 * RecyclerView is scrolling horizontally.
33 *
34 * <p>Snapping may be disabled for views whose height is greater than that of the
35 * {@code RecyclerView} that contains them. In this case, the view will only be snapped to when it
36 * is first encountered. Otherwise, the user will be allowed to scroll freely through that view
37 * when it appears in the list. The snapping behavior will resume when the large view is scrolled
38 * off-screen.
39 */
40public class PagedSnapHelper extends LinearSnapHelper {
41    /**
42     * The percentage of a View that needs to be completely visible for it to be a viable snap
43     * target.
44     */
45    private static final float VIEW_VISIBLE_THRESHOLD = 0.5f;
46
47    /**
48     * When a View is longer than containing RecyclerView, the percentage of the end of this View
49     * that needs to be completely visible to prevent the rest of views to be a viable snap target.
50     *
51     * <p>In other words, if a longer-than-screen View takes more than threshold screen space on its
52     * end, do not snap to any View.
53     */
54    private static final float LONG_ITEM_END_VISIBLE_THRESHOLD = 0.3f;
55
56    private final Context mContext;
57    private RecyclerView mRecyclerView;
58
59    public PagedSnapHelper(Context context) {
60        mContext = context;
61    }
62
63    // Orientation helpers are lazily created per LayoutManager.
64    @Nullable private OrientationHelper mVerticalHelper;
65    @Nullable private OrientationHelper mHorizontalHelper;
66
67    @Override
68    public int[] calculateDistanceToFinalSnap(@NonNull RecyclerView.LayoutManager layoutManager,
69            @NonNull View targetView) {
70        int[] out = new int[2];
71
72        out[0] = layoutManager.canScrollHorizontally()
73                ? getHorizontalHelper(layoutManager).getDecoratedStart(targetView)
74                : 0;
75
76        out[1] = layoutManager.canScrollVertically()
77                ? getVerticalHelper(layoutManager).getDecoratedStart(targetView)
78                : 0;
79
80        return out;
81    }
82
83    /**
84     * Finds the view to snap to. The view to snap to is the child of the LayoutManager that is
85     * closest to the start of the RecyclerView. The "start" depends on if the LayoutManager
86     * is scrolling horizontally or vertically. If it is horizontally scrolling, then the
87     * start is the view on the left (right if RTL). Otherwise, it is the top-most view.
88     *
89     * @param layoutManager The current {@link RecyclerView.LayoutManager} for the attached
90     *                      RecyclerView.
91     * @return The View closest to the start of the RecyclerView. Returns {@code null}when:
92     * <ul>
93     *     <li>there is no item; or
94     *     <li>no visible item can fully fit in the containing RecyclerView; or
95     *     <li>an item longer than containing RecyclerView is about to scroll out.
96     * </ul>
97     */
98    @Override
99    @Nullable
100    public View findSnapView(RecyclerView.LayoutManager layoutManager) {
101        int childCount = layoutManager.getChildCount();
102        if (childCount == 0) {
103            return null;
104        }
105
106        OrientationHelper orientationHelper = getOrientationHelper(layoutManager);
107
108        // If there's only one child, then that will be the snap target.
109        if (childCount == 1) {
110            View firstChild = layoutManager.getChildAt(0);
111            return isValidSnapView(firstChild, orientationHelper) ? firstChild : null;
112        }
113
114        // If the top child view is longer than the RecyclerView (long item), and it's not yet
115        // scrolled out - meaning the screen it takes up is more than threshold,
116        // do not snap to any view.
117        // This way avoids next View snapping to top "pushes" out the end of a long item.
118        View firstChild = mRecyclerView.getChildAt(0);
119        if (firstChild.getHeight() > mRecyclerView.getHeight()
120                // Long item start is scrolled past screen;
121                && orientationHelper.getDecoratedStart(firstChild) < 0
122                // and it takes up more than threshold screen size.
123                && orientationHelper.getDecoratedEnd(firstChild) > (
124                        mRecyclerView.getHeight() * LONG_ITEM_END_VISIBLE_THRESHOLD)) {
125            return null;
126        }
127
128        View lastVisibleChild = layoutManager.getChildAt(childCount - 1);
129
130        // Check if the last child visible is the last item in the list.
131        boolean lastItemVisible =
132                layoutManager.getPosition(lastVisibleChild) == layoutManager.getItemCount() - 1;
133
134        // If it is, then check how much of that view is visible.
135        float lastItemPercentageVisible = lastItemVisible
136                ? getPercentageVisible(lastVisibleChild, orientationHelper) : 0;
137
138        View closestChild = null;
139        int closestDistanceToStart = Integer.MAX_VALUE;
140        float closestPercentageVisible = 0.f;
141
142        // Iterate to find the child closest to the top and more than half way visible.
143        for (int i = 0; i < childCount; i++) {
144            View child = layoutManager.getChildAt(i);
145            int startOffset = orientationHelper.getDecoratedStart(child);
146
147            if (Math.abs(startOffset) < closestDistanceToStart) {
148                float percentageVisible = getPercentageVisible(child, orientationHelper);
149
150                if (percentageVisible > VIEW_VISIBLE_THRESHOLD
151                        && percentageVisible > closestPercentageVisible) {
152                    closestDistanceToStart = startOffset;
153                    closestChild = child;
154                    closestPercentageVisible = percentageVisible;
155                }
156            }
157        }
158
159        View childToReturn = closestChild;
160
161        // If closestChild is null, then that means we were unable to find a closest child that
162        // is over the VIEW_VISIBLE_THRESHOLD. This could happen if the views are larger than
163        // the given area. In this case, consider returning the lastVisibleChild so that the screen
164        // scrolls. Also, check if the last item should be displayed anyway if it is mostly visible.
165        if ((childToReturn == null
166                || (lastItemVisible && lastItemPercentageVisible > closestPercentageVisible))) {
167            childToReturn = lastVisibleChild;
168        }
169
170        // Return null if the childToReturn is not valid. This allows the user to scroll freely
171        // with no snapping. This can allow them to see the entire view.
172        return isValidSnapView(childToReturn, orientationHelper) ? childToReturn : null;
173    }
174
175    /**
176     * Returns whether or not the given View is a valid snapping view. A view is considered valid
177     * for snapping if it can fit entirely within the height of the RecyclerView it is contained
178     * within.
179     *
180     * <p>If the view is larger than the RecyclerView, then it might not want to be snapped to
181     * to allow the user to scroll and see the rest of the View.
182     *
183     * @param view The view to determine the snapping potential.
184     * @param helper The {@link OrientationHelper} associated with the current RecyclerView.
185     * @return {@code true} if the given view is a valid snapping view; {@code false} otherwise.
186     */
187    private boolean isValidSnapView(View view, OrientationHelper helper) {
188        return helper.getDecoratedMeasurement(view) <= helper.getLayoutManager().getHeight();
189    }
190
191    /**
192     * Returns the percentage of the given view that is visible, relative to its containing
193     * RecyclerView.
194     *
195     * @param view The View to get the percentage visible of.
196     * @param helper An {@link OrientationHelper} to aid with calculation.
197     * @return A float indicating the percentage of the given view that is visible.
198     */
199    private float getPercentageVisible(View view, OrientationHelper helper) {
200        int start = 0;
201        int end = helper.getEnd();
202
203        int viewStart = helper.getDecoratedStart(view);
204        int viewEnd = helper.getDecoratedEnd(view);
205
206        if (viewStart >= start && viewEnd <= end) {
207            // The view is within the bounds of the RecyclerView, so it's fully visible.
208            return 1.f;
209        } else if (viewStart <= start && viewEnd >= end) {
210            // The view is larger than the height of the RecyclerView.
211            int viewHeight = helper.getDecoratedMeasurement(view);
212            return 1.f - ((float) (Math.abs(viewStart) + Math.abs(viewEnd)) / viewHeight);
213        } else if (viewStart < start) {
214            // The view is above the start of the RecyclerView, so subtract the start offset
215            // from the total height.
216            return 1.f - ((float) Math.abs(viewStart) / helper.getDecoratedMeasurement(view));
217        } else {
218            // The view is below the end of the RecyclerView, so subtract the end offset from the
219            // total height.
220            return 1.f - ((float) Math.abs(viewEnd) / helper.getDecoratedMeasurement(view));
221        }
222    }
223
224    @Override
225    public void attachToRecyclerView(@Nullable RecyclerView recyclerView) {
226        super.attachToRecyclerView(recyclerView);
227        mRecyclerView = recyclerView;
228    }
229
230    /**
231     * Returns a scroller specific to this {@code PagedSnapHelper}. This scroller is used for all
232     * smooth scrolling operations, including flings.
233     *
234     * @param layoutManager The {@link RecyclerView.LayoutManager} associated with the attached
235     *                      {@link RecyclerView}.
236     *
237     * @return a {@link RecyclerView.SmoothScroller} which will handle the scrolling.
238     */
239    @Override
240    protected RecyclerView.SmoothScroller createScroller(RecyclerView.LayoutManager layoutManager) {
241        return new PagedSmoothScroller(mContext);
242    }
243
244    /**
245     * Calculate the estimated scroll distance in each direction given velocities on both axes.
246     * This method will clamp the maximum scroll distance so that a single fling will never scroll
247     * more than one page.
248     *
249     * @param velocityX Fling velocity on the horizontal axis.
250     * @param velocityY Fling velocity on the vertical axis.
251     * @return An array holding the calculated distances in x and y directions respectively.
252     */
253    @Override
254    public int[] calculateScrollDistance(int velocityX, int velocityY) {
255        int[] outDist = super.calculateScrollDistance(velocityX, velocityY);
256
257        if (mRecyclerView == null) {
258            return outDist;
259        }
260
261        RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
262        if (layoutManager == null || layoutManager.getChildCount() == 0) {
263            return outDist;
264        }
265
266        int lastChildPosition = isAtEnd(layoutManager) ? 0 : layoutManager.getChildCount() - 1;
267
268        OrientationHelper orientationHelper = getOrientationHelper(layoutManager);
269        View lastChild = layoutManager.getChildAt(lastChildPosition);
270        float percentageVisible = getPercentageVisible(lastChild, orientationHelper);
271
272        int maxDistance = layoutManager.getHeight();
273        if (percentageVisible > 0.f) {
274            // The max and min distance is the total height of the RecyclerView minus the height of
275            // the last child. This ensures that each scroll will never scroll more than a single
276            // page on the RecyclerView. That is, the max scroll will make the last child the
277            // first child and vice versa when scrolling the opposite way.
278            maxDistance -= layoutManager.getDecoratedMeasuredHeight(lastChild);
279        }
280
281        int minDistance = -maxDistance;
282
283        outDist[0] = clamp(outDist[0], minDistance, maxDistance);
284        outDist[1] = clamp(outDist[1], minDistance, maxDistance);
285
286        return outDist;
287    }
288
289    /** Returns {@code true} if the RecyclerView is completely displaying the first item. */
290    public boolean isAtStart(RecyclerView.LayoutManager layoutManager) {
291        if (layoutManager == null || layoutManager.getChildCount() == 0) {
292            return true;
293        }
294
295        View firstChild = layoutManager.getChildAt(0);
296        OrientationHelper orientationHelper = layoutManager.canScrollVertically()
297                ? getVerticalHelper(layoutManager)
298                : getHorizontalHelper(layoutManager);
299
300        // Check that the first child is completely visible and is the first item in the list.
301        return orientationHelper.getDecoratedStart(firstChild) >= 0
302                && layoutManager.getPosition(firstChild) == 0;
303    }
304
305    /** Returns {@code true} if the RecyclerView is completely displaying the last item. */
306    public boolean isAtEnd(RecyclerView.LayoutManager layoutManager) {
307        if (layoutManager == null || layoutManager.getChildCount() == 0) {
308            return true;
309        }
310
311        int childCount = layoutManager.getChildCount();
312        View lastVisibleChild = layoutManager.getChildAt(childCount - 1);
313
314        // The list has reached the bottom if the last child that is visible is the last item
315        // in the list and it's fully shown.
316        return layoutManager.getPosition(lastVisibleChild) == (layoutManager.getItemCount() - 1)
317                && layoutManager.getDecoratedBottom(lastVisibleChild) <= layoutManager.getHeight();
318    }
319
320    /**
321     * Returns an {@link OrientationHelper} that corresponds to the current scroll direction of
322     * the given {@link RecyclerView.LayoutManager}.
323     */
324    @NonNull
325    private OrientationHelper getOrientationHelper(
326            @NonNull RecyclerView.LayoutManager layoutManager) {
327        return layoutManager.canScrollVertically()
328                ? getVerticalHelper(layoutManager)
329                : getHorizontalHelper(layoutManager);
330    }
331
332    @NonNull
333    private OrientationHelper getVerticalHelper(@NonNull RecyclerView.LayoutManager layoutManager) {
334        if (mVerticalHelper == null || mVerticalHelper.getLayoutManager() != layoutManager) {
335            mVerticalHelper = OrientationHelper.createVerticalHelper(layoutManager);
336        }
337        return mVerticalHelper;
338    }
339
340    @NonNull
341    private OrientationHelper getHorizontalHelper(
342            @NonNull RecyclerView.LayoutManager layoutManager) {
343        if (mHorizontalHelper == null || mHorizontalHelper.getLayoutManager() != layoutManager) {
344            mHorizontalHelper = OrientationHelper.createHorizontalHelper(layoutManager);
345        }
346        return mHorizontalHelper;
347    }
348
349    /**
350     * Ensures that the given value falls between the range given by the min and max values. This
351     * method does not check that the min value is greater than or equal to the max value. If the
352     * parameters are not well-formed, this method's behavior is undefined.
353     *
354     * @param value The value to clamp.
355     * @param min The minimum value the given value can be.
356     * @param max The maximum value the given value can be.
357     * @return A number that falls between {@code min} or {@code max} or one of those values if the
358     * given value is less than or greater than {@code min} and {@code max} respectively.
359     */
360    private static int clamp(int value, int min, int max) {
361        return Math.max(min, Math.min(max, value));
362    }
363}
364