1 /* 2 * Copyright (C) 2008 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 17 package com.android.launcher3; 18 19 import android.annotation.TargetApi; 20 import android.content.Context; 21 import android.content.res.ColorStateList; 22 import android.content.res.Resources; 23 import android.content.res.Resources.Theme; 24 import android.content.res.TypedArray; 25 import android.graphics.Bitmap; 26 import android.graphics.Canvas; 27 import android.graphics.Region; 28 import android.graphics.drawable.BitmapDrawable; 29 import android.graphics.drawable.ColorDrawable; 30 import android.graphics.drawable.Drawable; 31 import android.os.Build; 32 import android.util.AttributeSet; 33 import android.util.SparseArray; 34 import android.util.TypedValue; 35 import android.view.KeyEvent; 36 import android.view.MotionEvent; 37 import android.view.View; 38 import android.view.ViewConfiguration; 39 import android.view.ViewParent; 40 import android.widget.TextView; 41 42 import com.android.launcher3.IconCache.IconLoadRequest; 43 import com.android.launcher3.model.PackageItemInfo; 44 45 import java.text.NumberFormat; 46 47 /** 48 * TextView that draws a bubble behind the text. We cannot use a LineBackgroundSpan 49 * because we want to make the bubble taller than the text and TextView's clip is 50 * too aggressive. 51 */ 52 public class BubbleTextView extends TextView 53 implements BaseRecyclerViewFastScrollBar.FastScrollFocusableView { 54 55 private static SparseArray<Theme> sPreloaderThemes = new SparseArray<Theme>(2); 56 57 private static final float SHADOW_LARGE_RADIUS = 4.0f; 58 private static final float SHADOW_SMALL_RADIUS = 1.75f; 59 private static final float SHADOW_Y_OFFSET = 2.0f; 60 private static final int SHADOW_LARGE_COLOUR = 0xDD000000; 61 private static final int SHADOW_SMALL_COLOUR = 0xCC000000; 62 63 private static final int DISPLAY_WORKSPACE = 0; 64 private static final int DISPLAY_ALL_APPS = 1; 65 66 private final Launcher mLauncher; 67 private Drawable mIcon; 68 private final Drawable mBackground; 69 private final CheckLongPressHelper mLongPressHelper; 70 private final HolographicOutlineHelper mOutlineHelper; 71 private final StylusEventHelper mStylusEventHelper; 72 73 private boolean mBackgroundSizeChanged; 74 75 private Bitmap mPressedBackground; 76 77 private float mSlop; 78 79 private final boolean mDeferShadowGenerationOnTouch; 80 private final boolean mCustomShadowsEnabled; 81 private final boolean mLayoutHorizontal; 82 private final int mIconSize; 83 private int mTextColor; 84 85 private boolean mStayPressed; 86 private boolean mIgnorePressedStateChange; 87 private boolean mDisableRelayout = false; 88 89 private IconLoadRequest mIconLoadRequest; 90 91 public BubbleTextView(Context context) { 92 this(context, null, 0); 93 } 94 95 public BubbleTextView(Context context, AttributeSet attrs) { 96 this(context, attrs, 0); 97 } 98 99 public BubbleTextView(Context context, AttributeSet attrs, int defStyle) { 100 super(context, attrs, defStyle); 101 mLauncher = (Launcher) context; 102 DeviceProfile grid = mLauncher.getDeviceProfile(); 103 104 TypedArray a = context.obtainStyledAttributes(attrs, 105 R.styleable.BubbleTextView, defStyle, 0); 106 mCustomShadowsEnabled = a.getBoolean(R.styleable.BubbleTextView_customShadows, true); 107 mLayoutHorizontal = a.getBoolean(R.styleable.BubbleTextView_layoutHorizontal, false); 108 mDeferShadowGenerationOnTouch = 109 a.getBoolean(R.styleable.BubbleTextView_deferShadowGeneration, false); 110 111 int display = a.getInteger(R.styleable.BubbleTextView_iconDisplay, DISPLAY_WORKSPACE); 112 int defaultIconSize = grid.iconSizePx; 113 if (display == DISPLAY_WORKSPACE) { 114 setTextSize(TypedValue.COMPLEX_UNIT_PX, grid.iconTextSizePx); 115 } else if (display == DISPLAY_ALL_APPS) { 116 setTextSize(TypedValue.COMPLEX_UNIT_SP, grid.allAppsIconTextSizeSp); 117 defaultIconSize = grid.allAppsIconSizePx; 118 } 119 120 mIconSize = a.getDimensionPixelSize(R.styleable.BubbleTextView_iconSizeOverride, 121 defaultIconSize); 122 123 a.recycle(); 124 125 if (mCustomShadowsEnabled) { 126 // Draw the background itself as the parent is drawn twice. 127 mBackground = getBackground(); 128 setBackground(null); 129 } else { 130 mBackground = null; 131 } 132 133 mLongPressHelper = new CheckLongPressHelper(this); 134 mStylusEventHelper = new StylusEventHelper(this); 135 136 mOutlineHelper = HolographicOutlineHelper.obtain(getContext()); 137 if (mCustomShadowsEnabled) { 138 setShadowLayer(SHADOW_LARGE_RADIUS, 0.0f, SHADOW_Y_OFFSET, SHADOW_LARGE_COLOUR); 139 } 140 141 setAccessibilityDelegate(LauncherAppState.getInstance().getAccessibilityDelegate()); 142 } 143 144 public void applyFromShortcutInfo(ShortcutInfo info, IconCache iconCache) { 145 applyFromShortcutInfo(info, iconCache, false); 146 } 147 148 public void applyFromShortcutInfo(ShortcutInfo info, IconCache iconCache, 149 boolean promiseStateChanged) { 150 Bitmap b = info.getIcon(iconCache); 151 152 FastBitmapDrawable iconDrawable = mLauncher.createIconDrawable(b); 153 if (info.isDisabled()) { 154 iconDrawable.setState(FastBitmapDrawable.State.DISABLED); 155 } 156 setIcon(iconDrawable, mIconSize); 157 if (info.contentDescription != null) { 158 setContentDescription(info.contentDescription); 159 } 160 setText(info.title); 161 setTag(info); 162 163 if (promiseStateChanged || info.isPromise()) { 164 applyState(promiseStateChanged); 165 } 166 } 167 168 public void applyFromApplicationInfo(AppInfo info) { 169 FastBitmapDrawable iconDrawable = mLauncher.createIconDrawable(info.iconBitmap); 170 if (info.isDisabled()) { 171 iconDrawable.setState(FastBitmapDrawable.State.DISABLED); 172 } 173 setIcon(iconDrawable, mIconSize); 174 setText(info.title); 175 if (info.contentDescription != null) { 176 setContentDescription(info.contentDescription); 177 } 178 // We don't need to check the info since it's not a ShortcutInfo 179 super.setTag(info); 180 181 // Verify high res immediately 182 verifyHighRes(); 183 } 184 185 public void applyFromPackageItemInfo(PackageItemInfo info) { 186 setIcon(mLauncher.createIconDrawable(info.iconBitmap), mIconSize); 187 setText(info.title); 188 if (info.contentDescription != null) { 189 setContentDescription(info.contentDescription); 190 } 191 // We don't need to check the info since it's not a ShortcutInfo 192 super.setTag(info); 193 194 // Verify high res immediately 195 verifyHighRes(); 196 } 197 198 /** 199 * Used for measurement only, sets some dummy values on this view. 200 */ 201 public void applyDummyInfo() { 202 ColorDrawable d = new ColorDrawable(); 203 setIcon(mLauncher.resizeIconDrawable(d), mIconSize); 204 setText(""); 205 } 206 207 /** 208 * Overrides the default long press timeout. 209 */ 210 public void setLongPressTimeout(int longPressTimeout) { 211 mLongPressHelper.setLongPressTimeout(longPressTimeout); 212 } 213 214 @Override 215 protected boolean setFrame(int left, int top, int right, int bottom) { 216 if (getLeft() != left || getRight() != right || getTop() != top || getBottom() != bottom) { 217 mBackgroundSizeChanged = true; 218 } 219 return super.setFrame(left, top, right, bottom); 220 } 221 222 @Override 223 protected boolean verifyDrawable(Drawable who) { 224 return who == mBackground || super.verifyDrawable(who); 225 } 226 227 @Override 228 public void setTag(Object tag) { 229 if (tag != null) { 230 LauncherModel.checkItemInfo((ItemInfo) tag); 231 } 232 super.setTag(tag); 233 } 234 235 @Override 236 public void setPressed(boolean pressed) { 237 super.setPressed(pressed); 238 239 if (!mIgnorePressedStateChange) { 240 updateIconState(); 241 } 242 } 243 244 /** Returns the icon for this view. */ 245 public Drawable getIcon() { 246 return mIcon; 247 } 248 249 /** Returns whether the layout is horizontal. */ 250 public boolean isLayoutHorizontal() { 251 return mLayoutHorizontal; 252 } 253 254 private void updateIconState() { 255 if (mIcon instanceof FastBitmapDrawable) { 256 FastBitmapDrawable d = (FastBitmapDrawable) mIcon; 257 if (getTag() instanceof ItemInfo 258 && ((ItemInfo) getTag()).isDisabled()) { 259 d.animateState(FastBitmapDrawable.State.DISABLED); 260 } else if (isPressed() || mStayPressed) { 261 d.animateState(FastBitmapDrawable.State.PRESSED); 262 } else { 263 d.animateState(FastBitmapDrawable.State.NORMAL); 264 } 265 } 266 } 267 268 @Override 269 public boolean onTouchEvent(MotionEvent event) { 270 // Call the superclass onTouchEvent first, because sometimes it changes the state to 271 // isPressed() on an ACTION_UP 272 boolean result = super.onTouchEvent(event); 273 274 // Check for a stylus button press, if it occurs cancel any long press checks. 275 if (mStylusEventHelper.checkAndPerformStylusEvent(event)) { 276 mLongPressHelper.cancelLongPress(); 277 result = true; 278 } 279 280 switch (event.getAction()) { 281 case MotionEvent.ACTION_DOWN: 282 // So that the pressed outline is visible immediately on setStayPressed(), 283 // we pre-create it on ACTION_DOWN (it takes a small but perceptible amount of time 284 // to create it) 285 if (!mDeferShadowGenerationOnTouch && mPressedBackground == null) { 286 mPressedBackground = mOutlineHelper.createMediumDropShadow(this); 287 } 288 289 // If we're in a stylus button press, don't check for long press. 290 if (!mStylusEventHelper.inStylusButtonPressed()) { 291 mLongPressHelper.postCheckForLongPress(); 292 } 293 break; 294 case MotionEvent.ACTION_CANCEL: 295 case MotionEvent.ACTION_UP: 296 // If we've touched down and up on an item, and it's still not "pressed", then 297 // destroy the pressed outline 298 if (!isPressed()) { 299 mPressedBackground = null; 300 } 301 302 mLongPressHelper.cancelLongPress(); 303 break; 304 case MotionEvent.ACTION_MOVE: 305 if (!Utilities.pointInView(this, event.getX(), event.getY(), mSlop)) { 306 mLongPressHelper.cancelLongPress(); 307 } 308 break; 309 } 310 return result; 311 } 312 313 void setStayPressed(boolean stayPressed) { 314 mStayPressed = stayPressed; 315 if (!stayPressed) { 316 mPressedBackground = null; 317 } else { 318 if (mPressedBackground == null) { 319 mPressedBackground = mOutlineHelper.createMediumDropShadow(this); 320 } 321 } 322 323 // Only show the shadow effect when persistent pressed state is set. 324 ViewParent parent = getParent(); 325 if (parent != null && parent.getParent() instanceof BubbleTextShadowHandler) { 326 ((BubbleTextShadowHandler) parent.getParent()).setPressedIcon( 327 this, mPressedBackground); 328 } 329 330 updateIconState(); 331 } 332 333 void clearPressedBackground() { 334 setPressed(false); 335 setStayPressed(false); 336 } 337 338 @Override 339 public boolean onKeyDown(int keyCode, KeyEvent event) { 340 if (super.onKeyDown(keyCode, event)) { 341 // Pre-create shadow so show immediately on click. 342 if (mPressedBackground == null) { 343 mPressedBackground = mOutlineHelper.createMediumDropShadow(this); 344 } 345 return true; 346 } 347 return false; 348 } 349 350 @Override 351 public boolean onKeyUp(int keyCode, KeyEvent event) { 352 // Unlike touch events, keypress event propagate pressed state change immediately, 353 // without waiting for onClickHandler to execute. Disable pressed state changes here 354 // to avoid flickering. 355 mIgnorePressedStateChange = true; 356 boolean result = super.onKeyUp(keyCode, event); 357 358 mPressedBackground = null; 359 mIgnorePressedStateChange = false; 360 updateIconState(); 361 return result; 362 } 363 364 @Override 365 public void draw(Canvas canvas) { 366 if (!mCustomShadowsEnabled) { 367 super.draw(canvas); 368 return; 369 } 370 371 final Drawable background = mBackground; 372 if (background != null) { 373 final int scrollX = getScrollX(); 374 final int scrollY = getScrollY(); 375 376 if (mBackgroundSizeChanged) { 377 background.setBounds(0, 0, getRight() - getLeft(), getBottom() - getTop()); 378 mBackgroundSizeChanged = false; 379 } 380 381 if ((scrollX | scrollY) == 0) { 382 background.draw(canvas); 383 } else { 384 canvas.translate(scrollX, scrollY); 385 background.draw(canvas); 386 canvas.translate(-scrollX, -scrollY); 387 } 388 } 389 390 // If text is transparent, don't draw any shadow 391 if (getCurrentTextColor() == getResources().getColor(android.R.color.transparent)) { 392 getPaint().clearShadowLayer(); 393 super.draw(canvas); 394 return; 395 } 396 397 // We enhance the shadow by drawing the shadow twice 398 getPaint().setShadowLayer(SHADOW_LARGE_RADIUS, 0.0f, SHADOW_Y_OFFSET, SHADOW_LARGE_COLOUR); 399 super.draw(canvas); 400 canvas.save(Canvas.CLIP_SAVE_FLAG); 401 canvas.clipRect(getScrollX(), getScrollY() + getExtendedPaddingTop(), 402 getScrollX() + getWidth(), 403 getScrollY() + getHeight(), Region.Op.INTERSECT); 404 getPaint().setShadowLayer(SHADOW_SMALL_RADIUS, 0.0f, 0.0f, SHADOW_SMALL_COLOUR); 405 super.draw(canvas); 406 canvas.restore(); 407 } 408 409 @Override 410 protected void onAttachedToWindow() { 411 super.onAttachedToWindow(); 412 413 if (mBackground != null) mBackground.setCallback(this); 414 415 if (mIcon instanceof PreloadIconDrawable) { 416 ((PreloadIconDrawable) mIcon).applyPreloaderTheme(getPreloaderTheme()); 417 } 418 mSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop(); 419 } 420 421 @Override 422 protected void onDetachedFromWindow() { 423 super.onDetachedFromWindow(); 424 if (mBackground != null) mBackground.setCallback(null); 425 } 426 427 @Override 428 public void setTextColor(int color) { 429 mTextColor = color; 430 super.setTextColor(color); 431 } 432 433 @Override 434 public void setTextColor(ColorStateList colors) { 435 mTextColor = colors.getDefaultColor(); 436 super.setTextColor(colors); 437 } 438 439 public void setTextVisibility(boolean visible) { 440 Resources res = getResources(); 441 if (visible) { 442 super.setTextColor(mTextColor); 443 } else { 444 super.setTextColor(res.getColor(android.R.color.transparent)); 445 } 446 } 447 448 @Override 449 public void cancelLongPress() { 450 super.cancelLongPress(); 451 452 mLongPressHelper.cancelLongPress(); 453 } 454 455 public void applyState(boolean promiseStateChanged) { 456 if (getTag() instanceof ShortcutInfo) { 457 ShortcutInfo info = (ShortcutInfo) getTag(); 458 final boolean isPromise = info.isPromise(); 459 final int progressLevel = isPromise ? 460 ((info.hasStatusFlag(ShortcutInfo.FLAG_INSTALL_SESSION_ACTIVE) ? 461 info.getInstallProgress() : 0)) : 100; 462 463 setContentDescription(progressLevel > 0 ? 464 getContext().getString(R.string.app_downloading_title, info.title, 465 NumberFormat.getPercentInstance().format(progressLevel * 0.01)) : 466 getContext().getString(R.string.app_waiting_download_title, info.title)); 467 468 if (mIcon != null) { 469 final PreloadIconDrawable preloadDrawable; 470 if (mIcon instanceof PreloadIconDrawable) { 471 preloadDrawable = (PreloadIconDrawable) mIcon; 472 } else { 473 preloadDrawable = new PreloadIconDrawable(mIcon, getPreloaderTheme()); 474 setIcon(preloadDrawable, mIconSize); 475 } 476 477 preloadDrawable.setLevel(progressLevel); 478 if (promiseStateChanged) { 479 preloadDrawable.maybePerformFinishedAnimation(); 480 } 481 } 482 } 483 } 484 485 private Theme getPreloaderTheme() { 486 Object tag = getTag(); 487 int style = ((tag != null) && (tag instanceof ShortcutInfo) && 488 (((ShortcutInfo) tag).container >= 0)) ? R.style.PreloadIcon_Folder 489 : R.style.PreloadIcon; 490 Theme theme = sPreloaderThemes.get(style); 491 if (theme == null) { 492 theme = getResources().newTheme(); 493 theme.applyStyle(style, true); 494 sPreloaderThemes.put(style, theme); 495 } 496 return theme; 497 } 498 499 /** 500 * Sets the icon for this view based on the layout direction. 501 */ 502 @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) 503 private Drawable setIcon(Drawable icon, int iconSize) { 504 mIcon = icon; 505 if (iconSize != -1) { 506 mIcon.setBounds(0, 0, iconSize, iconSize); 507 } 508 if (mLayoutHorizontal) { 509 if (Utilities.ATLEAST_JB_MR1) { 510 setCompoundDrawablesRelative(mIcon, null, null, null); 511 } else { 512 setCompoundDrawables(mIcon, null, null, null); 513 } 514 } else { 515 setCompoundDrawables(null, mIcon, null, null); 516 } 517 return icon; 518 } 519 520 @Override 521 public void requestLayout() { 522 if (!mDisableRelayout) { 523 super.requestLayout(); 524 } 525 } 526 527 /** 528 * Applies the item info if it is same as what the view is pointing to currently. 529 */ 530 public void reapplyItemInfo(final ItemInfo info) { 531 if (getTag() == info) { 532 FastBitmapDrawable.State prevState = FastBitmapDrawable.State.NORMAL; 533 if (mIcon instanceof FastBitmapDrawable) { 534 prevState = ((FastBitmapDrawable) mIcon).getCurrentState(); 535 } 536 mIconLoadRequest = null; 537 mDisableRelayout = true; 538 539 if (info instanceof AppInfo) { 540 applyFromApplicationInfo((AppInfo) info); 541 } else if (info instanceof ShortcutInfo) { 542 applyFromShortcutInfo((ShortcutInfo) info, 543 LauncherAppState.getInstance().getIconCache()); 544 if ((info.rank < FolderIcon.NUM_ITEMS_IN_PREVIEW) && (info.container >= 0)) { 545 View folderIcon = 546 mLauncher.getWorkspace().getHomescreenIconByItemId(info.container); 547 if (folderIcon != null) { 548 folderIcon.invalidate(); 549 } 550 } 551 } else if (info instanceof PackageItemInfo) { 552 applyFromPackageItemInfo((PackageItemInfo) info); 553 } 554 555 // If we are reapplying over an old icon, then we should update the new icon to the same 556 // state as the old icon 557 if (mIcon instanceof FastBitmapDrawable) { 558 ((FastBitmapDrawable) mIcon).setState(prevState); 559 } 560 561 mDisableRelayout = false; 562 } 563 } 564 565 /** 566 * Verifies that the current icon is high-res otherwise posts a request to load the icon. 567 */ 568 public void verifyHighRes() { 569 if (mIconLoadRequest != null) { 570 mIconLoadRequest.cancel(); 571 mIconLoadRequest = null; 572 } 573 if (getTag() instanceof AppInfo) { 574 AppInfo info = (AppInfo) getTag(); 575 if (info.usingLowResIcon) { 576 mIconLoadRequest = LauncherAppState.getInstance().getIconCache() 577 .updateIconInBackground(BubbleTextView.this, info); 578 } 579 } else if (getTag() instanceof ShortcutInfo) { 580 ShortcutInfo info = (ShortcutInfo) getTag(); 581 if (info.usingLowResIcon) { 582 mIconLoadRequest = LauncherAppState.getInstance().getIconCache() 583 .updateIconInBackground(BubbleTextView.this, info); 584 } 585 } else if (getTag() instanceof PackageItemInfo) { 586 PackageItemInfo info = (PackageItemInfo) getTag(); 587 if (info.usingLowResIcon) { 588 mIconLoadRequest = LauncherAppState.getInstance().getIconCache() 589 .updateIconInBackground(BubbleTextView.this, info); 590 } 591 } 592 } 593 594 @Override 595 public void setFastScrollFocusState(final FastBitmapDrawable.State focusState, boolean animated) { 596 // We can only set the fast scroll focus state on a FastBitmapDrawable 597 if (!(mIcon instanceof FastBitmapDrawable)) { 598 return; 599 } 600 601 FastBitmapDrawable d = (FastBitmapDrawable) mIcon; 602 if (animated) { 603 FastBitmapDrawable.State prevState = d.getCurrentState(); 604 if (d.animateState(focusState)) { 605 // If the state was updated, then update the view accordingly 606 animate().scaleX(focusState.viewScale) 607 .scaleY(focusState.viewScale) 608 .setStartDelay(getStartDelayForStateChange(prevState, focusState)) 609 .setDuration(d.getDurationForStateChange(prevState, focusState)) 610 .start(); 611 } 612 } else { 613 if (d.setState(focusState)) { 614 // If the state was updated, then update the view accordingly 615 animate().cancel(); 616 setScaleX(focusState.viewScale); 617 setScaleY(focusState.viewScale); 618 } 619 } 620 } 621 622 /** 623 * Returns the start delay when animating between certain {@link FastBitmapDrawable} states. 624 */ 625 private static int getStartDelayForStateChange(final FastBitmapDrawable.State fromState, 626 final FastBitmapDrawable.State toState) { 627 switch (toState) { 628 case NORMAL: 629 switch (fromState) { 630 case FAST_SCROLL_HIGHLIGHTED: 631 return FastBitmapDrawable.FAST_SCROLL_INACTIVE_DURATION / 4; 632 } 633 } 634 return 0; 635 } 636 637 /** 638 * Interface to be implemented by the grand parent to allow click shadow effect. 639 */ 640 public interface BubbleTextShadowHandler { 641 void setPressedIcon(BubbleTextView icon, Bitmap background); 642 } 643 } 644