• 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 
17 package com.android.car.apps.common.widget;
18 
19 import android.content.Context;
20 import android.view.View;
21 
22 import androidx.annotation.NonNull;
23 import androidx.annotation.Nullable;
24 import androidx.recyclerview.widget.LinearSnapHelper;
25 import androidx.recyclerview.widget.OrientationHelper;
26 import androidx.recyclerview.widget.RecyclerView;
27 import androidx.recyclerview.widget.RecyclerView.LayoutManager;
28 
29 import com.android.car.apps.common.util.PagedSmoothScroller;
30 
31 /**
32  * Inspired by {@link androidx.car.widget.PagedSnapHelper}
33  *
34  * Extension of a {@link LinearSnapHelper} that will snap to the start of the target child view to
35  * the start of the attached {@link RecyclerView}. The start of the view is defined as the top
36  * if the RecyclerView is scrolling vertically; it is defined as the left (or right if RTL) if the
37  * RecyclerView is scrolling horizontally.
38  */
39 public class PagedSnapHelper extends LinearSnapHelper {
40 
41     private final Context mContext;
42     private RecyclerView mRecyclerView;
43 
PagedSnapHelper(Context context)44     public PagedSnapHelper(Context context) {
45         mContext = context;
46     }
47 
48     // Orientation helpers are lazily created per LayoutManager.
49     @Nullable private OrientationHelper mVerticalHelper;
50     @Nullable private OrientationHelper mHorizontalHelper;
51 
52     @Override
calculateDistanceToFinalSnap( @onNull LayoutManager layoutManager, @NonNull View targetView)53     public int[] calculateDistanceToFinalSnap(
54             @NonNull LayoutManager layoutManager, @NonNull View targetView) {
55         int[] out = new int[2];
56         if (layoutManager.canScrollHorizontally()) {
57             out[0] = distanceToTopMargin(layoutManager, targetView,
58                     getHorizontalHelper(layoutManager));
59         } else {
60             out[0] = 0;
61         }
62 
63         if (layoutManager.canScrollVertically()) {
64             out[1] = distanceToTopMargin(layoutManager, targetView,
65                     getVerticalHelper(layoutManager));
66         } else {
67             out[1] = 0;
68         }
69         return out;
70     }
71 
72     @Override
findSnapView(LayoutManager layoutManager)73     public View findSnapView(LayoutManager layoutManager) {
74         OrientationHelper orientationHelper = getOrientationHelper(layoutManager);
75 
76         if (mRecyclerView.computeVerticalScrollRange() - mRecyclerView.computeVerticalScrollOffset()
77                  <= orientationHelper.getTotalSpace()
78                 + mRecyclerView.getPaddingTop() + mRecyclerView.getPaddingBottom()) {
79             return null;
80         }
81 
82         if (layoutManager.canScrollVertically()) {
83             return findTopView(layoutManager, getVerticalHelper(layoutManager));
84         } else if (layoutManager.canScrollHorizontally()) {
85             return findTopView(layoutManager, getHorizontalHelper(layoutManager));
86         }
87         return null;
88     }
89 
distanceToTopMargin(@onNull LayoutManager layoutManager, @NonNull View targetView, OrientationHelper helper)90     private int distanceToTopMargin(@NonNull LayoutManager layoutManager,
91             @NonNull View targetView, OrientationHelper helper) {
92         final int childTop = helper.getDecoratedStart(targetView);
93         final int containerTop = helper.getStartAfterPadding();
94         return childTop - containerTop;
95     }
96 
97     /**
98      * Finds the view to snap to. The view to snap to is the child of the LayoutManager that is
99      * closest to the start of the RecyclerView. The "start" depends on if the LayoutManager
100      * is scrolling horizontally or vertically. If it is horizontally scrolling, then the
101      * start is the view on the left (right if RTL). Otherwise, it is the top-most view.
102      *
103      * @param layoutManager The current {@link RecyclerView.LayoutManager} for the attached
104      *                      RecyclerView.
105      * @return The View closest to the start of the RecyclerView.
106      */
findTopView(LayoutManager layoutManager, OrientationHelper helper)107     private View findTopView(LayoutManager layoutManager,
108             OrientationHelper helper) {
109         int childCount = layoutManager.getChildCount();
110         if (childCount == 0) {
111             return null;
112         }
113 
114         View closestChild = null;
115         int absClosest = Integer.MAX_VALUE;
116 
117         for (int i = 0; i < childCount; i++) {
118             View child = layoutManager.getChildAt(i);
119             if (child == null) continue;
120             int absDistance = Math.abs(distanceToTopMargin(layoutManager, child, helper));
121 
122             /** if child top is closer than previous closest, set it as closest  **/
123             if (absDistance < absClosest) {
124                 absClosest = absDistance;
125                 closestChild = child;
126             }
127         }
128         return closestChild;
129     }
130 
131     /**
132      * Returns the percentage of the given view that is visible, relative to its containing
133      * RecyclerView.
134      *
135      * @param view The View to get the percentage visible of.
136      * @param helper An {@link OrientationHelper} to aid with calculation.
137      * @return A float indicating the percentage of the given view that is visible.
138      */
getPercentageVisible(View view, OrientationHelper helper)139     private float getPercentageVisible(View view,
140             OrientationHelper helper) {
141 
142         int start = helper.getStartAfterPadding();
143         int end = helper.getEndAfterPadding();
144 
145         int viewHeight = helper.getDecoratedMeasurement(view);
146 
147         int viewStart = helper.getDecoratedStart(view);
148         int viewEnd = helper.getDecoratedEnd(view);
149 
150         if (viewEnd < start) {
151             // The is outside of the bounds of the recyclerView.
152             return 0f;
153         } else if (viewStart >= start && viewEnd <= end) {
154             // The view is within the bounds of the RecyclerView, so it's fully visible.
155             return 1.f;
156         } else if (viewStart <= start && viewEnd >= end) {
157             // The view is larger than the height of the RecyclerView.
158             return 1.f - ((float) (Math.abs(viewStart) + Math.abs(viewEnd)) / viewHeight);
159         } else if (viewStart < start) {
160             // The view is above the start of the RecyclerView, so subtract the start offset
161             // from the total height.
162             return 1.f - ((float) Math.abs(viewStart) / helper.getDecoratedMeasurement(view));
163         } else {
164             // The view is below the end of the RecyclerView, so subtract the end offset from the
165             // total height.
166             return 1.f - ((float) Math.abs(viewEnd) / helper.getDecoratedMeasurement(view));
167         }
168     }
169 
170     @Override
attachToRecyclerView(@ullable RecyclerView recyclerView)171     public void attachToRecyclerView(@Nullable RecyclerView recyclerView) {
172         mRecyclerView = recyclerView;
173         super.attachToRecyclerView(recyclerView);
174     }
175 
176     /**
177      * Returns a scroller specific to this {@code PagedSnapHelper}. This scroller is used for all
178      * smooth scrolling operations, including flings.
179      *
180      * @param layoutManager The {@link RecyclerView.LayoutManager} associated with the attached
181      *                      {@link RecyclerView}.
182      *
183      * @return a {@link RecyclerView.SmoothScroller} which will handle the scrolling.
184      */
185     @Override
createScroller(RecyclerView.LayoutManager layoutManager)186     protected RecyclerView.SmoothScroller createScroller(RecyclerView.LayoutManager layoutManager) {
187         return new PagedSmoothScroller(mContext);
188     }
189 
190     /**
191      * Calculate the estimated scroll distance in each direction given velocities on both axes.
192      * This method will clamp the maximum scroll distance so that a single fling will never scroll
193      * more than one page.
194      *
195      * @param velocityX Fling velocity on the horizontal axis.
196      * @param velocityY Fling velocity on the vertical axis.
197      * @return An array holding the calculated distances in x and y directions respectively.
198      */
199     @Override
calculateScrollDistance(int velocityX, int velocityY)200     public int[] calculateScrollDistance(int velocityX, int velocityY) {
201         int[] outDist = super.calculateScrollDistance(velocityX, velocityY);
202 
203         if (mRecyclerView == null) {
204             return outDist;
205         }
206 
207         RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
208         if (layoutManager == null || layoutManager.getChildCount() == 0) {
209             return outDist;
210         }
211 
212         int lastChildPosition = isAtEnd(layoutManager) ? 0 : layoutManager.getChildCount() - 1;
213 
214         OrientationHelper orientationHelper = getOrientationHelper(layoutManager);
215         View lastChild = layoutManager.getChildAt(lastChildPosition);
216         float percentageVisible = getPercentageVisible(lastChild, orientationHelper);
217 
218         int maxDistance = layoutManager.getHeight();
219         if (percentageVisible > 0.f) {
220             // The max and min distance is the total height of the RecyclerView minus the height of
221             // the last child. This ensures that each scroll will never scroll more than a single
222             // page on the RecyclerView. That is, the max scroll will make the last child the
223             // first child and vice versa when scrolling the opposite way.
224             maxDistance -= layoutManager.getDecoratedMeasuredHeight(lastChild);
225         }
226 
227         int minDistance = -maxDistance;
228 
229         outDist[0] = clamp(outDist[0], minDistance, maxDistance);
230         outDist[1] = clamp(outDist[1], minDistance, maxDistance);
231 
232         return outDist;
233     }
234 
235     /** Returns {@code true} if the RecyclerView is completely displaying the first item. */
isAtStart(RecyclerView.LayoutManager layoutManager)236     boolean isAtStart(RecyclerView.LayoutManager layoutManager) {
237         if (layoutManager == null || layoutManager.getChildCount() == 0) {
238             return true;
239         }
240 
241         View firstChild = layoutManager.getChildAt(0);
242         OrientationHelper orientationHelper = layoutManager.canScrollVertically()
243                 ? getVerticalHelper(layoutManager)
244                 : getHorizontalHelper(layoutManager);
245 
246         // Check that the first child is completely visible and is the first item in the list.
247         return orientationHelper.getDecoratedStart(firstChild)
248                 >= orientationHelper.getStartAfterPadding()
249                 && layoutManager.getPosition(firstChild) == 0;
250     }
251 
252     /** Returns {@code true} if the RecyclerView is completely displaying the last item. */
isAtEnd(RecyclerView.LayoutManager layoutManager)253     public boolean isAtEnd(RecyclerView.LayoutManager layoutManager) {
254         if (layoutManager == null || layoutManager.getChildCount() == 0) {
255             return true;
256         }
257 
258         int childCount = layoutManager.getChildCount();
259         OrientationHelper orientationHelper = layoutManager.canScrollVertically()
260                 ? getVerticalHelper(layoutManager)
261                 : getHorizontalHelper(layoutManager);
262 
263         View lastVisibleChild = layoutManager.getChildAt(childCount - 1);
264 
265         // The list has reached the bottom if the last child that is visible is the last item
266         // in the list and it's fully shown.
267         return layoutManager.getPosition(lastVisibleChild) == (layoutManager.getItemCount() - 1)
268                 && layoutManager.getDecoratedBottom(lastVisibleChild)
269                 <= orientationHelper.getEndAfterPadding();
270     }
271 
272     /**
273      * Returns an {@link OrientationHelper} that corresponds to the current scroll direction of
274      * the given {@link RecyclerView.LayoutManager}.
275      */
276     @NonNull
getOrientationHelper( @onNull RecyclerView.LayoutManager layoutManager)277     private OrientationHelper getOrientationHelper(
278             @NonNull RecyclerView.LayoutManager layoutManager) {
279         return layoutManager.canScrollVertically()
280                 ? getVerticalHelper(layoutManager)
281                 : getHorizontalHelper(layoutManager);
282     }
283 
284     @NonNull
getVerticalHelper(@onNull RecyclerView.LayoutManager layoutManager)285     private OrientationHelper getVerticalHelper(@NonNull RecyclerView.LayoutManager layoutManager) {
286         if (mVerticalHelper == null || mVerticalHelper.getLayoutManager() != layoutManager) {
287             mVerticalHelper = OrientationHelper.createVerticalHelper(layoutManager);
288         }
289         return mVerticalHelper;
290     }
291 
292     @NonNull
getHorizontalHelper( @onNull RecyclerView.LayoutManager layoutManager)293     private OrientationHelper getHorizontalHelper(
294             @NonNull RecyclerView.LayoutManager layoutManager) {
295         if (mHorizontalHelper == null || mHorizontalHelper.getLayoutManager() != layoutManager) {
296             mHorizontalHelper = OrientationHelper.createHorizontalHelper(layoutManager);
297         }
298         return mHorizontalHelper;
299     }
300 
301     /**
302      * Ensures that the given value falls between the range given by the min and max values. This
303      * method does not check that the min value is greater than or equal to the max value. If the
304      * parameters are not well-formed, this method's behavior is undefined.
305      *
306      * @param value The value to clamp.
307      * @param min The minimum value the given value can be.
308      * @param max The maximum value the given value can be.
309      * @return A number that falls between {@code min} or {@code max} or one of those values if the
310      * given value is less than or greater than {@code min} and {@code max} respectively.
311      */
clamp(int value, int min, int max)312     private static int clamp(int value, int min, int max) {
313         return Math.max(min, Math.min(max, value));
314     }
315 }
316