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