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