1 /* 2 * Copyright (C) 2015 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 package com.android.launcher3.widget.picker; 17 18 import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_WIDGETSTRAY_APP_EXPANDED; 19 import static com.android.launcher3.recyclerview.ViewHolderBinder.POSITION_DEFAULT; 20 import static com.android.launcher3.recyclerview.ViewHolderBinder.POSITION_FIRST; 21 import static com.android.launcher3.recyclerview.ViewHolderBinder.POSITION_LAST; 22 import static com.android.launcher3.widget.BaseWidgetSheet.DEFAULT_MAX_HORIZONTAL_SPANS; 23 24 import android.content.Context; 25 import android.os.Process; 26 import android.util.Log; 27 import android.util.SparseArray; 28 import android.view.LayoutInflater; 29 import android.view.View; 30 import android.view.View.OnClickListener; 31 import android.view.View.OnLongClickListener; 32 import android.view.ViewGroup; 33 34 import androidx.annotation.NonNull; 35 import androidx.annotation.Nullable; 36 import androidx.annotation.Px; 37 import androidx.recyclerview.widget.DiffUtil; 38 import androidx.recyclerview.widget.DiffUtil.DiffResult; 39 import androidx.recyclerview.widget.LinearLayoutManager; 40 import androidx.recyclerview.widget.RecyclerView; 41 import androidx.recyclerview.widget.RecyclerView.Adapter; 42 import androidx.recyclerview.widget.RecyclerView.ViewHolder; 43 44 import com.android.launcher3.R; 45 import com.android.launcher3.model.data.PackageItemInfo; 46 import com.android.launcher3.recyclerview.ViewHolderBinder; 47 import com.android.launcher3.util.LabelComparator; 48 import com.android.launcher3.util.PackageUserKey; 49 import com.android.launcher3.views.ActivityContext; 50 import com.android.launcher3.widget.model.WidgetListSpaceEntry; 51 import com.android.launcher3.widget.model.WidgetsListBaseEntry; 52 import com.android.launcher3.widget.model.WidgetsListContentEntry; 53 import com.android.launcher3.widget.model.WidgetsListHeaderEntry; 54 import com.android.launcher3.widget.util.WidgetSizes; 55 56 import java.util.ArrayList; 57 import java.util.Arrays; 58 import java.util.Collections; 59 import java.util.Comparator; 60 import java.util.List; 61 import java.util.Map; 62 import java.util.OptionalInt; 63 import java.util.function.IntSupplier; 64 import java.util.function.Predicate; 65 import java.util.stream.Collectors; 66 import java.util.stream.IntStream; 67 68 /** 69 * Recycler view adapter for the widget tray. 70 * 71 * <p>This adapter supports view binding of subclasses of {@link WidgetsListBaseEntry}. There are 2 72 * subclasses: {@link WidgetsListHeader} & {@link WidgetsListContentEntry}. 73 * {@link WidgetsListHeader} entries are always visible in the recycler view. At most one 74 * {@link WidgetsListContentEntry} is shown in the recycler view at any time. Clicking a 75 * {@link WidgetsListHeader} will result in expanding / collapsing a corresponding 76 * {@link WidgetsListContentEntry} of the same app. 77 */ 78 public class WidgetsListAdapter extends Adapter<ViewHolder> implements OnHeaderClickListener { 79 80 private static final String TAG = "WidgetsListAdapter"; 81 private static final boolean DEBUG = false; 82 83 /** Uniquely identifies widgets list view type within the app. */ 84 public static final int VIEW_TYPE_WIDGETS_SPACE = R.id.view_type_widgets_space; 85 public static final int VIEW_TYPE_WIDGETS_LIST = R.id.view_type_widgets_list; 86 public static final int VIEW_TYPE_WIDGETS_HEADER = R.id.view_type_widgets_header; 87 88 private final Context mContext; 89 private final SparseArray<ViewHolderBinder> mViewHolderBinders = new SparseArray<>(); 90 private final WidgetListBaseRowEntryComparator mRowComparator = 91 new WidgetListBaseRowEntryComparator(); 92 @Nullable private final WidgetsFullSheet.HeaderChangeListener mHeaderChangeListener; 93 94 private final List<WidgetsListBaseEntry> mAllEntries = new ArrayList<>(); 95 private ArrayList<WidgetsListBaseEntry> mVisibleEntries = new ArrayList<>(); 96 @Nullable private PackageUserKey mWidgetsContentVisiblePackageUserKey = null; 97 98 private Predicate<WidgetsListBaseEntry> mHeaderAndSelectedContentFilter = entry -> 99 entry instanceof WidgetsListHeaderEntry 100 || PackageUserKey.fromPackageItemInfo(entry.mPkgItem) 101 .equals(mWidgetsContentVisiblePackageUserKey); 102 @Nullable private Predicate<WidgetsListBaseEntry> mFilter = null; 103 @Nullable private RecyclerView mRecyclerView; 104 @Nullable private PackageUserKey mPendingClickHeader; 105 @Px private int mMaxHorizontalSpan; 106 WidgetsListAdapter(Context context, LayoutInflater layoutInflater, IntSupplier emptySpaceHeightProvider, OnClickListener iconClickListener, OnLongClickListener iconLongClickListener, WidgetsFullSheet.HeaderChangeListener headerChangeListener)107 public WidgetsListAdapter(Context context, LayoutInflater layoutInflater, 108 IntSupplier emptySpaceHeightProvider, OnClickListener iconClickListener, 109 OnLongClickListener iconLongClickListener, 110 WidgetsFullSheet.HeaderChangeListener headerChangeListener) { 111 mHeaderChangeListener = headerChangeListener; 112 mContext = context; 113 mMaxHorizontalSpan = WidgetSizes.getWidgetSizePx( 114 ActivityContext.lookupContext(context).getDeviceProfile(), 115 DEFAULT_MAX_HORIZONTAL_SPANS, 1).getWidth(); 116 117 mViewHolderBinders.put( 118 VIEW_TYPE_WIDGETS_LIST, 119 new WidgetsListTableViewHolderBinder( 120 mContext, layoutInflater, iconClickListener, iconLongClickListener)); 121 mViewHolderBinders.put( 122 VIEW_TYPE_WIDGETS_HEADER, 123 new WidgetsListHeaderViewHolderBinder( 124 layoutInflater, /* onHeaderClickListener= */ this, 125 headerChangeListener != null)); 126 mViewHolderBinders.put( 127 VIEW_TYPE_WIDGETS_SPACE, 128 new WidgetsSpaceViewHolderBinder(emptySpaceHeightProvider)); 129 } 130 131 @Override onAttachedToRecyclerView(@onNull RecyclerView recyclerView)132 public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { 133 mRecyclerView = recyclerView; 134 } 135 136 @Override onDetachedFromRecyclerView(@onNull RecyclerView recyclerView)137 public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) { 138 mRecyclerView = null; 139 } 140 setFilter(Predicate<WidgetsListBaseEntry> filter)141 public void setFilter(Predicate<WidgetsListBaseEntry> filter) { 142 mFilter = filter; 143 } 144 145 @Override getItemCount()146 public int getItemCount() { 147 return mVisibleEntries.size(); 148 } 149 150 /** 151 * Returns true if the adapter has entries which will be visible to the user 152 */ hasVisibleEntries()153 public boolean hasVisibleEntries() { 154 // Account for the 1st space entry 155 return getItemCount() > 1; 156 } 157 158 /** Returns all items that will be drawn in a recycler view. */ getItems()159 public List<WidgetsListBaseEntry> getItems() { 160 return mVisibleEntries; 161 } 162 163 /** Gets the section name for {@link com.android.launcher3.views.RecyclerViewFastScroller}. */ getSectionName(int pos)164 public String getSectionName(int pos) { 165 return mVisibleEntries.get(pos).mTitleSectionName; 166 } 167 168 /** Updates the widget list based on {@code tempEntries}. */ setWidgets(List<WidgetsListBaseEntry> tempEntries)169 public void setWidgets(List<WidgetsListBaseEntry> tempEntries) { 170 mAllEntries.clear(); 171 mAllEntries.add(new WidgetListSpaceEntry()); 172 tempEntries.stream().sorted(mRowComparator).forEach(mAllEntries::add); 173 if (shouldClearVisibleEntries()) { 174 mVisibleEntries.clear(); 175 } 176 updateVisibleEntries(); 177 } 178 179 /** Updates the widget list based on {@code searchResults}. */ setWidgetsOnSearch(List<WidgetsListBaseEntry> searchResults)180 public void setWidgetsOnSearch(List<WidgetsListBaseEntry> searchResults) { 181 // Forget the expanded package every time widget list is refreshed in search mode. 182 mWidgetsContentVisiblePackageUserKey = null; 183 setWidgets(searchResults); 184 } 185 updateVisibleEntries()186 private void updateVisibleEntries() { 187 // Get the current top of the header with the matching key before adjusting the visible 188 // entries. 189 OptionalInt previousPositionForPackageUserKey = 190 getPositionForPackageUserKey(mPendingClickHeader); 191 OptionalInt topForPackageUserKey = 192 getOffsetForPosition(previousPositionForPackageUserKey); 193 194 List<WidgetsListBaseEntry> newVisibleEntries = mAllEntries.stream() 195 .filter(entry -> (((mFilter == null || mFilter.test(entry)) 196 && mHeaderAndSelectedContentFilter.test(entry)) 197 || entry instanceof WidgetListSpaceEntry) 198 && (mHeaderChangeListener == null 199 || !(entry instanceof WidgetsListContentEntry))) 200 .map(entry -> { 201 if (entry instanceof WidgetsListHeaderEntry 202 && matchesKey(entry, mWidgetsContentVisiblePackageUserKey)) { 203 // Adjust the original entries to expand headers for the selected content. 204 return ((WidgetsListHeaderEntry) entry).withWidgetListShown(); 205 } else if (entry instanceof WidgetsListContentEntry) { 206 // Adjust the original content entries to accommodate for the current 207 // maxSpanSize. 208 return ((WidgetsListContentEntry) entry).withMaxSpanSize( 209 mMaxHorizontalSpan); 210 } 211 return entry; 212 }) 213 .collect(Collectors.toList()); 214 215 DiffResult diffResult = DiffUtil.calculateDiff( 216 new WidgetsDiffCallback(mVisibleEntries, newVisibleEntries), false); 217 mVisibleEntries.clear(); 218 mVisibleEntries.addAll(newVisibleEntries); 219 diffResult.dispatchUpdatesTo(this); 220 221 if (mPendingClickHeader != null) { 222 // Get the position for the clicked header after adjusting the visible entries. The 223 // position may have changed if another header had previously been expanded. 224 OptionalInt positionForPackageUserKey = 225 getPositionForPackageUserKey(mPendingClickHeader); 226 scrollToPositionAndMaintainOffset(positionForPackageUserKey, topForPackageUserKey); 227 mPendingClickHeader = null; 228 } 229 } 230 231 /** Returns whether {@code entry} matches {@code key}. */ isHeaderForPackageUserKey( @onNull WidgetsListBaseEntry entry, @Nullable PackageUserKey key)232 private static boolean isHeaderForPackageUserKey( 233 @NonNull WidgetsListBaseEntry entry, @Nullable PackageUserKey key) { 234 return entry instanceof WidgetsListHeaderEntry && matchesKey(entry, key); 235 } 236 matchesKey(@onNull WidgetsListBaseEntry entry, @Nullable PackageUserKey key)237 private static boolean matchesKey(@NonNull WidgetsListBaseEntry entry, 238 @Nullable PackageUserKey key) { 239 if (key == null) return false; 240 return entry.mPkgItem.packageName.equals(key.mPackageName) 241 && entry.mPkgItem.widgetCategory == key.mWidgetCategory 242 && entry.mPkgItem.user.equals(key.mUser); 243 } 244 245 /** 246 * Resets any expanded widget header. 247 */ resetExpandedHeader()248 public void resetExpandedHeader() { 249 if (mWidgetsContentVisiblePackageUserKey != null) { 250 mWidgetsContentVisiblePackageUserKey = null; 251 updateVisibleEntries(); 252 } 253 } 254 255 @Override onBindViewHolder(ViewHolder holder, int position)256 public void onBindViewHolder(ViewHolder holder, int position) { 257 onBindViewHolder(holder, position, Collections.EMPTY_LIST); 258 } 259 260 @Override onBindViewHolder(ViewHolder holder, int pos, List<Object> payloads)261 public void onBindViewHolder(ViewHolder holder, int pos, List<Object> payloads) { 262 ViewHolderBinder viewHolderBinder = mViewHolderBinders.get(getItemViewType(pos)); 263 264 // The first entry has an empty space, count from second entries. 265 int listPos = (pos > 1) ? POSITION_DEFAULT : POSITION_FIRST; 266 if (pos == (getItemCount() - 1)) { 267 listPos |= POSITION_LAST; 268 } 269 viewHolderBinder.bindViewHolder(holder, mVisibleEntries.get(pos), listPos, payloads); 270 } 271 272 /** 273 * Selects the first visible header. This is used in search as we want to always select the 274 * first header in the new list that gets generated as we search. 275 */ selectFirstHeaderEntry()276 void selectFirstHeaderEntry() { 277 mVisibleEntries.stream() 278 .filter(entry -> entry instanceof WidgetsListHeaderEntry) 279 .findFirst() 280 .ifPresent(entry -> 281 onHeaderClicked(true, PackageUserKey.fromPackageItemInfo(entry.mPkgItem))); 282 } 283 284 @Override onCreateViewHolder(ViewGroup parent, int viewType)285 public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { 286 if (DEBUG) { 287 Log.v(TAG, "\nonCreateViewHolder"); 288 } 289 290 return mViewHolderBinders.get(viewType).newViewHolder(parent); 291 } 292 293 @Override onViewRecycled(ViewHolder holder)294 public void onViewRecycled(ViewHolder holder) { 295 mViewHolderBinders.get(holder.getItemViewType()).unbindViewHolder(holder); 296 } 297 298 @Override onFailedToRecycleView(ViewHolder holder)299 public boolean onFailedToRecycleView(ViewHolder holder) { 300 // If child views are animating, then the RecyclerView may choose not to recycle the view, 301 // causing extraneous onCreateViewHolder() calls. It is safe in this case to continue 302 // recycling this view, and take care in onViewRecycled() to cancel any existing 303 // animations. 304 return true; 305 } 306 307 @Override getItemId(int pos)308 public long getItemId(int pos) { 309 return Arrays.hashCode(new Object[]{ 310 mVisibleEntries.get(pos).mPkgItem.hashCode(), 311 getItemViewType(pos)}); 312 } 313 314 @Override getItemViewType(int pos)315 public int getItemViewType(int pos) { 316 WidgetsListBaseEntry entry = mVisibleEntries.get(pos); 317 if (entry instanceof WidgetsListContentEntry) { 318 return VIEW_TYPE_WIDGETS_LIST; 319 } else if (entry instanceof WidgetsListHeaderEntry) { 320 return VIEW_TYPE_WIDGETS_HEADER; 321 } else if (entry instanceof WidgetListSpaceEntry) { 322 return VIEW_TYPE_WIDGETS_SPACE; 323 } 324 throw new UnsupportedOperationException("ViewHolderBinder not found for " + entry); 325 } 326 327 @Override onHeaderClicked(boolean showWidgets, PackageUserKey packageUserKey)328 public void onHeaderClicked(boolean showWidgets, PackageUserKey packageUserKey) { 329 // Ignore invalid clicks, such as collapsing a package that isn't currently expanded. 330 if (!showWidgets && !packageUserKey.equals(mWidgetsContentVisiblePackageUserKey)) return; 331 332 if (mHeaderChangeListener != null 333 && packageUserKey.equals(mWidgetsContentVisiblePackageUserKey)) return; 334 335 if (showWidgets) { 336 mWidgetsContentVisiblePackageUserKey = packageUserKey; 337 ActivityContext.lookupContext(mContext) 338 .getStatsLogManager().logger().log(LAUNCHER_WIDGETSTRAY_APP_EXPANDED); 339 } else { 340 mWidgetsContentVisiblePackageUserKey = null; 341 } 342 343 // Store the header that was clicked so that its position will be maintained the next time 344 // we update the entries. 345 mPendingClickHeader = packageUserKey; 346 347 updateVisibleEntries(); 348 349 if (mHeaderChangeListener != null && mWidgetsContentVisiblePackageUserKey != null) { 350 mHeaderChangeListener.onHeaderChanged(mWidgetsContentVisiblePackageUserKey); 351 } 352 } 353 354 /** 355 * Returns the position of {@code key} in {@link #mVisibleEntries}, or empty if it's not 356 * present. 357 */ 358 @NonNull getPositionForPackageUserKey(@ullable PackageUserKey key)359 private OptionalInt getPositionForPackageUserKey(@Nullable PackageUserKey key) { 360 return IntStream.range(0, mVisibleEntries.size()) 361 .filter(index -> isHeaderForPackageUserKey(mVisibleEntries.get(index), key)) 362 .findFirst(); 363 } 364 365 /** 366 * Returns the top of {@code positionOptional} in the recycler view, or empty if its view 367 * can't be found for any reason, including the position not being currently visible. The 368 * returned value does not include the top padding of the recycler view. 369 */ getOffsetForPosition(OptionalInt positionOptional)370 private OptionalInt getOffsetForPosition(OptionalInt positionOptional) { 371 if (!positionOptional.isPresent() || mRecyclerView == null) return OptionalInt.empty(); 372 373 RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager(); 374 if (layoutManager == null) return OptionalInt.empty(); 375 376 View view = layoutManager.findViewByPosition(positionOptional.getAsInt()); 377 if (view == null) return OptionalInt.empty(); 378 379 return OptionalInt.of(layoutManager.getDecoratedTop(view)); 380 } 381 382 /** 383 * Scrolls to the selected header position with the provided offset. LinearLayoutManager 384 * scrolls the minimum distance necessary, so this will keep the selected header in place during 385 * clicks, without interrupting the animation. 386 * 387 * @param positionOptional The position too scroll to. No scrolling will be done if empty. 388 * @param offsetOptional The offset from the top to maintain. If empty, then the list will 389 * scroll to the top of the position. 390 */ scrollToPositionAndMaintainOffset( OptionalInt positionOptional, OptionalInt offsetOptional)391 private void scrollToPositionAndMaintainOffset( 392 OptionalInt positionOptional, 393 OptionalInt offsetOptional) { 394 if (!positionOptional.isPresent() || mRecyclerView == null) return; 395 int position = positionOptional.getAsInt(); 396 397 LinearLayoutManager layoutManager = (LinearLayoutManager) mRecyclerView.getLayoutManager(); 398 if (layoutManager == null) return; 399 400 if (position == mVisibleEntries.size() - 2 401 && mVisibleEntries.get(mVisibleEntries.size() - 1) 402 instanceof WidgetsListContentEntry) { 403 // If the selected header is in the last position and its content is showing, then 404 // scroll to the final position so the last list of widgets will show. 405 layoutManager.scrollToPosition(mVisibleEntries.size() - 1); 406 return; 407 } 408 409 // Scroll to the header view's current offset, accounting for the recycler view's padding. 410 // If the header view couldn't be found, then it will appear at the top of the list. 411 layoutManager.scrollToPositionWithOffset( 412 position, 413 offsetOptional.orElse(0) - mRecyclerView.getPaddingTop()); 414 } 415 416 /** 417 * Sets the max horizontal span in pixels that is allowed for grouping more than one widget in a 418 * table row. 419 */ setMaxHorizontalSpansPxPerRow(@x int maxHorizontalSpan)420 public void setMaxHorizontalSpansPxPerRow(@Px int maxHorizontalSpan) { 421 mMaxHorizontalSpan = maxHorizontalSpan; 422 updateVisibleEntries(); 423 } 424 425 /** 426 * Returns {@code true} if there is a change in {@link #mAllEntries} that results in an 427 * invalidation of {@link #mVisibleEntries}. e.g. there is change in the device language. 428 */ shouldClearVisibleEntries()429 private boolean shouldClearVisibleEntries() { 430 Map<PackageUserKey, PackageItemInfo> packagesInfo = 431 mAllEntries.stream() 432 .filter(entry -> entry instanceof WidgetsListHeaderEntry) 433 .map(entry -> entry.mPkgItem) 434 .collect(Collectors.toMap( 435 entry -> PackageUserKey.fromPackageItemInfo(entry), 436 entry -> entry)); 437 for (WidgetsListBaseEntry visibleEntry: mVisibleEntries) { 438 PackageUserKey key = PackageUserKey.fromPackageItemInfo(visibleEntry.mPkgItem); 439 PackageItemInfo packageItemInfo = packagesInfo.get(key); 440 if (packageItemInfo != null 441 && !visibleEntry.mPkgItem.title.equals(packageItemInfo.title)) { 442 return true; 443 } 444 } 445 return false; 446 } 447 448 /** Comparator for sorting WidgetListRowEntry based on package title. */ 449 public static class WidgetListBaseRowEntryComparator implements 450 Comparator<WidgetsListBaseEntry> { 451 452 private final LabelComparator mComparator = new LabelComparator(); 453 454 @Override compare(WidgetsListBaseEntry a, WidgetsListBaseEntry b)455 public int compare(WidgetsListBaseEntry a, WidgetsListBaseEntry b) { 456 int i = mComparator.compare(a.mPkgItem.title.toString(), b.mPkgItem.title.toString()); 457 if (i != 0) { 458 return i; 459 } 460 // Prioritize entries from current user over other users if the entries are same. 461 if (a.mPkgItem.user.equals(b.mPkgItem.user)) return 0; 462 if (a.mPkgItem.user.equals(Process.myUserHandle())) return -1; 463 return 1; 464 } 465 } 466 } 467