1 /* 2 * Copyright (C) 2023 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.carlauncher; 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.annotation.VisibleForTesting; 25 import androidx.recyclerview.widget.LinearSnapHelper; 26 import androidx.recyclerview.widget.OrientationHelper; 27 import androidx.recyclerview.widget.RecyclerView; 28 29 30 /** 31 * <p>Extension of a {@link LinearSnapHelper} that will snap to the next/previous page. 32 * for a horizontal's recycler view. 33 */ 34 public class AppGridPageSnapper extends LinearSnapHelper { 35 private final float mPageSnapThreshold; 36 private final float mFlingThreshold; 37 38 @NonNull 39 private final Context mContext; 40 private AppGridRecyclerView mRecyclerView; 41 private int mPrevFirstVisiblePos = 0; 42 private AppGridPageSnapCallback mSnapCallback; 43 AppGridPageSnapper( @onNull Context context, AppGridPageSnapCallback snapCallback)44 public AppGridPageSnapper( 45 @NonNull Context context, 46 AppGridPageSnapCallback snapCallback) { 47 mSnapCallback = snapCallback; 48 mContext = context; 49 mPageSnapThreshold = context.getResources().getFloat(R.dimen.page_snap_threshold); 50 mFlingThreshold = context.getResources().getFloat(R.dimen.fling_threshold); 51 } 52 53 // Orientation helpers are lazily created per LayoutManager. 54 @Nullable 55 private OrientationHelper mHorizontalHelper; 56 @Nullable 57 private OrientationHelper mVerticalHelper; 58 59 @VisibleForTesting 60 RecyclerView.OnFlingListener mOnFlingListener; 61 62 /** 63 * Finds the view to snap to. The view to snap can be either the current, next or previous page. 64 * Start is defined as the left if the orientation is horizontal and top if the orientation is 65 * vertical 66 */ 67 @Override 68 @Nullable findSnapView(@ullable RecyclerView.LayoutManager layoutManager)69 public View findSnapView(@Nullable RecyclerView.LayoutManager layoutManager) { 70 if (layoutManager == null || layoutManager.getChildCount() == 0) { 71 return null; 72 } 73 74 OrientationHelper orientationHelper = getOrientationHelper(layoutManager); 75 76 if (mRecyclerView == null) { 77 return null; 78 } 79 80 View currentPosView = getFirstMostVisibleChild(orientationHelper); 81 int adapterPos = findAdapterPosition(currentPosView); 82 int posToReturn; 83 int blockSize = mRecyclerView.getNumOfRows() * mRecyclerView.getNumOfCols(); 84 85 // In the case of swiping left, the current adapter position is smaller than the previous 86 // first visible position. In the case of swiping right, the current adapter position is 87 // greater than the previous first visible position. In this case, if the swipe is 88 // by only 1 column, the page should remain the same since we want to demonstrate some 89 // stickiness 90 if (adapterPos <= mPrevFirstVisiblePos 91 || (float) adapterPos % blockSize / blockSize < mPageSnapThreshold) { 92 posToReturn = adapterPos - adapterPos % blockSize; 93 } else { 94 // Snap to next page 95 posToReturn = (adapterPos / blockSize + 1) * blockSize + blockSize - 1; 96 } 97 handleScrollToPos(posToReturn, orientationHelper); 98 return null; 99 } 100 findAdapterPosition(View view)101 private int findAdapterPosition(View view) { 102 RecyclerView.ViewHolder holder = mRecyclerView.findContainingViewHolder(view); 103 return holder.getAbsoluteAdapterPosition(); 104 } 105 106 @VisibleForTesting findFirstItemOnNextPage(int adapterPos)107 int findFirstItemOnNextPage(int adapterPos) { 108 int blockSize = mRecyclerView.getNumOfRows() * mRecyclerView.getNumOfCols(); 109 return (adapterPos / blockSize + 1) * blockSize + blockSize - 1; 110 } 111 112 @VisibleForTesting findFirstItemOnPrevPage(int adapterPos)113 int findFirstItemOnPrevPage(int adapterPos) { 114 int blockSize = mRecyclerView.getNumOfRows() * mRecyclerView.getNumOfCols(); 115 return adapterPos - (adapterPos - 1) % blockSize - 1; 116 } 117 handleScrollToPos(int posToReturn, OrientationHelper orientationHelper)118 private void handleScrollToPos(int posToReturn, OrientationHelper orientationHelper) { 119 int blockSize = mRecyclerView.getNumOfRows() * mRecyclerView.getNumOfCols(); 120 mPrevFirstVisiblePos = posToReturn / blockSize * blockSize; 121 mRecyclerView.smoothScrollToPosition(posToReturn); 122 mSnapCallback.notifySnapToPosition(posToReturn); 123 124 // If there is a gap between the start of the first fully visible child and the start of 125 // the recycler view (this can happen after the swipe or when the swipe offset is too small 126 // such that the first fully visible item doesn't change), smooth scroll to make sure the 127 // gap no longer exists. 128 RecyclerView.ViewHolder childToReturn = mRecyclerView.findViewHolderForAdapterPosition( 129 posToReturn); 130 if (childToReturn != null) { 131 int start = orientationHelper.getStartAfterPadding(); 132 int viewStart = orientationHelper.getDecoratedStart(childToReturn.itemView); 133 if (viewStart - start > 0) { 134 if (mHorizontalHelper != null) { 135 mRecyclerView.smoothScrollBy(viewStart - start, 0); 136 } else { 137 mRecyclerView.smoothScrollBy(0, viewStart - start); 138 } 139 } 140 } 141 } 142 143 @NonNull getOrientationHelper( @onNull RecyclerView.LayoutManager layoutManager)144 private OrientationHelper getOrientationHelper( 145 @NonNull RecyclerView.LayoutManager layoutManager) { 146 return layoutManager.canScrollVertically() ? getVerticalHelper(layoutManager) 147 : getHorizontalHelper(layoutManager); 148 } 149 150 @NonNull getVerticalHelper( @onNull RecyclerView.LayoutManager layoutManager)151 private OrientationHelper getVerticalHelper( 152 @NonNull RecyclerView.LayoutManager layoutManager) { 153 if (mVerticalHelper == null || mVerticalHelper.getLayoutManager() != layoutManager) { 154 mVerticalHelper = OrientationHelper.createVerticalHelper(layoutManager); 155 } 156 return mVerticalHelper; 157 } 158 159 @NonNull getHorizontalHelper( @onNull RecyclerView.LayoutManager layoutManager)160 private OrientationHelper getHorizontalHelper( 161 @NonNull RecyclerView.LayoutManager layoutManager) { 162 if (mHorizontalHelper == null || mHorizontalHelper.getLayoutManager() != layoutManager) { 163 mHorizontalHelper = OrientationHelper.createHorizontalHelper(layoutManager); 164 } 165 return mHorizontalHelper; 166 } 167 168 /** 169 * Returns the percentage of the given view that is visible, relative to its containing 170 * RecyclerView. 171 * 172 * @param view The View to get the percentage visible of. 173 * @param helper An {@link OrientationHelper} to aid with calculation. 174 * @return A float indicating the percentage of the given view that is visible. 175 */ getPercentageVisible(@ullable View view, @NonNull OrientationHelper helper)176 static float getPercentageVisible(@Nullable View view, @NonNull OrientationHelper helper) { 177 if (view == null) { 178 return 0; 179 } 180 int start = helper.getStartAfterPadding(); 181 int end = helper.getEndAfterPadding(); 182 183 int viewStart = helper.getDecoratedStart(view); 184 int viewEnd = helper.getDecoratedEnd(view); 185 186 if (viewStart >= start && viewEnd <= end) { 187 // The view is within the bounds of the RecyclerView, so it's fully visible. 188 return 1.f; 189 } else if (viewEnd <= start) { 190 // The view is above the visible area of the RecyclerView. 191 return 0; 192 } else if (viewStart >= end) { 193 // The view is below the visible area of the RecyclerView. 194 return 0; 195 } else if (viewStart <= start && viewEnd >= end) { 196 // The view is larger than the height of the RecyclerView. 197 return ((float) end - start) / helper.getDecoratedMeasurement(view); 198 } else if (viewStart < start) { 199 // The view is above the start of the RecyclerView. 200 return ((float) viewEnd - start) / helper.getDecoratedMeasurement(view); 201 } else { 202 // The view is below the end of the RecyclerView. 203 return ((float) end - viewStart) / helper.getDecoratedMeasurement(view); 204 } 205 } 206 207 @Nullable getFirstMostVisibleChild(@onNull OrientationHelper helper)208 private View getFirstMostVisibleChild(@NonNull OrientationHelper helper) { 209 float mostVisiblePercent = 0; 210 View mostVisibleView = null; 211 for (int i = 0; i < mRecyclerView.getLayoutManager().getChildCount(); i++) { 212 View child = mRecyclerView.getLayoutManager().getChildAt(i); 213 float visiblePercentage = getPercentageVisible(child, helper); 214 if (visiblePercentage == 1f) { 215 mostVisibleView = child; 216 break; 217 } else if (visiblePercentage > mostVisiblePercent) { 218 mostVisiblePercent = visiblePercentage; 219 mostVisibleView = child; 220 } 221 } 222 return mostVisibleView; 223 } 224 225 @Override attachToRecyclerView(@ullable RecyclerView recyclerView)226 public void attachToRecyclerView(@Nullable RecyclerView recyclerView) { 227 if (recyclerView == null) { 228 return; 229 } 230 if (!(recyclerView instanceof AppGridRecyclerView)) { 231 throw new IllegalStateException( 232 "AppGridPageSnapper can only be used with AppGridRecyclerView."); 233 } 234 super.attachToRecyclerView(recyclerView); 235 mRecyclerView = (AppGridRecyclerView) recyclerView; 236 237 // When a fling happens, try to find the target snap view and go there. 238 mOnFlingListener = new RecyclerView.OnFlingListener() { 239 @Override 240 public boolean onFling(int velocityX, int velocityY) { 241 RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager(); 242 OrientationHelper orientationHelper = getOrientationHelper(layoutManager); 243 View currentPosView = getFirstMostVisibleChild(orientationHelper); 244 int adapterPos = findAdapterPosition(currentPosView); 245 int posToReturn = mPrevFirstVisiblePos; 246 if (velocityX > mFlingThreshold || velocityY > mFlingThreshold) { 247 posToReturn = findFirstItemOnNextPage(adapterPos); 248 } else if (velocityX < -mFlingThreshold || velocityY < -mFlingThreshold) { 249 posToReturn = findFirstItemOnPrevPage(adapterPos); 250 } 251 handleScrollToPos(posToReturn, orientationHelper); 252 return true; 253 } 254 }; 255 mRecyclerView.setOnFlingListener(mOnFlingListener); 256 } 257 258 @VisibleForTesting setOnFlingListener(RecyclerView.OnFlingListener onFlingListener)259 void setOnFlingListener(RecyclerView.OnFlingListener onFlingListener) { 260 mRecyclerView.setOnFlingListener(onFlingListener); 261 } 262 263 /** 264 * A Callback contract between all app grid components that causes triggers a scroll or snap 265 * behavior and its listener. 266 * 267 * Scrolling by user touch or by recyclerview during off page scroll should always cause a 268 * page snap, and it is up to the AppGridPageSnapCallback to notify the listener to cache that 269 * snapped index to allow user to return to that location when they trigger onResume. 270 */ 271 public static class AppGridPageSnapCallback { 272 private final PageSnapListener mSnapListener; 273 private int mSnapPosition; 274 private int mScrollState; 275 AppGridPageSnapCallback(PageSnapListener snapListener)276 public AppGridPageSnapCallback(PageSnapListener snapListener) { 277 mSnapListener = snapListener; 278 } 279 280 /** caches the most recent snap position and notifies the listener */ notifySnapToPosition(int gridPosition)281 public void notifySnapToPosition(int gridPosition) { 282 mSnapPosition = gridPosition; 283 mSnapListener.onSnapToPosition(gridPosition); 284 } 285 286 /** return the most recent cached snap position */ getSnapPosition()287 public int getSnapPosition() { 288 return mSnapPosition; 289 } 290 291 /** caches the current recent scroll state */ setScrollState(int newState)292 public void setScrollState(int newState) { 293 mScrollState = newState; 294 } 295 296 /** return the most recent scroll state */ getScrollState()297 public int getScrollState() { 298 return mScrollState; 299 } 300 } 301 302 /** 303 * Listener class that should be implemented by AppGridActivity. 304 */ 305 public interface PageSnapListener { 306 /** Listener method called during AppGridPageSnapCallback.notifySnapToPosition */ onSnapToPosition(int gridPosition)307 void onSnapToPosition(int gridPosition); 308 } 309 } 310