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