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