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