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