/* * Copyright (C) 2008 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.launcher3; import static com.android.launcher3.icons.GraphicsUtils.setColorAlphaBound; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.ObjectAnimator; import android.content.Context; import android.content.res.ColorStateList; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Rect; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.text.TextUtils.TruncateAt; import android.util.AttributeSet; import android.util.Log; import android.util.Property; import android.util.TypedValue; import android.view.KeyEvent; import android.view.MotionEvent; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewDebug; import android.widget.TextView; import com.android.launcher3.Launcher.OnResumeCallback; import com.android.launcher3.accessibility.LauncherAccessibilityDelegate; import com.android.launcher3.dot.DotInfo; import com.android.launcher3.folder.FolderIcon; import com.android.launcher3.graphics.DrawableFactory; import com.android.launcher3.graphics.IconPalette; import com.android.launcher3.graphics.IconShape; import com.android.launcher3.graphics.PreloadIconDrawable; import com.android.launcher3.icons.DotRenderer; import com.android.launcher3.icons.IconCache.IconLoadRequest; import com.android.launcher3.icons.IconCache.ItemInfoUpdateReceiver; import com.android.launcher3.model.PackageItemInfo; import com.android.launcher3.testing.TestProtocol; import com.android.launcher3.views.ActivityContext; import java.text.NumberFormat; /** * TextView that draws a bubble behind the text. We cannot use a LineBackgroundSpan * because we want to make the bubble taller than the text and TextView's clip is * too aggressive. */ public class BubbleTextView extends TextView implements ItemInfoUpdateReceiver, OnResumeCallback { private static final int DISPLAY_WORKSPACE = 0; private static final int DISPLAY_ALL_APPS = 1; private static final int DISPLAY_FOLDER = 2; private static final int[] STATE_PRESSED = new int[] {android.R.attr.state_pressed}; private static final Property DOT_SCALE_PROPERTY = new Property(Float.TYPE, "dotScale") { @Override public Float get(BubbleTextView bubbleTextView) { return bubbleTextView.mDotParams.scale; } @Override public void set(BubbleTextView bubbleTextView, Float value) { bubbleTextView.mDotParams.scale = value; bubbleTextView.invalidate(); } }; public static final Property TEXT_ALPHA_PROPERTY = new Property(Float.class, "textAlpha") { @Override public Float get(BubbleTextView bubbleTextView) { return bubbleTextView.mTextAlpha; } @Override public void set(BubbleTextView bubbleTextView, Float alpha) { bubbleTextView.setTextAlpha(alpha); } }; private final ActivityContext mActivity; private Drawable mIcon; private final boolean mCenterVertically; private final CheckLongPressHelper mLongPressHelper; private final StylusEventHelper mStylusEventHelper; private final float mSlop; private final boolean mLayoutHorizontal; private final int mIconSize; @ViewDebug.ExportedProperty(category = "launcher") private boolean mIsIconVisible = true; @ViewDebug.ExportedProperty(category = "launcher") private int mTextColor; @ViewDebug.ExportedProperty(category = "launcher") private float mTextAlpha = 1; @ViewDebug.ExportedProperty(category = "launcher") private DotInfo mDotInfo; private DotRenderer mDotRenderer; @ViewDebug.ExportedProperty(category = "launcher", deepExport = true) private DotRenderer.DrawParams mDotParams; private Animator mDotScaleAnim; private boolean mForceHideDot; @ViewDebug.ExportedProperty(category = "launcher") private boolean mStayPressed; @ViewDebug.ExportedProperty(category = "launcher") private boolean mIgnorePressedStateChange; @ViewDebug.ExportedProperty(category = "launcher") private boolean mDisableRelayout = false; private IconLoadRequest mIconLoadRequest; public BubbleTextView(Context context) { this(context, null, 0); } public BubbleTextView(Context context, AttributeSet attrs) { this(context, attrs, 0); } public BubbleTextView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); mActivity = ActivityContext.lookupContext(context); mSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop(); TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.BubbleTextView, defStyle, 0); mLayoutHorizontal = a.getBoolean(R.styleable.BubbleTextView_layoutHorizontal, false); int display = a.getInteger(R.styleable.BubbleTextView_iconDisplay, DISPLAY_WORKSPACE); final int defaultIconSize; if (display == DISPLAY_WORKSPACE) { DeviceProfile grid = mActivity.getWallpaperDeviceProfile(); setTextSize(TypedValue.COMPLEX_UNIT_PX, grid.iconTextSizePx); setCompoundDrawablePadding(grid.iconDrawablePaddingPx); defaultIconSize = grid.iconSizePx; } else if (display == DISPLAY_ALL_APPS) { DeviceProfile grid = mActivity.getDeviceProfile(); setTextSize(TypedValue.COMPLEX_UNIT_PX, grid.allAppsIconTextSizePx); setCompoundDrawablePadding(grid.allAppsIconDrawablePaddingPx); defaultIconSize = grid.allAppsIconSizePx; } else if (display == DISPLAY_FOLDER) { DeviceProfile grid = mActivity.getDeviceProfile(); setTextSize(TypedValue.COMPLEX_UNIT_PX, grid.folderChildTextSizePx); setCompoundDrawablePadding(grid.folderChildDrawablePaddingPx); defaultIconSize = grid.folderChildIconSizePx; } else { defaultIconSize = mActivity.getDeviceProfile().iconSizePx; } mCenterVertically = a.getBoolean(R.styleable.BubbleTextView_centerVertically, false); mIconSize = a.getDimensionPixelSize(R.styleable.BubbleTextView_iconSizeOverride, defaultIconSize); a.recycle(); mLongPressHelper = new CheckLongPressHelper(this); mStylusEventHelper = new StylusEventHelper(new SimpleOnStylusPressListener(this), this); mDotParams = new DotRenderer.DrawParams(); setEllipsize(TruncateAt.END); setAccessibilityDelegate(mActivity.getAccessibilityDelegate()); setTextAlpha(1f); } @Override protected void onFocusChanged(boolean focused, int direction, Rect previouslyFocusedRect) { // Disable marques when not focused to that, so that updating text does not cause relayout. setEllipsize(focused ? TruncateAt.MARQUEE : TruncateAt.END); super.onFocusChanged(focused, direction, previouslyFocusedRect); } /** * Resets the view so it can be recycled. */ public void reset() { mDotInfo = null; mDotParams.color = Color.TRANSPARENT; cancelDotScaleAnim(); mDotParams.scale = 0f; mForceHideDot = false; } private void cancelDotScaleAnim() { if (mDotScaleAnim != null) { mDotScaleAnim.cancel(); } } private void animateDotScale(float... dotScales) { cancelDotScaleAnim(); mDotScaleAnim = ObjectAnimator.ofFloat(this, DOT_SCALE_PROPERTY, dotScales); mDotScaleAnim.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { mDotScaleAnim = null; } }); mDotScaleAnim.start(); } public void applyFromWorkspaceItem(WorkspaceItemInfo info) { applyFromWorkspaceItem(info, false); } @Override public void setAccessibilityDelegate(AccessibilityDelegate delegate) { if (delegate instanceof LauncherAccessibilityDelegate) { super.setAccessibilityDelegate(delegate); } else { // NO-OP // Workaround for b/129745295 where RecyclerView is setting our Accessibility // delegate incorrectly. There are no cases when we shouldn't be using the // LauncherAccessibilityDelegate for BubbleTextView. } } public void applyFromWorkspaceItem(WorkspaceItemInfo info, boolean promiseStateChanged) { applyIconAndLabel(info); setTag(info); if (promiseStateChanged || (info.hasPromiseIconUi())) { applyPromiseState(promiseStateChanged); } applyDotState(info, false /* animate */); } public void applyFromApplicationInfo(AppInfo info) { applyIconAndLabel(info); // We don't need to check the info since it's not a WorkspaceItemInfo super.setTag(info); // Verify high res immediately verifyHighRes(); if (info instanceof PromiseAppInfo) { PromiseAppInfo promiseAppInfo = (PromiseAppInfo) info; applyProgressLevel(promiseAppInfo.level); } applyDotState(info, false /* animate */); } public void applyFromPackageItemInfo(PackageItemInfo info) { applyIconAndLabel(info); // We don't need to check the info since it's not a WorkspaceItemInfo super.setTag(info); // Verify high res immediately verifyHighRes(); } private void applyIconAndLabel(ItemInfoWithIcon info) { FastBitmapDrawable iconDrawable = DrawableFactory.INSTANCE.get(getContext()) .newIcon(getContext(), info); mDotParams.color = IconPalette.getMutedColor(info.iconColor, 0.54f); setIcon(iconDrawable); setText(info.title); if (info.contentDescription != null) { setContentDescription(info.isDisabled() ? getContext().getString(R.string.disabled_app_label, info.contentDescription) : info.contentDescription); } } /** * Overrides the default long press timeout. */ public void setLongPressTimeoutFactor(float longPressTimeoutFactor) { mLongPressHelper.setLongPressTimeoutFactor(longPressTimeoutFactor); } @Override public void refreshDrawableState() { if (!mIgnorePressedStateChange) { super.refreshDrawableState(); } } @Override protected int[] onCreateDrawableState(int extraSpace) { final int[] drawableState = super.onCreateDrawableState(extraSpace + 1); if (mStayPressed) { mergeDrawableStates(drawableState, STATE_PRESSED); } return drawableState; } /** Returns the icon for this view. */ public Drawable getIcon() { return mIcon; } @Override public boolean onTouchEvent(MotionEvent event) { if (TestProtocol.sDebugTracing) { Log.d(TestProtocol.NO_START_TAG, "BubbleTextView.onTouchEvent " + event); } // Call the superclass onTouchEvent first, because sometimes it changes the state to // isPressed() on an ACTION_UP boolean result = super.onTouchEvent(event); // Check for a stylus button press, if it occurs cancel any long press checks. if (mStylusEventHelper.onMotionEvent(event)) { mLongPressHelper.cancelLongPress(); result = true; } switch (event.getAction()) { case MotionEvent.ACTION_DOWN: // If we're in a stylus button press, don't check for long press. if (!mStylusEventHelper.inStylusButtonPressed()) { mLongPressHelper.postCheckForLongPress(); } break; case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: mLongPressHelper.cancelLongPress(); break; case MotionEvent.ACTION_MOVE: if (!Utilities.pointInView(this, event.getX(), event.getY(), mSlop)) { mLongPressHelper.cancelLongPress(); } break; } return result; } void setStayPressed(boolean stayPressed) { mStayPressed = stayPressed; refreshDrawableState(); } @Override public void onVisibilityAggregated(boolean isVisible) { super.onVisibilityAggregated(isVisible); if (mIcon != null) { mIcon.setVisible(isVisible, false); } } @Override public void onLauncherResume() { // Reset the pressed state of icon that was locked in the press state while activity // was launching setStayPressed(false); } void clearPressedBackground() { setPressed(false); setStayPressed(false); } @Override public boolean onKeyUp(int keyCode, KeyEvent event) { // Unlike touch events, keypress event propagate pressed state change immediately, // without waiting for onClickHandler to execute. Disable pressed state changes here // to avoid flickering. mIgnorePressedStateChange = true; boolean result = super.onKeyUp(keyCode, event); mIgnorePressedStateChange = false; refreshDrawableState(); return result; } @SuppressWarnings("wrongcall") protected void drawWithoutDot(Canvas canvas) { super.onDraw(canvas); } @Override public void onDraw(Canvas canvas) { super.onDraw(canvas); drawDotIfNecessary(canvas); } /** * Draws the notification dot in the top right corner of the icon bounds. * @param canvas The canvas to draw to. */ protected void drawDotIfNecessary(Canvas canvas) { if (!mForceHideDot && (hasDot() || mDotParams.scale > 0)) { getIconBounds(mDotParams.iconBounds); Utilities.scaleRectAboutCenter(mDotParams.iconBounds, IconShape.getNormalizationScale()); final int scrollX = getScrollX(); final int scrollY = getScrollY(); canvas.translate(scrollX, scrollY); mDotRenderer.draw(canvas, mDotParams); canvas.translate(-scrollX, -scrollY); } } public void forceHideDot(boolean forceHideDot) { if (mForceHideDot == forceHideDot) { return; } mForceHideDot = forceHideDot; if (forceHideDot) { invalidate(); } else if (hasDot()) { animateDotScale(0, 1); } } private boolean hasDot() { return mDotInfo != null; } public void getIconBounds(Rect outBounds) { getIconBounds(this, outBounds, mIconSize); } public static void getIconBounds(View iconView, Rect outBounds, int iconSize) { int top = iconView.getPaddingTop(); int left = (iconView.getWidth() - iconSize) / 2; int right = left + iconSize; int bottom = top + iconSize; outBounds.set(left, top, right, bottom); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { if (mCenterVertically) { Paint.FontMetrics fm = getPaint().getFontMetrics(); int cellHeightPx = mIconSize + getCompoundDrawablePadding() + (int) Math.ceil(fm.bottom - fm.top); int height = MeasureSpec.getSize(heightMeasureSpec); setPadding(getPaddingLeft(), (height - cellHeightPx) / 2, getPaddingRight(), getPaddingBottom()); } super.onMeasure(widthMeasureSpec, heightMeasureSpec); } @Override public void setTextColor(int color) { mTextColor = color; super.setTextColor(getModifiedColor()); } @Override public void setTextColor(ColorStateList colors) { mTextColor = colors.getDefaultColor(); if (Float.compare(mTextAlpha, 1) == 0) { super.setTextColor(colors); } else { super.setTextColor(getModifiedColor()); } } public boolean shouldTextBeVisible() { // Text should be visible everywhere but the hotseat. Object tag = getParent() instanceof FolderIcon ? ((View) getParent()).getTag() : getTag(); ItemInfo info = tag instanceof ItemInfo ? (ItemInfo) tag : null; return info == null || info.container != LauncherSettings.Favorites.CONTAINER_HOTSEAT; } public void setTextVisibility(boolean visible) { setTextAlpha(visible ? 1 : 0); } private void setTextAlpha(float alpha) { mTextAlpha = alpha; super.setTextColor(getModifiedColor()); } private int getModifiedColor() { if (mTextAlpha == 0) { // Special case to prevent text shadows in high contrast mode return Color.TRANSPARENT; } return setColorAlphaBound(mTextColor, Math.round(Color.alpha(mTextColor) * mTextAlpha)); } /** * Creates an animator to fade the text in or out. * @param fadeIn Whether the text should fade in or fade out. */ public ObjectAnimator createTextAlphaAnimator(boolean fadeIn) { float toAlpha = shouldTextBeVisible() && fadeIn ? 1 : 0; return ObjectAnimator.ofFloat(this, TEXT_ALPHA_PROPERTY, toAlpha); } @Override public void cancelLongPress() { super.cancelLongPress(); mLongPressHelper.cancelLongPress(); } public void applyPromiseState(boolean promiseStateChanged) { if (getTag() instanceof WorkspaceItemInfo) { WorkspaceItemInfo info = (WorkspaceItemInfo) getTag(); final boolean isPromise = info.hasPromiseIconUi(); final int progressLevel = isPromise ? ((info.hasStatusFlag(WorkspaceItemInfo.FLAG_INSTALL_SESSION_ACTIVE) ? info.getInstallProgress() : 0)) : 100; PreloadIconDrawable preloadDrawable = applyProgressLevel(progressLevel); if (preloadDrawable != null && promiseStateChanged) { preloadDrawable.maybePerformFinishedAnimation(); } } } public PreloadIconDrawable applyProgressLevel(int progressLevel) { if (getTag() instanceof ItemInfoWithIcon) { ItemInfoWithIcon info = (ItemInfoWithIcon) getTag(); if (progressLevel >= 100) { setContentDescription(info.contentDescription != null ? info.contentDescription : ""); } else if (progressLevel > 0) { setContentDescription(getContext() .getString(R.string.app_downloading_title, info.title, NumberFormat.getPercentInstance().format(progressLevel * 0.01))); } else { setContentDescription(getContext() .getString(R.string.app_waiting_download_title, info.title)); } if (mIcon != null) { final PreloadIconDrawable preloadDrawable; if (mIcon instanceof PreloadIconDrawable) { preloadDrawable = (PreloadIconDrawable) mIcon; preloadDrawable.setLevel(progressLevel); } else { preloadDrawable = DrawableFactory.INSTANCE.get(getContext()) .newPendingIcon(getContext(), info); preloadDrawable.setLevel(progressLevel); setIcon(preloadDrawable); } return preloadDrawable; } } return null; } public void applyDotState(ItemInfo itemInfo, boolean animate) { if (mIcon instanceof FastBitmapDrawable) { boolean wasDotted = mDotInfo != null; mDotInfo = mActivity.getDotInfoForItem(itemInfo); boolean isDotted = mDotInfo != null; float newDotScale = isDotted ? 1f : 0; mDotRenderer = mActivity.getDeviceProfile().mDotRenderer; if (wasDotted || isDotted) { // Animate when a dot is first added or when it is removed. if (animate && (wasDotted ^ isDotted) && isShown()) { animateDotScale(newDotScale); } else { cancelDotScaleAnim(); mDotParams.scale = newDotScale; invalidate(); } } if (itemInfo.contentDescription != null) { if (itemInfo.isDisabled()) { setContentDescription(getContext().getString(R.string.disabled_app_label, itemInfo.contentDescription)); } else if (hasDot()) { int count = mDotInfo.getNotificationCount(); setContentDescription(getContext().getResources().getQuantityString( R.plurals.dotted_app_label, count, itemInfo.contentDescription, count)); } else { setContentDescription(itemInfo.contentDescription); } } } } /** * Sets the icon for this view based on the layout direction. */ private void setIcon(Drawable icon) { if (mIsIconVisible) { applyCompoundDrawables(icon); } mIcon = icon; if (mIcon != null) { mIcon.setVisible(getWindowVisibility() == VISIBLE && isShown(), false); } } public void setIconVisible(boolean visible) { mIsIconVisible = visible; Drawable icon = visible ? mIcon : new ColorDrawable(Color.TRANSPARENT); applyCompoundDrawables(icon); } protected void applyCompoundDrawables(Drawable icon) { // If we had already set an icon before, disable relayout as the icon size is the // same as before. mDisableRelayout = mIcon != null; icon.setBounds(0, 0, mIconSize, mIconSize); if (mLayoutHorizontal) { setCompoundDrawablesRelative(icon, null, null, null); } else { setCompoundDrawables(null, icon, null, null); } mDisableRelayout = false; } @Override public void requestLayout() { if (!mDisableRelayout) { super.requestLayout(); } } /** * Applies the item info if it is same as what the view is pointing to currently. */ @Override public void reapplyItemInfo(ItemInfoWithIcon info) { if (getTag() == info) { mIconLoadRequest = null; mDisableRelayout = true; // Optimization: Starting in N, pre-uploads the bitmap to RenderThread. info.iconBitmap.prepareToDraw(); if (info instanceof AppInfo) { applyFromApplicationInfo((AppInfo) info); } else if (info instanceof WorkspaceItemInfo) { applyFromWorkspaceItem((WorkspaceItemInfo) info); mActivity.invalidateParent(info); } else if (info instanceof PackageItemInfo) { applyFromPackageItemInfo((PackageItemInfo) info); } mDisableRelayout = false; } } /** * Verifies that the current icon is high-res otherwise posts a request to load the icon. */ public void verifyHighRes() { if (mIconLoadRequest != null) { mIconLoadRequest.cancel(); mIconLoadRequest = null; } if (getTag() instanceof ItemInfoWithIcon) { ItemInfoWithIcon info = (ItemInfoWithIcon) getTag(); if (info.usingLowResIcon()) { mIconLoadRequest = LauncherAppState.getInstance(getContext()).getIconCache() .updateIconInBackground(BubbleTextView.this, info); } } } public int getIconSize() { return mIconSize; } }