• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2019 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 package com.android.car.ui.recyclerview;
17 
18 import android.content.Context;
19 import android.view.View;
20 
21 import androidx.annotation.NonNull;
22 import androidx.annotation.Nullable;
23 import androidx.recyclerview.widget.LinearSnapHelper;
24 import androidx.recyclerview.widget.OrientationHelper;
25 import androidx.recyclerview.widget.RecyclerView;
26 import androidx.recyclerview.widget.RecyclerView.LayoutManager;
27 
28 import java.util.Objects;
29 
30 /**
31  * Inspired by {@link androidx.car.widget.PagedSnapHelper}
32  *
33  * <p>Extension of a {@link LinearSnapHelper} that will snap to the start of the target child view
34  * to the start of the attached {@link RecyclerView}. The start of the view is defined as the top if
35  * the RecyclerView is scrolling vertically; it is defined as the left (or right if RTL) if the
36  * RecyclerView is scrolling horizontally.
37  */
38 /* package */ class CarUiSnapHelper extends LinearSnapHelper {
39     /**
40      * The percentage of a View that needs to be completely visible for it to be a viable snap
41      * target.
42      */
43     private static final float VIEW_VISIBLE_THRESHOLD = 0.5f;
44 
45     /**
46      * When a View is longer than containing RecyclerView, the percentage of the end of this View
47      * that needs to be completely visible to prevent the rest of views to be a viable snap target.
48      *
49      * <p>In other words, if a longer-than-screen View takes more than threshold screen space on its
50      * end, do not snap to any View.
51      */
52     private static final float LONG_ITEM_END_VISIBLE_THRESHOLD = 0.3f;
53 
54     private final Context mContext;
55     @Nullable
56     private RecyclerView mRecyclerView;
57 
CarUiSnapHelper(Context context)58     public CarUiSnapHelper(Context context) {
59         mContext = context;
60     }
61 
62     // Orientation helpers are lazily created per LayoutManager.
63     @Nullable
64     private OrientationHelper mVerticalHelper;
65     @Nullable
66     private OrientationHelper mHorizontalHelper;
67 
68     @Override
calculateDistanceToFinalSnap( @onNull LayoutManager layoutManager, @NonNull View targetView)69     public int[] calculateDistanceToFinalSnap(
70             @NonNull LayoutManager layoutManager, @NonNull View targetView) {
71         int[] out = new int[2];
72 
73         // Don't snap when not in touch mode, i.e. when using rotary.
74         if (!mRecyclerView.isInTouchMode()) {
75             return out;
76         }
77 
78         if (layoutManager.canScrollHorizontally()) {
79             out[0] = distanceToTopMargin(targetView, getHorizontalHelper(layoutManager));
80         }
81 
82         if (layoutManager.canScrollVertically()) {
83             out[1] = distanceToTopMargin(targetView, getVerticalHelper(layoutManager));
84         }
85 
86         return out;
87     }
88 
89     /**
90      * Finds the view to snap to. The view to snap to is the child of the LayoutManager that is
91      * closest to the start of the RecyclerView. The "start" depends on if the LayoutManager
92      * is scrolling horizontally or vertically. If it is horizontally scrolling, then the
93      * start is the view on the left (right if RTL). Otherwise, it is the top-most view.
94      *
95      * @param layoutManager The current {@link LayoutManager} for the attached RecyclerView.
96      * @return The View closest to the start of the RecyclerView. Returns {@code null}when:
97      * <ul>
98      *     <li>there is no item; or
99      *     <li>no visible item can fully fit in the containing RecyclerView; or
100      *     <li>an item longer than containing RecyclerView is about to scroll out.
101      * </ul>
102      */
103     @Override
104     @Nullable
findSnapView(LayoutManager layoutManager)105     public View findSnapView(LayoutManager layoutManager) {
106         int childCount = layoutManager.getChildCount();
107         if (childCount == 0) {
108             return null;
109         }
110 
111         OrientationHelper orientationHelper = getOrientationHelper(layoutManager);
112 
113         // If there's only one child, then that will be the snap target.
114         if (childCount == 1) {
115             View firstChild = layoutManager.getChildAt(0);
116             return isValidSnapView(firstChild, orientationHelper) ? firstChild : null;
117         }
118 
119         if (mRecyclerView == null) {
120             return null;
121         }
122 
123         // If the top child view is longer than the RecyclerView (long item), and it's not yet
124         // scrolled out - meaning the screen it takes up is more than threshold,
125         // do not snap to any view.
126         // This way avoids next View snapping to top "pushes" out the end of a long item.
127         View firstChild = mRecyclerView.getChildAt(0);
128         if (firstChild.getHeight() > mRecyclerView.getHeight()
129                 // Long item start is scrolled past screen;
130                 && orientationHelper.getDecoratedStart(firstChild) < 0
131                 // and it takes up more than threshold screen size.
132                 && orientationHelper.getDecoratedEnd(firstChild) > (
133                 mRecyclerView.getHeight() * LONG_ITEM_END_VISIBLE_THRESHOLD)) {
134             return null;
135         }
136 
137         @NonNull View lastVisibleChild = Objects.requireNonNull(
138                 layoutManager.getChildAt(childCount - 1));
139 
140         // Check if the last child visible is the last item in the list.
141         boolean lastItemVisible =
142                 layoutManager.getPosition(lastVisibleChild) == layoutManager.getItemCount() - 1;
143 
144         // If it is, then check how much of that view is visible.
145         float lastItemPercentageVisible = lastItemVisible
146                 ? getPercentageVisible(lastVisibleChild, orientationHelper) : 0;
147 
148         View closestChild = null;
149         int closestDistanceToStart = Integer.MAX_VALUE;
150         float closestPercentageVisible = 0.f;
151 
152         // Iterate to find the child closest to the top and more than half way visible.
153         for (int i = 0; i < childCount; i++) {
154             View child = layoutManager.getChildAt(i);
155             int startOffset = orientationHelper.getDecoratedStart(child);
156 
157             if (Math.abs(startOffset) < closestDistanceToStart) {
158                 float percentageVisible = getPercentageVisible(child, orientationHelper);
159 
160                 if (percentageVisible > VIEW_VISIBLE_THRESHOLD
161                         && percentageVisible > closestPercentageVisible) {
162                     closestDistanceToStart = startOffset;
163                     closestChild = child;
164                     closestPercentageVisible = percentageVisible;
165                 }
166             }
167         }
168 
169         View childToReturn = closestChild;
170 
171         // If closestChild is null, then that means we were unable to find a closest child that
172         // is over the VIEW_VISIBLE_THRESHOLD. This could happen if the views are larger than
173         // the given area. In this case, consider returning the lastVisibleChild so that the screen
174         // scrolls. Also, check if the last item should be displayed anyway if it is mostly visible.
175         if ((childToReturn == null
176                 || (lastItemVisible && lastItemPercentageVisible > closestPercentageVisible))) {
177             childToReturn = lastVisibleChild;
178         }
179 
180         // Return null if the childToReturn is not valid. This allows the user to scroll freely
181         // with no snapping. This can allow them to see the entire view.
182         return isValidSnapView(childToReturn, orientationHelper) ? childToReturn : null;
183     }
184 
distanceToTopMargin(@onNull View targetView, OrientationHelper helper)185     private static int distanceToTopMargin(@NonNull View targetView, OrientationHelper helper) {
186         final int childTop = helper.getDecoratedStart(targetView);
187         final int containerTop = helper.getStartAfterPadding();
188         return childTop - containerTop;
189     }
190 
191     /**
192      * Returns whether or not the given View is a valid snapping view. A view is considered valid
193      * for snapping if it can fit entirely within the height of the RecyclerView it is contained
194      * within.
195      *
196      * <p>If the view is larger than the RecyclerView, then it might not want to be snapped to
197      * to allow the user to scroll and see the rest of the View.
198      *
199      * @param view   The view to determine the snapping potential.
200      * @param helper The {@link OrientationHelper} associated with the current RecyclerView.
201      * @return {@code true} if the given view is a valid snapping view; {@code false} otherwise.
202      */
isValidSnapView(View view, OrientationHelper helper)203     private static boolean isValidSnapView(View view, OrientationHelper helper) {
204         return helper.getDecoratedMeasurement(view) <= helper.getTotalSpace();
205     }
206 
207     /**
208      * Returns the percentage of the given view that is visible, relative to its containing
209      * RecyclerView.
210      *
211      * @param view   The View to get the percentage visible of.
212      * @param helper An {@link OrientationHelper} to aid with calculation.
213      * @return A float indicating the percentage of the given view that is visible.
214      */
getPercentageVisible(View view, OrientationHelper helper)215     static float getPercentageVisible(View view, OrientationHelper helper) {
216         int start = helper.getStartAfterPadding();
217         int end = helper.getEndAfterPadding();
218 
219         int viewStart = helper.getDecoratedStart(view);
220         int viewEnd = helper.getDecoratedEnd(view);
221 
222         if (viewStart >= start && viewEnd <= end) {
223             // The view is within the bounds of the RecyclerView, so it's fully visible.
224             return 1.f;
225         } else if (viewEnd <= start) {
226             // The view is above the visible area of the RecyclerView.
227             return 0;
228         } else if (viewStart >= end) {
229             // The view is below the visible area of the RecyclerView.
230             return 0;
231         } else if (viewStart <= start && viewEnd >= end) {
232             // The view is larger than the height of the RecyclerView.
233             return ((float) end - start) / helper.getDecoratedMeasurement(view);
234         } else if (viewStart < start) {
235             // The view is above the start of the RecyclerView.
236             return ((float) viewEnd - start) / helper.getDecoratedMeasurement(view);
237         } else {
238             // The view is below the end of the RecyclerView.
239             return ((float) end - viewStart) / helper.getDecoratedMeasurement(view);
240         }
241     }
242 
243     @Override
attachToRecyclerView(@ullable RecyclerView recyclerView)244     public void attachToRecyclerView(@Nullable RecyclerView recyclerView) {
245         super.attachToRecyclerView(recyclerView);
246         mRecyclerView = recyclerView;
247     }
248 
249     /**
250      * Returns a scroller specific to this {@code PagedSnapHelper}. This scroller is used for all
251      * smooth scrolling operations, including flings.
252      *
253      * @param layoutManager The {@link LayoutManager} associated with the attached
254      *                      {@link RecyclerView}.
255      * @return a {@link RecyclerView.SmoothScroller} which will handle the scrolling.
256      */
257     @Override
createScroller(@onNull LayoutManager layoutManager)258     protected RecyclerView.SmoothScroller createScroller(@NonNull LayoutManager layoutManager) {
259         return new CarUiSmoothScroller(mContext);
260     }
261 
262     /**
263      * Calculate the estimated scroll distance in each direction given velocities on both axes.
264      * This method will clamp the maximum scroll distance so that a single fling will never scroll
265      * more than one page.
266      *
267      * @param velocityX Fling velocity on the horizontal axis.
268      * @param velocityY Fling velocity on the vertical axis.
269      * @return An array holding the calculated distances in x and y directions respectively.
270      */
271     @Override
calculateScrollDistance(int velocityX, int velocityY)272     public int[] calculateScrollDistance(int velocityX, int velocityY) {
273         int[] outDist = super.calculateScrollDistance(velocityX, velocityY);
274 
275         if (mRecyclerView == null) {
276             return outDist;
277         }
278 
279         LayoutManager layoutManager = mRecyclerView.getLayoutManager();
280         if (layoutManager == null || layoutManager.getChildCount() == 0) {
281             return outDist;
282         }
283 
284         int lastChildPosition = isAtEnd(layoutManager) ? 0 : layoutManager.getChildCount() - 1;
285 
286         OrientationHelper orientationHelper = getOrientationHelper(layoutManager);
287         @NonNull View lastChild = Objects.requireNonNull(
288                 layoutManager.getChildAt(lastChildPosition));
289         float percentageVisible = getPercentageVisible(lastChild, orientationHelper);
290 
291         int maxDistance = layoutManager.getHeight();
292         if (percentageVisible > 0.f) {
293             // The max and min distance is the total height of the RecyclerView minus the height of
294             // the last child. This ensures that each scroll will never scroll more than a single
295             // page on the RecyclerView. That is, the max scroll will make the last child the
296             // first child and vice versa when scrolling the opposite way.
297             maxDistance -= layoutManager.getDecoratedMeasuredHeight(lastChild);
298         }
299 
300         int minDistance = -maxDistance;
301 
302         outDist[0] = clamp(outDist[0], minDistance, maxDistance);
303         outDist[1] = clamp(outDist[1], minDistance, maxDistance);
304 
305         return outDist;
306     }
307 
308     /**
309      * Estimates a position to which CarUiSnapHelper will try to snap to for a requested scroll
310      * distance.
311      *
312      * @param helper         The {@link OrientationHelper} that is created from the LayoutManager.
313      * @param scrollDistance The intended scroll distance.
314      *
315      * @return The diff between the target snap position and the current position.
316      */
estimateNextPositionDiffForScrollDistance(OrientationHelper helper, int scrollDistance)317     public int estimateNextPositionDiffForScrollDistance(OrientationHelper helper,
318             int scrollDistance) {
319         float distancePerChild = computeDistancePerChild(helper.getLayoutManager(), helper);
320         if (distancePerChild <= 0) {
321             return 0;
322         }
323         return Math.round(scrollDistance / distancePerChild);
324     }
325 
326     /**
327      * This method is taken verbatim from the [androidx] {@link LinearSnapHelper} private method
328      * implementation.
329      *
330      * Computes an average pixel value to pass a single child.
331      * <p>
332      * Returns a negative value if it cannot be calculated.
333      *
334      * @param layoutManager The {@link RecyclerView.LayoutManager} associated with the attached
335      *                      {@link RecyclerView}.
336      * @param helper        The relevant {@link OrientationHelper} for the attached
337      *                      {@link RecyclerView.LayoutManager}.
338      *
339      * @return A float value that is the average number of pixels needed to scroll by one view in
340      * the relevant direction.
341      */
computeDistancePerChild(RecyclerView.LayoutManager layoutManager, OrientationHelper helper)342     private float computeDistancePerChild(RecyclerView.LayoutManager layoutManager,
343             OrientationHelper helper) {
344         View minPosView = null;
345         View maxPosView = null;
346         int minPos = Integer.MAX_VALUE;
347         int maxPos = Integer.MIN_VALUE;
348         int childCount = layoutManager.getChildCount();
349         if (childCount == 0) {
350             return 1;
351         }
352 
353         for (int i = 0; i < childCount; i++) {
354             View child = layoutManager.getChildAt(i);
355             final int pos = layoutManager.getPosition(child);
356             if (pos == RecyclerView.NO_POSITION) {
357                 continue;
358             }
359             if (pos < minPos) {
360                 minPos = pos;
361                 minPosView = child;
362             }
363             if (pos > maxPos) {
364                 maxPos = pos;
365                 maxPosView = child;
366             }
367         }
368         if (minPosView == null || maxPosView == null) {
369             return 1;
370         }
371         int start = Math.min(helper.getDecoratedStart(minPosView),
372                 helper.getDecoratedStart(maxPosView));
373         int end = Math.max(helper.getDecoratedEnd(minPosView),
374                 helper.getDecoratedEnd(maxPosView));
375         int distance = end - start;
376         if (distance == 0) {
377             return 0;
378         }
379         return 1f * distance / ((maxPos - minPos) + 1);
380     }
381 
382     /**
383      * Returns {@code true} if the RecyclerView is completely displaying the first item.
384      */
isAtStart(@ullable LayoutManager layoutManager)385     public boolean isAtStart(@Nullable LayoutManager layoutManager) {
386         if (layoutManager == null || layoutManager.getChildCount() == 0) {
387             return true;
388         }
389 
390         @NonNull View firstChild = Objects.requireNonNull(layoutManager.getChildAt(0));
391         OrientationHelper orientationHelper =
392                 layoutManager.canScrollVertically() ? getVerticalHelper(layoutManager)
393                         : getHorizontalHelper(layoutManager);
394 
395         // Check that the first child is completely visible and is the first item in the list.
396         return orientationHelper.getDecoratedStart(firstChild)
397                 >= orientationHelper.getStartAfterPadding() && layoutManager.getPosition(firstChild)
398                 == 0;
399     }
400 
401     /**
402      * Returns {@code true} if the RecyclerView is completely displaying the last item.
403      */
isAtEnd(@ullable LayoutManager layoutManager)404     public boolean isAtEnd(@Nullable LayoutManager layoutManager) {
405         if (layoutManager == null || layoutManager.getChildCount() == 0) {
406             return true;
407         }
408 
409         int childCount = layoutManager.getChildCount();
410         OrientationHelper orientationHelper =
411                 layoutManager.canScrollVertically() ? getVerticalHelper(layoutManager)
412                         : getHorizontalHelper(layoutManager);
413 
414         @NonNull View lastVisibleChild = Objects.requireNonNull(
415                 layoutManager.getChildAt(childCount - 1));
416 
417         // The list has reached the bottom if the last child that is visible is the last item
418         // in the list and it's fully shown.
419         return layoutManager.getPosition(lastVisibleChild) == (layoutManager.getItemCount() - 1)
420                 && layoutManager.getDecoratedBottom(lastVisibleChild)
421                 <= orientationHelper.getEndAfterPadding();
422     }
423 
424     /**
425      * Returns an {@link OrientationHelper} that corresponds to the current scroll direction of the
426      * given {@link LayoutManager}.
427      */
428     @NonNull
getOrientationHelper(@onNull LayoutManager layoutManager)429     private OrientationHelper getOrientationHelper(@NonNull LayoutManager layoutManager) {
430         return layoutManager.canScrollVertically()
431                 ? getVerticalHelper(layoutManager)
432                 : getHorizontalHelper(layoutManager);
433     }
434 
435     @NonNull
getVerticalHelper(@onNull LayoutManager layoutManager)436     private OrientationHelper getVerticalHelper(@NonNull LayoutManager layoutManager) {
437         if (mVerticalHelper == null || mVerticalHelper.getLayoutManager() != layoutManager) {
438             mVerticalHelper = OrientationHelper.createVerticalHelper(layoutManager);
439         }
440         return mVerticalHelper;
441     }
442 
443     @NonNull
getHorizontalHelper(@onNull LayoutManager layoutManager)444     private OrientationHelper getHorizontalHelper(@NonNull LayoutManager layoutManager) {
445         if (mHorizontalHelper == null || mHorizontalHelper.getLayoutManager() != layoutManager) {
446             mHorizontalHelper = OrientationHelper.createHorizontalHelper(layoutManager);
447         }
448         return mHorizontalHelper;
449     }
450 
451     /**
452      * Ensures that the given value falls between the range given by the min and max values. This
453      * method does not check that the min value is greater than or equal to the max value. If the
454      * parameters are not well-formed, this method's behavior is undefined.
455      *
456      * @param value The value to clamp.
457      * @param min   The minimum value the given value can be.
458      * @param max   The maximum value the given value can be.
459      * @return A number that falls between {@code min} or {@code max} or one of those values if the
460      * given value is less than or greater than {@code min} and {@code max} respectively.
461      */
clamp(int value, int min, int max)462     private static int clamp(int value, int min, int max) {
463         return Math.max(min, Math.min(max, value));
464     }
465 }
466