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.recyclerview;
18 
19 import static com.android.car.carlauncher.AppGridConstants.AppItemBoundDirection;
20 
21 import android.content.Context;
22 import android.graphics.Rect;
23 import android.view.LayoutInflater;
24 import android.view.View;
25 import android.view.ViewGroup;
26 import android.widget.LinearLayout;
27 
28 import androidx.annotation.NonNull;
29 import androidx.recyclerview.widget.DiffUtil;
30 import androidx.recyclerview.widget.RecyclerView;
31 
32 import com.android.car.carlauncher.AppGridFragment.Mode;
33 import com.android.car.carlauncher.AppGridPageSnapper;
34 import com.android.car.carlauncher.AppGridRecyclerView;
35 import com.android.car.carlauncher.AppItem;
36 import com.android.car.carlauncher.LauncherItem;
37 import com.android.car.carlauncher.LauncherItemDiffCallback;
38 import com.android.car.carlauncher.R;
39 import com.android.car.carlauncher.RecentAppsRowViewHolder;
40 import com.android.car.carlauncher.pagination.PageIndexingHelper;
41 
42 import java.util.ArrayList;
43 import java.util.List;
44 
45 /**
46  * The adapter that populates the grid view with apps.
47  */
48 public class AppGridAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
49     public static final int RECENT_APPS_TYPE = 1;
50     public static final int APP_ITEM_TYPE = 2;
51 
52     private static final String TAG = "AppGridAdapter";
53     private final Context mContext;
54     private final LayoutInflater mInflater;
55     private PageIndexingHelper mIndexingHelper;
56     private final AppItemViewHolder.AppItemDragCallback mDragCallback;
57     private final AppGridPageSnapper.AppGridPageSnapCallback mSnapCallback;
58     private int mNumOfCols;
59     private int mNumOfRows;
60     private int mAppItemWidth;
61     private int mAppItemHeight;
62     // grid order of the mLauncherItems used by DiffUtils in dispatchUpdates to animate UI updates
63     private final List<LauncherItem> mGridOrderedLauncherItems;
64 
65     private List<LauncherItem> mLauncherItems;
66     private boolean mIsDistractionOptimizationRequired;
67     private int mPageScrollDestination;
68     // the global bounding rect of the app grid including margins (excluding page indicator bar)
69     private Rect mPageBound;
70     private Mode mAppGridMode;
71 
72     private AppGridAdapterListener mAppGridAdapterListener;
73 
AppGridAdapter(Context context, AppItemViewHolder.AppItemDragCallback dragCallback, AppGridPageSnapper.AppGridPageSnapCallback snapCallback, AppGridAdapterListener appGridAdapterListener, Mode mode)74     public AppGridAdapter(Context context,
75             AppItemViewHolder.AppItemDragCallback dragCallback,
76             AppGridPageSnapper.AppGridPageSnapCallback snapCallback,
77             AppGridAdapterListener appGridAdapterListener,
78             Mode mode) {
79         mContext = context;
80         mInflater = LayoutInflater.from(context);
81         mDragCallback = dragCallback;
82         mSnapCallback = snapCallback;
83         mGridOrderedLauncherItems = new ArrayList<>();
84         mAppGridMode = mode;
85         mAppGridAdapterListener = appGridAdapterListener;
86     }
87 
88     @Override
onAttachedToRecyclerView(@onNull RecyclerView recyclerView)89     public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
90         if (!(recyclerView instanceof AppGridRecyclerView)) {
91             throw new IllegalStateException(
92                     "AppGridPageSnapper can only be used with AppGridRecyclerView.");
93         }
94         super.onAttachedToRecyclerView(recyclerView);
95         mNumOfRows = ((AppGridRecyclerView) recyclerView).getNumOfRows();
96         mNumOfCols = ((AppGridRecyclerView) recyclerView).getNumOfCols();
97         mIndexingHelper = ((AppGridRecyclerView) recyclerView).getPageIndexingHelper();
98         if (mIndexingHelper == null) {
99             throw new IllegalStateException(
100                     "AppGridRecyclerView's PageIndexingHelper is not initialized. "
101                             + "Please ensure the adapter is attached to AppGridRecyclerView only "
102                             + "after the bounds are ready.");
103         }
104     }
105 
106     /**
107      * Updates the dimension measurements of the app items and app grid bounds.
108      *
109      * To dispatch the UI changes, the recyclerview needs to call {@link RecyclerView#setAdapter}
110      * after calling this method to recreate the view holders.
111      */
updateViewHolderDimensions(Rect pageBound, int appItemWidth, int appItemHeight)112     public void updateViewHolderDimensions(Rect pageBound, int appItemWidth, int appItemHeight) {
113         mPageBound = pageBound;
114         mAppItemWidth = appItemWidth;
115         mAppItemHeight = appItemHeight;
116     }
117 
118     /**
119      * Updates the current driving restriction to {@code isDistractionOptimizationRequired}, then
120      * rebind the view holders.
121      */
setIsDistractionOptimizationRequired(boolean isDistractionOptimizationRequired)122     public void setIsDistractionOptimizationRequired(boolean isDistractionOptimizationRequired) {
123         mIsDistractionOptimizationRequired = isDistractionOptimizationRequired;
124         // notifyDataSetChanged will rebind distraction optimization to all app items
125         notifyDataSetChanged();
126     }
127 
128     /**
129      * Updates the current app grid mode to {@code mode}, then
130      * rebind the view holders.
131      */
setMode(Mode mode)132     public void setMode(Mode mode) {
133         mAppGridMode = mode;
134         notifyDataSetChanged();
135     }
136 
137     /**
138      * Sets a new list of launcher items to be displayed in the app grid.
139      * This should only be called by onChanged() in the observer as a response to data change in the
140      * adapter's LauncherViewModel.
141      */
setLauncherItems(List<? extends LauncherItem> launcherItems)142     public void setLauncherItems(List<? extends LauncherItem> launcherItems) {
143         mLauncherItems = (List<LauncherItem>) launcherItems;
144         int newSnapPosition = mSnapCallback.getSnapPosition();
145         if (newSnapPosition != 0 && newSnapPosition >= getItemCount()) {
146             // in case user deletes the only app item on the last page, the page should snap to the
147             // last icon on the second last page.
148             mSnapCallback.notifySnapToPosition(getItemCount() - 1);
149         }
150         dispatchUpdates();
151     }
152 
153     @Override
getItemViewType(int position)154     public int getItemViewType(int position) {
155         if (position == 0 && hasRecentlyUsedApps()) {
156             return RECENT_APPS_TYPE;
157         }
158         return APP_ITEM_TYPE;
159     }
160 
161     @Override
onCreateViewHolder(ViewGroup parent, int viewType)162     public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
163         if (viewType == RECENT_APPS_TYPE) {
164             View view =
165                     mInflater.inflate(R.layout.recent_apps_row, parent, /* attachToRoot= */ false);
166             return new RecentAppsRowViewHolder(view, mContext);
167         } else {
168             View view = mInflater.inflate(R.layout.app_item, parent, /* attachToRoot= */ false);
169             LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(
170                     mAppItemWidth, mAppItemHeight);
171             view.setLayoutParams(layoutParams);
172             return new AppItemViewHolder(view, mContext, mDragCallback, mSnapCallback);
173         }
174     }
175 
176     @Override
onBindViewHolder(RecyclerView.ViewHolder holder, int position)177     public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
178         AppItemViewHolder viewHolder = (AppItemViewHolder) holder;
179         LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(
180                 mAppItemWidth, mAppItemHeight);
181         holder.itemView.setLayoutParams(layoutParams);
182 
183         AppItemViewHolder.BindInfo bindInfo = new AppItemViewHolder.BindInfo(
184                 mIsDistractionOptimizationRequired, mPageBound, mAppGridMode);
185         int adapterIndex = mIndexingHelper.gridPositionToAdaptorIndex(position);
186         if (adapterIndex >= mLauncherItems.size()) {
187             // the current view holder is an empty item used to pad the last page.
188             viewHolder.bind(null, bindInfo);
189             return;
190         }
191         AppItem item = (AppItem) mLauncherItems.get(adapterIndex);
192         viewHolder.bind(item.getAppMetaData(), bindInfo);
193     }
194 
195     /**
196      * Sets the layout direction of the indexing helper.
197      */
setLayoutDirection(int layoutDirection)198     public void setLayoutDirection(int layoutDirection) {
199         mIndexingHelper.setLayoutDirection(layoutDirection);
200     }
201 
202     @Override
getItemCount()203     public int getItemCount() {
204         return getItemCountInternal(getLauncherItemsCount());
205     }
206 
207     /** Returns the item count including padded spaces on the last page */
getItemCountInternal(int unpaddedItemCount)208     private int getItemCountInternal(int unpaddedItemCount) {
209         // item count should always be a multiple of block size to ensure pagination
210         // is done properly. Extra spaces will have empty ViewHolders binded.
211         float pageFraction = (float) unpaddedItemCount / (mNumOfCols * mNumOfRows);
212         int pageCount = (int) Math.ceil(pageFraction);
213         return pageCount * mNumOfCols * mNumOfRows;
214     }
215 
getLauncherItemsCount()216     public int getLauncherItemsCount() {
217         return mLauncherItems == null ? 0 : mLauncherItems.size();
218     }
219 
220     /**
221      * Calculates the number of pages required to fit the all app items in the recycler view, with
222      * minimum of 1 page when no items have been added to data model.
223      */
getPageCount()224     public int getPageCount() {
225         return getPageCount(/* unpaddedItemCount */ getItemCount());
226     }
227 
228     /**
229      * Calculates the number of pages required to fit {@code unpaddedItemCount} number of app items.
230      */
getPageCount(int unpaddedItemCount)231     public int getPageCount(int unpaddedItemCount) {
232         int pageCount = getItemCountInternal(unpaddedItemCount) / (mNumOfRows * mNumOfCols);
233         return Math.max(pageCount, 1);
234     }
235 
236     /**
237      * Return the offset bound direction of the given gridPosition.
238      */
239     @AppItemBoundDirection
getOffsetBoundDirection(int gridPosition)240     public int getOffsetBoundDirection(int gridPosition) {
241         return mIndexingHelper.getOffsetBoundDirection(gridPosition);
242     }
243 
244 
hasRecentlyUsedApps()245     private boolean hasRecentlyUsedApps() {
246         // TODO (b/266988404): deprecate ui logic associated with recently used apps
247         return false;
248     }
249 
250     /**
251      * Sets the cached drag start position to {@code gridPosition}.
252      */
setDragStartPoint(int gridPosition)253     public void setDragStartPoint(int gridPosition) {
254         mPageScrollDestination = mIndexingHelper.roundToFirstIndexOnPage(gridPosition);
255         mSnapCallback.notifySnapToPosition(mPageScrollDestination);
256     }
257 
258     /**
259      * The magical function that writes the new order to proto datastore.
260      *
261      * There should not be any calls to update RecyclerView, such as via notifyDatasetChanged in
262      * this method since UI changes relating to data model should be handled by data observer.
263      */
moveAppItem(int gridPositionFrom, int gridPositionTo)264     public void moveAppItem(int gridPositionFrom, int gridPositionTo) {
265         int adaptorIndexFrom = mIndexingHelper.gridPositionToAdaptorIndex(gridPositionFrom);
266         int adaptorIndexTo = mIndexingHelper.gridPositionToAdaptorIndex(gridPositionTo);
267         mPageScrollDestination = mIndexingHelper.roundToFirstIndexOnPage(gridPositionTo);
268         mSnapCallback.notifySnapToPosition(mPageScrollDestination);
269 
270         // we need to move package to target index even if the from and to index are the same to
271         // ensure dispatchLayout gets called to re-anchor the recyclerview to current page.
272         AppItem selectedApp = (AppItem) mLauncherItems.get(adaptorIndexFrom);
273         mAppGridAdapterListener.onAppPositionChanged(adaptorIndexTo, selectedApp);
274     }
275 
276 
277     /**
278      * Updates page scroll destination after user has held the app item at the end of page for
279      * longer than the scroll dispatch threshold.
280      */
updatePageScrollDestination(boolean scrollToNextPage)281     public void updatePageScrollDestination(boolean scrollToNextPage) {
282         int newDestination;
283         int blockSize = mNumOfCols * mNumOfRows;
284         if (scrollToNextPage) {
285             newDestination = mPageScrollDestination + blockSize;
286             mPageScrollDestination = (newDestination >= getItemCount()) ? mPageScrollDestination :
287                     mIndexingHelper.roundToLastIndexOnPage(newDestination);
288         } else {
289             newDestination = mPageScrollDestination - blockSize;
290             mPageScrollDestination = (newDestination < 0) ? mPageScrollDestination :
291                     mIndexingHelper.roundToFirstIndexOnPage(newDestination);
292         }
293         mSnapCallback.notifySnapToPosition(mPageScrollDestination);
294     }
295 
296     /**
297      * Returns the last cached page scroll destination.
298      */
getPageScrollDestination()299     public int getPageScrollDestination() {
300         return mPageScrollDestination;
301     }
302 
303     /**
304      * Dispatches the paged reordering animation using async list differ, based on
305      * the current adapter order when the method is called.
306      */
dispatchUpdates()307     private void dispatchUpdates() {
308         List<LauncherItem> newAppsList = new ArrayList<>();
309         // we first need to pad the empty items on the last page
310         for (int i = 0; i < getItemCount(); i++) {
311             newAppsList.add(getEmptyLauncherItem());
312         }
313 
314         for (int i = 0; i < mLauncherItems.size(); i++) {
315             newAppsList.set(mIndexingHelper.adaptorIndexToGridPosition(i), mLauncherItems.get(i));
316         }
317         LauncherItemDiffCallback callback = new LauncherItemDiffCallback(
318                 /* oldList */ mGridOrderedLauncherItems, /* newList */ newAppsList);
319         DiffUtil.DiffResult result = DiffUtil.calculateDiff(callback);
320 
321         mGridOrderedLauncherItems.clear();
322         mGridOrderedLauncherItems.addAll(newAppsList);
323         result.dispatchUpdatesTo(this);
324     }
325 
getEmptyLauncherItem()326     private LauncherItem getEmptyLauncherItem() {
327         return new AppItem(/* packageName*/ "", /* className */ "", /* displayName */ "",
328                 /* appMetaData */ null);
329     }
330 
331     /**
332      * Returns the grid position of the next intended rotary focus view. This should follow the
333      * same logical order as the adapter indexes.
334      */
getNextRotaryFocus(int focusedGridPosition, int direction)335     public int getNextRotaryFocus(int focusedGridPosition, int direction) {
336         int targetAdapterIndex = mIndexingHelper.gridPositionToAdaptorIndex(focusedGridPosition)
337                 + (direction == View.FOCUS_FORWARD ? 1 : -1);
338         if (targetAdapterIndex < 0 || targetAdapterIndex >= getLauncherItemsCount()) {
339             return focusedGridPosition;
340         }
341         return mIndexingHelper.adaptorIndexToGridPosition(targetAdapterIndex);
342     }
343 
344     public interface AppGridAdapterListener {
onAppPositionChanged(int newPosition, AppItem appItem)345         void onAppPositionChanged(int newPosition, AppItem appItem);
346     }
347 }
348