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