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.annotation.SuppressLint; 19 import android.content.Context; 20 import android.content.res.Resources; 21 import android.graphics.Point; 22 import android.graphics.Rect; 23 import android.support.v7.widget.RecyclerView; 24 import android.text.Selection; 25 import android.text.SpannableStringBuilder; 26 import android.text.method.TextKeyListener; 27 import android.util.AttributeSet; 28 import android.view.KeyEvent; 29 import android.view.LayoutInflater; 30 import android.view.MotionEvent; 31 import android.view.View; 32 import android.view.ViewConfiguration; 33 34 import com.android.launcher3.AppInfo; 35 import com.android.launcher3.BaseContainerView; 36 import com.android.launcher3.BubbleTextView; 37 import com.android.launcher3.CellLayout; 38 import com.android.launcher3.DeleteDropTarget; 39 import com.android.launcher3.DeviceProfile; 40 import com.android.launcher3.DragSource; 41 import com.android.launcher3.DropTarget; 42 import com.android.launcher3.ExtendedEditText; 43 import com.android.launcher3.Folder; 44 import com.android.launcher3.ItemInfo; 45 import com.android.launcher3.Launcher; 46 import com.android.launcher3.LauncherTransitionable; 47 import com.android.launcher3.R; 48 import com.android.launcher3.Utilities; 49 import com.android.launcher3.Workspace; 50 import com.android.launcher3.util.ComponentKey; 51 52 import java.nio.charset.Charset; 53 import java.nio.charset.CharsetEncoder; 54 import java.util.ArrayList; 55 import java.util.List; 56 57 58 59 /** 60 * A merge algorithm that merges every section indiscriminately. 61 */ 62 final class FullMergeAlgorithm implements AlphabeticalAppsList.MergeAlgorithm { 63 64 @Override 65 public boolean continueMerging(AlphabeticalAppsList.SectionInfo section, 66 AlphabeticalAppsList.SectionInfo withSection, 67 int sectionAppCount, int numAppsPerRow, int mergeCount) { 68 // Don't merge the predicted apps 69 if (section.firstAppItem.viewType != AllAppsGridAdapter.ICON_VIEW_TYPE) { 70 return false; 71 } 72 // Otherwise, merge every other section 73 return true; 74 } 75 } 76 77 /** 78 * The logic we use to merge multiple sections. We only merge sections when their final row 79 * contains less than a certain number of icons, and stop at a specified max number of merges. 80 * In addition, we will try and not merge sections that identify apps from different scripts. 81 */ 82 final class SimpleSectionMergeAlgorithm implements AlphabeticalAppsList.MergeAlgorithm { 83 84 private int mMinAppsPerRow; 85 private int mMinRowsInMergedSection; 86 private int mMaxAllowableMerges; 87 private CharsetEncoder mAsciiEncoder; 88 89 public SimpleSectionMergeAlgorithm(int minAppsPerRow, int minRowsInMergedSection, int maxNumMerges) { 90 mMinAppsPerRow = minAppsPerRow; 91 mMinRowsInMergedSection = minRowsInMergedSection; 92 mMaxAllowableMerges = maxNumMerges; 93 mAsciiEncoder = Charset.forName("US-ASCII").newEncoder(); 94 } 95 96 @Override 97 public boolean continueMerging(AlphabeticalAppsList.SectionInfo section, 98 AlphabeticalAppsList.SectionInfo withSection, 99 int sectionAppCount, int numAppsPerRow, int mergeCount) { 100 // Don't merge the predicted apps 101 if (section.firstAppItem.viewType != AllAppsGridAdapter.ICON_VIEW_TYPE) { 102 return false; 103 } 104 105 // Continue merging if the number of hanging apps on the final row is less than some 106 // fixed number (ragged), the merged rows has yet to exceed some minimum row count, 107 // and while the number of merged sections is less than some fixed number of merges 108 int rows = sectionAppCount / numAppsPerRow; 109 int cols = sectionAppCount % numAppsPerRow; 110 111 // Ensure that we do not merge across scripts, currently we only allow for english and 112 // native scripts so we can test if both can just be ascii encoded 113 boolean isCrossScript = false; 114 if (section.firstAppItem != null && withSection.firstAppItem != null) { 115 isCrossScript = mAsciiEncoder.canEncode(section.firstAppItem.sectionName) != 116 mAsciiEncoder.canEncode(withSection.firstAppItem.sectionName); 117 } 118 return (0 < cols && cols < mMinAppsPerRow) && 119 rows < mMinRowsInMergedSection && 120 mergeCount < mMaxAllowableMerges && 121 !isCrossScript; 122 } 123 } 124 125 /** 126 * The all apps view container. 127 */ 128 public class AllAppsContainerView extends BaseContainerView implements DragSource, 129 LauncherTransitionable, View.OnTouchListener, View.OnLongClickListener, 130 AllAppsSearchBarController.Callbacks { 131 132 private static final int MIN_ROWS_IN_MERGED_SECTION_PHONE = 3; 133 private static final int MAX_NUM_MERGES_PHONE = 2; 134 135 private final Launcher mLauncher; 136 private final AlphabeticalAppsList mApps; 137 private final AllAppsGridAdapter mAdapter; 138 private final RecyclerView.LayoutManager mLayoutManager; 139 private final RecyclerView.ItemDecoration mItemDecoration; 140 141 // The computed bounds of the container 142 private final Rect mContentBounds = new Rect(); 143 144 private AllAppsRecyclerView mAppsRecyclerView; 145 private AllAppsSearchBarController mSearchBarController; 146 147 private View mSearchContainer; 148 private ExtendedEditText mSearchInput; 149 private HeaderElevationController mElevationController; 150 151 private SpannableStringBuilder mSearchQueryBuilder = null; 152 153 private int mSectionNamesMargin; 154 private int mNumAppsPerRow; 155 private int mNumPredictedAppsPerRow; 156 private int mRecyclerViewTopBottomPadding; 157 // This coordinate is relative to this container view 158 private final Point mBoundsCheckLastTouchDownPos = new Point(-1, -1); 159 // This coordinate is relative to its parent 160 private final Point mIconLastTouchPos = new Point(); 161 162 public AllAppsContainerView(Context context) { 163 this(context, null); 164 } 165 166 public AllAppsContainerView(Context context, AttributeSet attrs) { 167 this(context, attrs, 0); 168 } 169 170 public AllAppsContainerView(Context context, AttributeSet attrs, int defStyleAttr) { 171 super(context, attrs, defStyleAttr); 172 Resources res = context.getResources(); 173 174 mLauncher = (Launcher) context; 175 mSectionNamesMargin = res.getDimensionPixelSize(R.dimen.all_apps_grid_view_start_margin); 176 mApps = new AlphabeticalAppsList(context); 177 mAdapter = new AllAppsGridAdapter(mLauncher, mApps, this, mLauncher, this); 178 mApps.setAdapter(mAdapter); 179 mLayoutManager = mAdapter.getLayoutManager(); 180 mItemDecoration = mAdapter.getItemDecoration(); 181 mRecyclerViewTopBottomPadding = 182 res.getDimensionPixelSize(R.dimen.all_apps_list_top_bottom_padding); 183 184 mSearchQueryBuilder = new SpannableStringBuilder(); 185 Selection.setSelection(mSearchQueryBuilder, 0); 186 } 187 188 /** 189 * Sets the current set of predicted apps. 190 */ 191 public void setPredictedApps(List<ComponentKey> apps) { 192 mApps.setPredictedApps(apps); 193 } 194 195 /** 196 * Sets the current set of apps. 197 */ 198 public void setApps(List<AppInfo> apps) { 199 mApps.setApps(apps); 200 } 201 202 /** 203 * Adds new apps to the list. 204 */ 205 public void addApps(List<AppInfo> apps) { 206 mApps.addApps(apps); 207 } 208 209 /** 210 * Updates existing apps in the list 211 */ 212 public void updateApps(List<AppInfo> apps) { 213 mApps.updateApps(apps); 214 } 215 216 /** 217 * Removes some apps from the list. 218 */ 219 public void removeApps(List<AppInfo> apps) { 220 mApps.removeApps(apps); 221 } 222 223 /** 224 * Sets the search bar that shows above the a-z list. 225 */ 226 public void setSearchBarController(AllAppsSearchBarController searchController) { 227 if (mSearchBarController != null) { 228 throw new RuntimeException("Expected search bar controller to only be set once"); 229 } 230 mSearchBarController = searchController; 231 mSearchBarController.initialize(mApps, mSearchInput, mLauncher, this); 232 mAdapter.setSearchController(mSearchBarController); 233 234 updateBackgroundAndPaddings(); 235 } 236 237 /** 238 * Scrolls this list view to the top. 239 */ 240 public void scrollToTop() { 241 mAppsRecyclerView.scrollToTop(); 242 } 243 244 /** 245 * Focuses the search field and begins an app search. 246 */ 247 public void startAppsSearch() { 248 if (mSearchBarController != null) { 249 mSearchBarController.focusSearchField(); 250 } 251 } 252 253 /** 254 * Resets the state of AllApps. 255 */ 256 public void reset() { 257 // Reset the search bar and base recycler view after transitioning home 258 mSearchBarController.reset(); 259 mAppsRecyclerView.reset(); 260 } 261 262 @Override 263 protected void onFinishInflate() { 264 super.onFinishInflate(); 265 266 // This is a focus listener that proxies focus from a view into the list view. This is to 267 // work around the search box from getting first focus and showing the cursor. 268 getContentView().setOnFocusChangeListener(new View.OnFocusChangeListener() { 269 @Override 270 public void onFocusChange(View v, boolean hasFocus) { 271 if (hasFocus) { 272 mAppsRecyclerView.requestFocus(); 273 } 274 } 275 }); 276 277 mSearchContainer = findViewById(R.id.search_container); 278 mSearchInput = (ExtendedEditText) findViewById(R.id.search_box_input); 279 mElevationController = Utilities.ATLEAST_LOLLIPOP 280 ? new HeaderElevationController.ControllerVL(mSearchContainer) 281 : new HeaderElevationController.ControllerV16(mSearchContainer); 282 283 // Load the all apps recycler view 284 mAppsRecyclerView = (AllAppsRecyclerView) findViewById(R.id.apps_list_view); 285 mAppsRecyclerView.setApps(mApps); 286 mAppsRecyclerView.setLayoutManager(mLayoutManager); 287 mAppsRecyclerView.setAdapter(mAdapter); 288 mAppsRecyclerView.setHasFixedSize(true); 289 mAppsRecyclerView.addOnScrollListener(mElevationController); 290 mAppsRecyclerView.setElevationController(mElevationController); 291 292 if (mItemDecoration != null) { 293 mAppsRecyclerView.addItemDecoration(mItemDecoration); 294 } 295 296 // Precalculate the prediction icon and normal icon sizes 297 LayoutInflater layoutInflater = LayoutInflater.from(getContext()); 298 final int widthMeasureSpec = MeasureSpec.makeMeasureSpec( 299 getResources().getDisplayMetrics().widthPixels, MeasureSpec.AT_MOST); 300 final int heightMeasureSpec = MeasureSpec.makeMeasureSpec( 301 getResources().getDisplayMetrics().heightPixels, MeasureSpec.AT_MOST); 302 303 BubbleTextView icon = (BubbleTextView) layoutInflater.inflate( 304 R.layout.all_apps_icon, this, false); 305 icon.applyDummyInfo(); 306 icon.measure(widthMeasureSpec, heightMeasureSpec); 307 BubbleTextView predIcon = (BubbleTextView) layoutInflater.inflate( 308 R.layout.all_apps_prediction_bar_icon, this, false); 309 predIcon.applyDummyInfo(); 310 predIcon.measure(widthMeasureSpec, heightMeasureSpec); 311 mAppsRecyclerView.setPremeasuredIconHeights(predIcon.getMeasuredHeight(), 312 icon.getMeasuredHeight()); 313 314 updateBackgroundAndPaddings(); 315 } 316 317 @Override 318 public void onBoundsChanged(Rect newBounds) { 319 mLauncher.updateOverlayBounds(newBounds); 320 } 321 322 @Override 323 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 324 mContentBounds.set(mContentPadding.left, mContentPadding.top, 325 MeasureSpec.getSize(widthMeasureSpec) - mContentPadding.right, 326 MeasureSpec.getSize(heightMeasureSpec) - mContentPadding.bottom); 327 328 // Update the number of items in the grid before we measure the view 329 // TODO: mSectionNamesMargin is currently 0, but also account for it, 330 // if it's enabled in the future. 331 int availableWidth = (!mContentBounds.isEmpty() ? mContentBounds.width() : 332 MeasureSpec.getSize(widthMeasureSpec)) 333 - 2 * mAppsRecyclerView.getMaxScrollbarWidth(); 334 DeviceProfile grid = mLauncher.getDeviceProfile(); 335 grid.updateAppsViewNumCols(getResources(), availableWidth); 336 if (mNumAppsPerRow != grid.allAppsNumCols || 337 mNumPredictedAppsPerRow != grid.allAppsNumPredictiveCols) { 338 mNumAppsPerRow = grid.allAppsNumCols; 339 mNumPredictedAppsPerRow = grid.allAppsNumPredictiveCols; 340 341 // If there is a start margin to draw section names, determine how we are going to merge 342 // app sections 343 boolean mergeSectionsFully = mSectionNamesMargin == 0 || !grid.isPhone; 344 AlphabeticalAppsList.MergeAlgorithm mergeAlgorithm = mergeSectionsFully ? 345 new FullMergeAlgorithm() : 346 new SimpleSectionMergeAlgorithm((int) Math.ceil(mNumAppsPerRow / 2f), 347 MIN_ROWS_IN_MERGED_SECTION_PHONE, MAX_NUM_MERGES_PHONE); 348 349 mAppsRecyclerView.setNumAppsPerRow(grid, mNumAppsPerRow); 350 mAdapter.setNumAppsPerRow(mNumAppsPerRow); 351 mApps.setNumAppsPerRow(mNumAppsPerRow, mNumPredictedAppsPerRow, mergeAlgorithm); 352 353 if (mNumAppsPerRow > 0) { 354 int iconSize = availableWidth / mNumAppsPerRow; 355 int iconSpacing = (iconSize - grid.allAppsIconSizePx) / 2; 356 mSearchInput.setPaddingRelative(iconSpacing, 0, iconSpacing, 0); 357 } 358 } 359 360 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 361 } 362 363 /** 364 * Update the background and padding of the Apps view and children. Instead of insetting the 365 * container view, we inset the background and padding of the recycler view to allow for the 366 * recycler view to handle touch events (for fast scrolling) all the way to the edge. 367 */ 368 @Override 369 protected void onUpdateBgPadding(Rect padding, Rect bgPadding) { 370 mAppsRecyclerView.updateBackgroundPadding(bgPadding); 371 mAdapter.updateBackgroundPadding(bgPadding); 372 mElevationController.updateBackgroundPadding(bgPadding); 373 374 // Pad the recycler view by the background padding plus the start margin (for the section 375 // names) 376 int maxScrollBarWidth = mAppsRecyclerView.getMaxScrollbarWidth(); 377 int startInset = Math.max(mSectionNamesMargin, maxScrollBarWidth); 378 int topBottomPadding = mRecyclerViewTopBottomPadding; 379 if (Utilities.isRtl(getResources())) { 380 mAppsRecyclerView.setPadding(padding.left + maxScrollBarWidth, 381 topBottomPadding, padding.right + startInset, topBottomPadding); 382 } else { 383 mAppsRecyclerView.setPadding(padding.left + startInset, topBottomPadding, 384 padding.right + maxScrollBarWidth, topBottomPadding); 385 } 386 387 MarginLayoutParams lp = (MarginLayoutParams) mSearchContainer.getLayoutParams(); 388 lp.leftMargin = padding.left; 389 lp.rightMargin = padding.right; 390 mSearchContainer.setLayoutParams(lp); 391 } 392 393 @Override 394 public boolean dispatchKeyEvent(KeyEvent event) { 395 // Determine if the key event was actual text, if so, focus the search bar and then dispatch 396 // the key normally so that it can process this key event 397 if (!mSearchBarController.isSearchFieldFocused() && 398 event.getAction() == KeyEvent.ACTION_DOWN) { 399 final int unicodeChar = event.getUnicodeChar(); 400 final boolean isKeyNotWhitespace = unicodeChar > 0 && 401 !Character.isWhitespace(unicodeChar) && !Character.isSpaceChar(unicodeChar); 402 if (isKeyNotWhitespace) { 403 boolean gotKey = TextKeyListener.getInstance().onKeyDown(this, mSearchQueryBuilder, 404 event.getKeyCode(), event); 405 if (gotKey && mSearchQueryBuilder.length() > 0) { 406 mSearchBarController.focusSearchField(); 407 } 408 } 409 } 410 411 return super.dispatchKeyEvent(event); 412 } 413 414 @Override 415 public boolean onInterceptTouchEvent(MotionEvent ev) { 416 return handleTouchEvent(ev); 417 } 418 419 @SuppressLint("ClickableViewAccessibility") 420 @Override 421 public boolean onTouchEvent(MotionEvent ev) { 422 return handleTouchEvent(ev); 423 } 424 425 @SuppressLint("ClickableViewAccessibility") 426 @Override 427 public boolean onTouch(View v, MotionEvent ev) { 428 switch (ev.getAction()) { 429 case MotionEvent.ACTION_DOWN: 430 case MotionEvent.ACTION_MOVE: 431 mIconLastTouchPos.set((int) ev.getX(), (int) ev.getY()); 432 break; 433 } 434 return false; 435 } 436 437 @Override 438 public boolean onLongClick(View v) { 439 // Return early if this is not initiated from a touch 440 if (!v.isInTouchMode()) return false; 441 // When we have exited all apps or are in transition, disregard long clicks 442 if (!mLauncher.isAppsViewVisible() || 443 mLauncher.getWorkspace().isSwitchingState()) return false; 444 // Return if global dragging is not enabled 445 if (!mLauncher.isDraggingEnabled()) return false; 446 447 // Start the drag 448 mLauncher.getWorkspace().beginDragShared(v, mIconLastTouchPos, this, false); 449 // Enter spring loaded mode 450 mLauncher.enterSpringLoadedDragMode(); 451 452 return false; 453 } 454 455 @Override 456 public boolean supportsFlingToDelete() { 457 return true; 458 } 459 460 @Override 461 public boolean supportsAppInfoDropTarget() { 462 return true; 463 } 464 465 @Override 466 public boolean supportsDeleteDropTarget() { 467 return false; 468 } 469 470 @Override 471 public float getIntrinsicIconScaleFactor() { 472 DeviceProfile grid = mLauncher.getDeviceProfile(); 473 return (float) grid.allAppsIconSizePx / grid.iconSizePx; 474 } 475 476 @Override 477 public void onFlingToDeleteCompleted() { 478 // We just dismiss the drag when we fling, so cleanup here 479 mLauncher.exitSpringLoadedDragModeDelayed(true, 480 Launcher.EXIT_SPRINGLOADED_MODE_SHORT_TIMEOUT, null); 481 mLauncher.unlockScreenOrientation(false); 482 } 483 484 @Override 485 public void onDropCompleted(View target, DropTarget.DragObject d, boolean isFlingToDelete, 486 boolean success) { 487 if (isFlingToDelete || !success || (target != mLauncher.getWorkspace() && 488 !(target instanceof DeleteDropTarget) && !(target instanceof Folder))) { 489 // Exit spring loaded mode if we have not successfully dropped or have not handled the 490 // drop in Workspace 491 mLauncher.exitSpringLoadedDragModeDelayed(true, 492 Launcher.EXIT_SPRINGLOADED_MODE_SHORT_TIMEOUT, null); 493 } 494 mLauncher.unlockScreenOrientation(false); 495 496 // Display an error message if the drag failed due to there not being enough space on the 497 // target layout we were dropping on. 498 if (!success) { 499 boolean showOutOfSpaceMessage = false; 500 if (target instanceof Workspace) { 501 int currentScreen = mLauncher.getCurrentWorkspaceScreen(); 502 Workspace workspace = (Workspace) target; 503 CellLayout layout = (CellLayout) workspace.getChildAt(currentScreen); 504 ItemInfo itemInfo = (ItemInfo) d.dragInfo; 505 if (layout != null) { 506 showOutOfSpaceMessage = 507 !layout.findCellForSpan(null, itemInfo.spanX, itemInfo.spanY); 508 } 509 } 510 if (showOutOfSpaceMessage) { 511 mLauncher.showOutOfSpaceMessage(false); 512 } 513 514 d.deferDragViewCleanupPostAnimation = false; 515 } 516 } 517 518 @Override 519 public void onLauncherTransitionPrepare(Launcher l, boolean animated, boolean toWorkspace) { 520 // Do nothing 521 } 522 523 @Override 524 public void onLauncherTransitionStart(Launcher l, boolean animated, boolean toWorkspace) { 525 // Do nothing 526 } 527 528 @Override 529 public void onLauncherTransitionStep(Launcher l, float t) { 530 // Do nothing 531 } 532 533 @Override 534 public void onLauncherTransitionEnd(Launcher l, boolean animated, boolean toWorkspace) { 535 if (toWorkspace) { 536 reset(); 537 } 538 } 539 540 /** 541 * Handles the touch events to dismiss all apps when clicking outside the bounds of the 542 * recycler view. 543 */ 544 private boolean handleTouchEvent(MotionEvent ev) { 545 DeviceProfile grid = mLauncher.getDeviceProfile(); 546 int x = (int) ev.getX(); 547 int y = (int) ev.getY(); 548 549 switch (ev.getAction()) { 550 case MotionEvent.ACTION_DOWN: 551 if (!mContentBounds.isEmpty()) { 552 // Outset the fixed bounds and check if the touch is outside all apps 553 Rect tmpRect = new Rect(mContentBounds); 554 tmpRect.inset(-grid.allAppsIconSizePx / 2, 0); 555 if (ev.getX() < tmpRect.left || ev.getX() > tmpRect.right) { 556 mBoundsCheckLastTouchDownPos.set(x, y); 557 return true; 558 } 559 } else { 560 // Check if the touch is outside all apps 561 if (ev.getX() < getPaddingLeft() || 562 ev.getX() > (getWidth() - getPaddingRight())) { 563 mBoundsCheckLastTouchDownPos.set(x, y); 564 return true; 565 } 566 } 567 break; 568 case MotionEvent.ACTION_UP: 569 if (mBoundsCheckLastTouchDownPos.x > -1) { 570 ViewConfiguration viewConfig = ViewConfiguration.get(getContext()); 571 float dx = ev.getX() - mBoundsCheckLastTouchDownPos.x; 572 float dy = ev.getY() - mBoundsCheckLastTouchDownPos.y; 573 float distance = (float) Math.hypot(dx, dy); 574 if (distance < viewConfig.getScaledTouchSlop()) { 575 // The background was clicked, so just go home 576 Launcher launcher = (Launcher) getContext(); 577 launcher.showWorkspace(true); 578 return true; 579 } 580 } 581 // Fall through 582 case MotionEvent.ACTION_CANCEL: 583 mBoundsCheckLastTouchDownPos.set(-1, -1); 584 break; 585 } 586 return false; 587 } 588 589 @Override 590 public void onSearchResult(String query, ArrayList<ComponentKey> apps) { 591 if (apps != null) { 592 if (mApps.setOrderedFilter(apps)) { 593 mAppsRecyclerView.onSearchResultsChanged(); 594 } 595 mAdapter.setLastSearchQuery(query); 596 } 597 } 598 599 @Override 600 public void clearSearchResult() { 601 if (mApps.setOrderedFilter(null)) { 602 mAppsRecyclerView.onSearchResultsChanged(); 603 } 604 605 // Clear the search query 606 mSearchQueryBuilder.clear(); 607 mSearchQueryBuilder.clearSpans(); 608 Selection.setSelection(mSearchQueryBuilder, 0); 609 } 610 } 611