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.allapps; 17 18 import static android.view.View.MeasureSpec.EXACTLY; 19 import static android.view.View.MeasureSpec.UNSPECIFIED; 20 import static android.view.View.MeasureSpec.makeMeasureSpec; 21 22 import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_ALLAPPS_VERTICAL_SWIPE_BEGIN; 23 import static com.android.launcher3.logging.StatsLogManager.LauncherEvent.LAUNCHER_ALLAPPS_VERTICAL_SWIPE_END; 24 import static com.android.launcher3.util.LogConfig.SEARCH_LOGGING; 25 import static com.android.launcher3.util.UiThreadHelper.hideKeyboardAsync; 26 27 import android.content.Context; 28 import android.content.res.Resources; 29 import android.graphics.Canvas; 30 import android.graphics.drawable.Drawable; 31 import android.util.AttributeSet; 32 import android.util.Log; 33 import android.util.SparseIntArray; 34 import android.view.MotionEvent; 35 import android.view.View; 36 37 import androidx.recyclerview.widget.RecyclerView; 38 39 import com.android.launcher3.BaseDraggingActivity; 40 import com.android.launcher3.BaseRecyclerView; 41 import com.android.launcher3.DeviceProfile; 42 import com.android.launcher3.LauncherAppState; 43 import com.android.launcher3.R; 44 import com.android.launcher3.Utilities; 45 import com.android.launcher3.config.FeatureFlags; 46 import com.android.launcher3.logging.StatsLogManager; 47 import com.android.launcher3.views.ActivityContext; 48 import com.android.launcher3.views.RecyclerViewFastScroller; 49 50 import java.util.ArrayList; 51 import java.util.List; 52 53 /** 54 * A RecyclerView with custom fast scroll support for the all apps view. 55 */ 56 public class AllAppsRecyclerView extends BaseRecyclerView { 57 private static final String TAG = "AllAppsContainerView"; 58 private static final boolean DEBUG = false; 59 private static final boolean DEBUG_LATENCY = Utilities.isPropertyEnabled(SEARCH_LOGGING); 60 61 private AlphabeticalAppsList mApps; 62 private final int mNumAppsPerRow; 63 64 // The specific view heights that we use to calculate scroll 65 private final SparseIntArray mViewHeights = new SparseIntArray(); 66 private final SparseIntArray mCachedScrollPositions = new SparseIntArray(); 67 private final AllAppsFastScrollHelper mFastScrollHelper; 68 69 70 private final AdapterDataObserver mObserver = new RecyclerView.AdapterDataObserver() { 71 public void onChanged() { 72 mCachedScrollPositions.clear(); 73 } 74 }; 75 76 // The empty-search result background 77 private AllAppsBackgroundDrawable mEmptySearchBackground; 78 private int mEmptySearchBackgroundTopOffset; 79 80 private ArrayList<View> mAutoSizedOverlays = new ArrayList<>(); 81 AllAppsRecyclerView(Context context)82 public AllAppsRecyclerView(Context context) { 83 this(context, null); 84 } 85 AllAppsRecyclerView(Context context, AttributeSet attrs)86 public AllAppsRecyclerView(Context context, AttributeSet attrs) { 87 this(context, attrs, 0); 88 } 89 AllAppsRecyclerView(Context context, AttributeSet attrs, int defStyleAttr)90 public AllAppsRecyclerView(Context context, AttributeSet attrs, int defStyleAttr) { 91 this(context, attrs, defStyleAttr, 0); 92 } 93 AllAppsRecyclerView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)94 public AllAppsRecyclerView(Context context, AttributeSet attrs, int defStyleAttr, 95 int defStyleRes) { 96 super(context, attrs, defStyleAttr); 97 Resources res = getResources(); 98 mEmptySearchBackgroundTopOffset = res.getDimensionPixelSize( 99 R.dimen.all_apps_empty_search_bg_top_offset); 100 mNumAppsPerRow = LauncherAppState.getIDP(context).numColumns; 101 mFastScrollHelper = new AllAppsFastScrollHelper(this); 102 } 103 104 /** 105 * Sets the list of apps in this view, used to determine the fastscroll position. 106 */ setApps(AlphabeticalAppsList apps)107 public void setApps(AlphabeticalAppsList apps) { 108 mApps = apps; 109 } 110 getApps()111 public AlphabeticalAppsList getApps() { 112 return mApps; 113 } 114 updatePoolSize()115 private void updatePoolSize() { 116 DeviceProfile grid = BaseDraggingActivity.fromContext(getContext()).getDeviceProfile(); 117 RecyclerView.RecycledViewPool pool = getRecycledViewPool(); 118 int approxRows = (int) Math.ceil(grid.availableHeightPx / grid.allAppsIconSizePx); 119 pool.setMaxRecycledViews(AllAppsGridAdapter.VIEW_TYPE_EMPTY_SEARCH, 1); 120 pool.setMaxRecycledViews(AllAppsGridAdapter.VIEW_TYPE_ALL_APPS_DIVIDER, 1); 121 pool.setMaxRecycledViews(AllAppsGridAdapter.VIEW_TYPE_SEARCH_MARKET, 1); 122 pool.setMaxRecycledViews(AllAppsGridAdapter.VIEW_TYPE_ICON, approxRows 123 * (mNumAppsPerRow + 1)); 124 125 mViewHeights.clear(); 126 mViewHeights.put(AllAppsGridAdapter.VIEW_TYPE_ICON, grid.allAppsCellHeightPx); 127 } 128 129 130 @Override onDraw(Canvas c)131 public void onDraw(Canvas c) { 132 // Draw the background 133 if (mEmptySearchBackground != null && mEmptySearchBackground.getAlpha() > 0) { 134 mEmptySearchBackground.draw(c); 135 } 136 if (DEBUG) { 137 Log.d(TAG, "onDraw at = " + System.currentTimeMillis()); 138 } 139 if (DEBUG_LATENCY) { 140 Log.d(SEARCH_LOGGING, 141 "-- Recycle view onDraw, time stamp = " + System.currentTimeMillis()); 142 } 143 super.onDraw(c); 144 } 145 146 @Override verifyDrawable(Drawable who)147 protected boolean verifyDrawable(Drawable who) { 148 return who == mEmptySearchBackground || super.verifyDrawable(who); 149 } 150 151 @Override onSizeChanged(int w, int h, int oldw, int oldh)152 protected void onSizeChanged(int w, int h, int oldw, int oldh) { 153 updateEmptySearchBackgroundBounds(); 154 updatePoolSize(); 155 for (int i = 0; i < mAutoSizedOverlays.size(); i++) { 156 View overlay = mAutoSizedOverlays.get(i); 157 overlay.measure(makeMeasureSpec(w, EXACTLY), makeMeasureSpec(w, EXACTLY)); 158 overlay.layout(0, 0, w, h); 159 } 160 } 161 162 /** 163 * Adds an overlay that automatically rescales with the recyclerview. 164 */ addAutoSizedOverlay(View overlay)165 public void addAutoSizedOverlay(View overlay) { 166 mAutoSizedOverlays.add(overlay); 167 getOverlay().add(overlay); 168 onSizeChanged(getWidth(), getHeight(), getWidth(), getHeight()); 169 } 170 171 /** 172 * Clears auto scaling overlay views added by #addAutoSizedOverlay 173 */ clearAutoSizedOverlays()174 public void clearAutoSizedOverlays() { 175 for (View v : mAutoSizedOverlays) { 176 getOverlay().remove(v); 177 } 178 mAutoSizedOverlays.clear(); 179 } 180 onSearchResultsChanged()181 public void onSearchResultsChanged() { 182 // Always scroll the view to the top so the user can see the changed results 183 scrollToTop(); 184 185 if (mApps.hasNoFilteredResults() && !FeatureFlags.ENABLE_DEVICE_SEARCH.get()) { 186 if (mEmptySearchBackground == null) { 187 mEmptySearchBackground = new AllAppsBackgroundDrawable(getContext()); 188 mEmptySearchBackground.setAlpha(0); 189 mEmptySearchBackground.setCallback(this); 190 updateEmptySearchBackgroundBounds(); 191 } 192 mEmptySearchBackground.animateBgAlpha(1f, 150); 193 } else if (mEmptySearchBackground != null) { 194 // For the time being, we just immediately hide the background to ensure that it does 195 // not overlap with the results 196 mEmptySearchBackground.setBgAlpha(0f); 197 } 198 } 199 200 @Override onScrollStateChanged(int state)201 public void onScrollStateChanged(int state) { 202 super.onScrollStateChanged(state); 203 204 StatsLogManager mgr = BaseDraggingActivity.fromContext(getContext()).getStatsLogManager(); 205 switch (state) { 206 case SCROLL_STATE_DRAGGING: 207 requestFocus(); 208 mgr.logger().sendToInteractionJankMonitor( 209 LAUNCHER_ALLAPPS_VERTICAL_SWIPE_BEGIN, this); 210 break; 211 case SCROLL_STATE_IDLE: 212 mgr.logger().sendToInteractionJankMonitor( 213 LAUNCHER_ALLAPPS_VERTICAL_SWIPE_END, this); 214 break; 215 } 216 } 217 218 @Override onInterceptTouchEvent(MotionEvent e)219 public boolean onInterceptTouchEvent(MotionEvent e) { 220 boolean result = super.onInterceptTouchEvent(e); 221 if (!result && e.getAction() == MotionEvent.ACTION_DOWN 222 && mEmptySearchBackground != null && mEmptySearchBackground.getAlpha() > 0) { 223 mEmptySearchBackground.setHotspot(e.getX(), e.getY()); 224 } 225 hideKeyboardAsync(ActivityContext.lookupContext(getContext()), 226 getApplicationWindowToken()); 227 return result; 228 } 229 230 /** 231 * Maps the touch (from 0..1) to the adapter position that should be visible. 232 */ 233 @Override scrollToPositionAtProgress(float touchFraction)234 public String scrollToPositionAtProgress(float touchFraction) { 235 int rowCount = mApps.getNumAppRows(); 236 if (rowCount == 0) { 237 return ""; 238 } 239 240 // Find the fastscroll section that maps to this touch fraction 241 List<AlphabeticalAppsList.FastScrollSectionInfo> fastScrollSections = 242 mApps.getFastScrollerSections(); 243 AlphabeticalAppsList.FastScrollSectionInfo lastInfo = fastScrollSections.get(0); 244 for (int i = 1; i < fastScrollSections.size(); i++) { 245 AlphabeticalAppsList.FastScrollSectionInfo info = fastScrollSections.get(i); 246 if (info.touchFraction > touchFraction) { 247 break; 248 } 249 lastInfo = info; 250 } 251 252 mFastScrollHelper.smoothScrollToSection(lastInfo); 253 return lastInfo.sectionName; 254 } 255 256 @Override onFastScrollCompleted()257 public void onFastScrollCompleted() { 258 super.onFastScrollCompleted(); 259 mFastScrollHelper.onFastScrollCompleted(); 260 } 261 262 @Override setAdapter(Adapter adapter)263 public void setAdapter(Adapter adapter) { 264 if (getAdapter() != null) { 265 getAdapter().unregisterAdapterDataObserver(mObserver); 266 } 267 super.setAdapter(adapter); 268 if (adapter != null) { 269 adapter.registerAdapterDataObserver(mObserver); 270 } 271 } 272 273 @Override getBottomFadingEdgeStrength()274 protected float getBottomFadingEdgeStrength() { 275 // No bottom fading edge. 276 return 0; 277 } 278 279 @Override isPaddingOffsetRequired()280 protected boolean isPaddingOffsetRequired() { 281 return true; 282 } 283 284 @Override getTopPaddingOffset()285 protected int getTopPaddingOffset() { 286 return -getPaddingTop(); 287 } 288 289 /** 290 * Updates the bounds for the scrollbar. 291 */ 292 @Override onUpdateScrollbar(int dy)293 public void onUpdateScrollbar(int dy) { 294 if (mApps == null) { 295 return; 296 } 297 List<AllAppsGridAdapter.AdapterItem> items = mApps.getAdapterItems(); 298 299 // Skip early if there are no items or we haven't been measured 300 if (items.isEmpty() || mNumAppsPerRow == 0) { 301 mScrollbar.setThumbOffsetY(-1); 302 return; 303 } 304 305 // Skip early if, there no child laid out in the container. 306 int scrollY = getCurrentScrollY(); 307 if (scrollY < 0) { 308 mScrollbar.setThumbOffsetY(-1); 309 return; 310 } 311 312 // Only show the scrollbar if there is height to be scrolled 313 int availableScrollBarHeight = getAvailableScrollBarHeight(); 314 int availableScrollHeight = getAvailableScrollHeight(); 315 if (availableScrollHeight <= 0) { 316 mScrollbar.setThumbOffsetY(-1); 317 return; 318 } 319 320 if (mScrollbar.isThumbDetached()) { 321 if (!mScrollbar.isDraggingThumb()) { 322 // Calculate the current scroll position, the scrollY of the recycler view accounts 323 // for the view padding, while the scrollBarY is drawn right up to the background 324 // padding (ignoring padding) 325 int scrollBarY = (int) 326 (((float) scrollY / availableScrollHeight) * availableScrollBarHeight); 327 328 int thumbScrollY = mScrollbar.getThumbOffsetY(); 329 int diffScrollY = scrollBarY - thumbScrollY; 330 if (diffScrollY * dy > 0f) { 331 // User is scrolling in the same direction the thumb needs to catch up to the 332 // current scroll position. We do this by mapping the difference in movement 333 // from the original scroll bar position to the difference in movement necessary 334 // in the detached thumb position to ensure that both speed towards the same 335 // position at either end of the list. 336 if (dy < 0) { 337 int offset = (int) ((dy * thumbScrollY) / (float) scrollBarY); 338 thumbScrollY += Math.max(offset, diffScrollY); 339 } else { 340 int offset = (int) ((dy * (availableScrollBarHeight - thumbScrollY)) / 341 (float) (availableScrollBarHeight - scrollBarY)); 342 thumbScrollY += Math.min(offset, diffScrollY); 343 } 344 thumbScrollY = Math.max(0, Math.min(availableScrollBarHeight, thumbScrollY)); 345 mScrollbar.setThumbOffsetY(thumbScrollY); 346 if (scrollBarY == thumbScrollY) { 347 mScrollbar.reattachThumbToScroll(); 348 } 349 } else { 350 // User is scrolling in an opposite direction to the direction that the thumb 351 // needs to catch up to the scroll position. Do nothing except for updating 352 // the scroll bar x to match the thumb width. 353 mScrollbar.setThumbOffsetY(thumbScrollY); 354 } 355 } 356 } else { 357 synchronizeScrollBarThumbOffsetToViewScroll(scrollY, availableScrollHeight); 358 } 359 } 360 361 @Override supportsFastScrolling()362 public boolean supportsFastScrolling() { 363 // Only allow fast scrolling when the user is not searching, since the results are not 364 // grouped in a meaningful order 365 return !mApps.hasFilter(); 366 } 367 368 @Override getCurrentScrollY()369 public int getCurrentScrollY() { 370 // Return early if there are no items or we haven't been measured 371 List<AllAppsGridAdapter.AdapterItem> items = mApps.getAdapterItems(); 372 if (items.isEmpty() || mNumAppsPerRow == 0 || getChildCount() == 0) { 373 return -1; 374 } 375 376 // Calculate the y and offset for the item 377 View child = getChildAt(0); 378 int position = getChildPosition(child); 379 if (position == NO_POSITION) { 380 return -1; 381 } 382 return getPaddingTop() + 383 getCurrentScrollY(position, getLayoutManager().getDecoratedTop(child)); 384 } 385 getCurrentScrollY(int position, int offset)386 public int getCurrentScrollY(int position, int offset) { 387 List<AllAppsGridAdapter.AdapterItem> items = mApps.getAdapterItems(); 388 AllAppsGridAdapter.AdapterItem posItem = position < items.size() 389 ? items.get(position) : null; 390 int y = mCachedScrollPositions.get(position, -1); 391 if (y < 0) { 392 y = 0; 393 for (int i = 0; i < position; i++) { 394 AllAppsGridAdapter.AdapterItem item = items.get(i); 395 if (AllAppsGridAdapter.isIconViewType(item.viewType)) { 396 // Break once we reach the desired row 397 if (posItem != null && posItem.viewType == item.viewType && 398 posItem.rowIndex == item.rowIndex) { 399 break; 400 } 401 // Otherwise, only account for the first icon in the row since they are the same 402 // size within a row 403 if (item.rowAppIndex == 0) { 404 y += mViewHeights.get(item.viewType, 0); 405 } 406 } else { 407 // Rest of the views span the full width 408 int elHeight = mViewHeights.get(item.viewType); 409 if (elHeight == 0) { 410 ViewHolder holder = findViewHolderForAdapterPosition(i); 411 if (holder == null) { 412 holder = getAdapter().createViewHolder(this, item.viewType); 413 getAdapter().onBindViewHolder(holder, i); 414 holder.itemView.measure(UNSPECIFIED, UNSPECIFIED); 415 elHeight = holder.itemView.getMeasuredHeight(); 416 417 getRecycledViewPool().putRecycledView(holder); 418 } else { 419 elHeight = holder.itemView.getMeasuredHeight(); 420 } 421 } 422 y += elHeight; 423 } 424 } 425 mCachedScrollPositions.put(position, y); 426 } 427 return y - offset; 428 } 429 430 /** 431 * Returns the available scroll height: 432 * AvailableScrollHeight = Total height of the all items - last page height 433 */ 434 @Override 435 protected int getAvailableScrollHeight() { 436 return getPaddingTop() + getCurrentScrollY(getAdapter().getItemCount(), 0) 437 - getHeight() + getPaddingBottom(); 438 } 439 440 public int getScrollBarTop() { 441 return getResources().getDimensionPixelOffset(R.dimen.all_apps_header_top_padding); 442 } 443 444 public RecyclerViewFastScroller getScrollbar() { 445 return mScrollbar; 446 } 447 448 /** 449 * Updates the bounds of the empty search background. 450 */ 451 private void updateEmptySearchBackgroundBounds() { 452 if (mEmptySearchBackground == null) { 453 return; 454 } 455 456 // Center the empty search background on this new view bounds 457 int x = (getMeasuredWidth() - mEmptySearchBackground.getIntrinsicWidth()) / 2; 458 int y = mEmptySearchBackgroundTopOffset; 459 mEmptySearchBackground.setBounds(x, y, 460 x + mEmptySearchBackground.getIntrinsicWidth(), 461 y + mEmptySearchBackground.getIntrinsicHeight()); 462 } 463 464 @Override 465 public boolean hasOverlappingRendering() { 466 return false; 467 } 468 469 /** 470 * Returns distance between left and right app icons 471 */ 472 public int getTabWidth() { 473 DeviceProfile grid = BaseDraggingActivity.fromContext(getContext()).getDeviceProfile(); 474 int totalWidth = getMeasuredWidth() - getPaddingLeft() - getPaddingRight(); 475 int iconPadding = totalWidth / grid.numShownAllAppsColumns - grid.allAppsIconSizePx; 476 return totalWidth - iconPadding - grid.allAppsIconDrawablePaddingPx; 477 } 478 } 479