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