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