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