diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarController.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarController.java index 056fc74c30..7e2b037a3c 100644 --- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarController.java +++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarController.java @@ -33,6 +33,7 @@ import static com.android.systemui.shared.system.QuickStepContract.SYSUI_STATE_S import android.annotation.BinderThread; import android.annotation.Nullable; +import android.app.Notification; import android.content.Context; import android.content.pm.ApplicationInfo; import android.content.pm.LauncherApps; @@ -243,17 +244,21 @@ public class BubbleBarController extends IBubblesListener.Stub { BUBBLE_STATE_EXECUTOR.execute(() -> { createAndAddOverflowIfNeeded(); if (update.addedBubble != null) { - viewUpdate.addedBubble = populateBubble(update.addedBubble, mContext, mBarView); + viewUpdate.addedBubble = populateBubble(mContext, update.addedBubble, mBarView, + null /* existingBubble */); } if (update.updatedBubble != null) { + BubbleBarBubble existingBubble = mBubbles.get(update.updatedBubble.getKey()); viewUpdate.updatedBubble = - populateBubble(update.updatedBubble, mContext, mBarView); + populateBubble(mContext, update.updatedBubble, mBarView, + existingBubble); } if (update.currentBubbleList != null && !update.currentBubbleList.isEmpty()) { List currentBubbles = new ArrayList<>(); for (int i = 0; i < update.currentBubbleList.size(); i++) { BubbleBarBubble b = - populateBubble(update.currentBubbleList.get(i), mContext, mBarView); + populateBubble(mContext, update.currentBubbleList.get(i), mBarView, + null /* existingBubble */); currentBubbles.add(b); } viewUpdate.currentBubbles = currentBubbles; @@ -315,9 +320,11 @@ public class BubbleBarController extends IBubblesListener.Stub { mBubbleStashedHandleViewController.setHiddenForBubbles(mBubbles.isEmpty()); if (update.updatedBubble != null) { - // TODO: (b/269670235) handle updates: - // (1) if content / icons change -- requires reload & add back in place - // (2) if showing update dot changes -- tell the view to hide / show the dot + // Updates mean the dot state may have changed; any other changes were updated in + // the populateBubble step. + BubbleBarBubble bb = mBubbles.get(update.updatedBubble.getKey()); + // If we're not stashed, we're visible so animate + bb.getView().updateDotVisibility(!mBubbleStashController.isStashed() /* animate */); } if (update.bubbleKeysInOrder != null && !update.bubbleKeysInOrder.isEmpty()) { // Create the new list @@ -362,7 +369,13 @@ public class BubbleBarController extends IBubblesListener.Stub { if (getSelectedBubbleKey() != null) { int[] bubbleBarCoords = mBarView.getLocationOnScreen(); if (mSelectedBubble instanceof BubbleBarBubble) { - // TODO (b/269670235): hide the update dot on the view if needed. + // Because we've visited this bubble, we should suppress the notification. + // This is updated on WMShell side when we show the bubble, but that update isn't + // passed to launcher, instead we apply it directly here. + BubbleInfo info = ((BubbleBarBubble) mSelectedBubble).getInfo(); + info.setFlags( + info.getFlags() | Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION); + mSelectedBubble.getView().updateDotVisibility(true /* animate */); } mSystemUiProxy.showBubble(getSelectedBubbleKey(), bubbleBarCoords[0], bubbleBarCoords[1]); @@ -407,7 +420,8 @@ public class BubbleBarController extends IBubblesListener.Stub { // @Nullable - private BubbleBarBubble populateBubble(BubbleInfo b, Context context, BubbleBarView bbv) { + private BubbleBarBubble populateBubble(Context context, BubbleInfo b, BubbleBarView bbv, + @Nullable BubbleBarBubble existingBubble) { String appName; Bitmap badgeBitmap; Bitmap bubbleBitmap; @@ -476,16 +490,27 @@ public class BubbleBarController extends IBubblesListener.Stub { iconPath.transform(matrix); dotPath = iconPath; dotColor = ColorUtils.blendARGB(badgeBitmapInfo.color, - Color.WHITE, WHITE_SCRIM_ALPHA); + Color.WHITE, WHITE_SCRIM_ALPHA / 255f); - LayoutInflater inflater = LayoutInflater.from(context); - BubbleView bubbleView = (BubbleView) inflater.inflate( - R.layout.bubblebar_item_view, bbv, false /* attachToRoot */); + if (existingBubble == null) { + LayoutInflater inflater = LayoutInflater.from(context); + BubbleView bubbleView = (BubbleView) inflater.inflate( + R.layout.bubblebar_item_view, bbv, false /* attachToRoot */); - BubbleBarBubble bubble = new BubbleBarBubble(b, bubbleView, - badgeBitmap, bubbleBitmap, dotColor, dotPath, appName); - bubbleView.setBubble(bubble); - return bubble; + BubbleBarBubble bubble = new BubbleBarBubble(b, bubbleView, + badgeBitmap, bubbleBitmap, dotColor, dotPath, appName); + bubbleView.setBubble(bubble); + return bubble; + } else { + // If we already have a bubble (so it already has an inflated view), update it. + existingBubble.setInfo(b); + existingBubble.setBadge(badgeBitmap); + existingBubble.setIcon(bubbleBitmap); + existingBubble.setDotColor(dotColor); + existingBubble.setDotPath(dotPath); + existingBubble.setAppName(appName); + return existingBubble; + } } private BubbleBarOverflow createOverflow(Context context) { diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarItem.kt b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarItem.kt index 582dcc750f..43e21f4085 100644 --- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarItem.kt +++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarItem.kt @@ -20,18 +20,18 @@ import android.graphics.Path import com.android.wm.shell.common.bubbles.BubbleInfo /** An entity in the bubble bar. */ -sealed class BubbleBarItem(open val key: String, open val view: BubbleView) +sealed class BubbleBarItem(open var key: String, open var view: BubbleView) /** Contains state info about a bubble in the bubble bar as well as presentation information. */ data class BubbleBarBubble( - val info: BubbleInfo, - override val view: BubbleView, - val badge: Bitmap, - val icon: Bitmap, - val dotColor: Int, - val dotPath: Path, - val appName: String + var info: BubbleInfo, + override var view: BubbleView, + var badge: Bitmap, + var icon: Bitmap, + var dotColor: Int, + var dotPath: Path, + var appName: String ) : BubbleBarItem(info.key, view) /** Represents the overflow bubble in the bubble bar. */ -data class BubbleBarOverflow(override val view: BubbleView) : BubbleBarItem("Overflow", view) +data class BubbleBarOverflow(override var view: BubbleView) : BubbleBarItem("Overflow", view) diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java index 563ba02a42..cf52a5e7e8 100644 --- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java +++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java @@ -234,6 +234,7 @@ public class BubbleBarView extends FrameLayout { final float collapsedWidth = collapsedWidth(); int bubbleCount = getChildCount(); final float ty = (mBubbleBarBounds.height() - mIconSize) / 2f; + final boolean animate = getVisibility() == VISIBLE; for (int i = 0; i < bubbleCount; i++) { BubbleView bv = (BubbleView) getChildAt(i); bv.setTranslationY(ty); @@ -251,16 +252,14 @@ public class BubbleBarView extends FrameLayout { if (widthState == 1f) { bv.setZ(0); } - bv.showBadge(); + // When we're expanded, we're not stacked so we're not behind the stack + bv.setBehindStack(false, animate); } else { final float targetX = currentWidth - collapsedWidth + collapsedX; bv.setTranslationX(widthState * (expandedX - targetX) + targetX); bv.setZ((MAX_BUBBLES * mBubbleElevation) - i); - if (i > 0) { - bv.hideBadge(); - } else { - bv.showBadge(); - } + // If we're not the first bubble we're behind the stack + bv.setBehindStack(i > 0, animate); } } diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleView.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleView.java index 92b76a6f85..12cb8c5353 100644 --- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleView.java +++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleView.java @@ -18,7 +18,9 @@ package com.android.launcher3.taskbar.bubbles; import android.annotation.Nullable; import android.content.Context; import android.graphics.Bitmap; +import android.graphics.Canvas; import android.graphics.Outline; +import android.graphics.Rect; import android.util.AttributeSet; import android.view.LayoutInflater; import android.view.View; @@ -28,10 +30,13 @@ import android.widget.ImageView; import androidx.constraintlayout.widget.ConstraintLayout; import com.android.launcher3.R; +import com.android.launcher3.icons.DotRenderer; import com.android.launcher3.icons.IconNormalizer; +import com.android.wm.shell.animation.Interpolators; + +import java.util.EnumSet; // TODO: (b/276978250) This is will be similar to WMShell's BadgedImageView, it'd be nice to share. -// TODO: (b/269670235) currently this doesn't show the 'update dot' /** * View that displays a bubble icon, along with an app badge on either the left or @@ -39,14 +44,42 @@ import com.android.launcher3.icons.IconNormalizer; */ public class BubbleView extends ConstraintLayout { - // TODO: (b/269670235) currently we don't render the 'update dot', this will be used for that. public static final int DEFAULT_PATH_SIZE = 100; + /** + * Flags that suppress the visibility of the 'new' dot or the app badge, for one reason or + * another. If any of these flags are set, the dot will not be shown. + * If {@link SuppressionFlag#BEHIND_STACK} then the app badge will not be shown. + */ + enum SuppressionFlag { + // TODO: (b/277815200) implement flyout + // Suppressed because the flyout is visible - it will morph into the dot via animation. + FLYOUT_VISIBLE, + // Suppressed because this bubble is behind others in the collapsed stack. + BEHIND_STACK, + } + + private final EnumSet mSuppressionFlags = + EnumSet.noneOf(SuppressionFlag.class); + private final ImageView mBubbleIcon; private final ImageView mAppIcon; private final int mBubbleSize; + private DotRenderer mDotRenderer; + private DotRenderer.DrawParams mDrawParams; + private int mDotColor; + private Rect mTempBounds = new Rect(); + + // Whether the dot is animating + private boolean mDotIsAnimating; + // What scale value the dot is animating to + private float mAnimatingToDotScale; + // The current scale value of the dot + private float mDotScale; + // TODO: (b/273310265) handle RTL + // Whether the bubbles are positioned on the left or right side of the screen private boolean mOnLeft = false; private BubbleBarItem mBubble; @@ -75,6 +108,8 @@ public class BubbleView extends ConstraintLayout { mBubbleIcon = findViewById(R.id.icon_view); mAppIcon = findViewById(R.id.app_icon_view); + mDrawParams = new DotRenderer.DrawParams(); + setFocusable(true); setClickable(true); setOutlineProvider(new ViewOutlineProvider() { @@ -91,17 +126,43 @@ public class BubbleView extends ConstraintLayout { outline.setOval(inset, inset, inset + normalizedSize, inset + normalizedSize); } + @Override + public void dispatchDraw(Canvas canvas) { + super.dispatchDraw(canvas); + + if (!shouldDrawDot()) { + return; + } + + getDrawingRect(mTempBounds); + + mDrawParams.dotColor = mDotColor; + mDrawParams.iconBounds = mTempBounds; + mDrawParams.leftAlign = mOnLeft; + mDrawParams.scale = mDotScale; + + mDotRenderer.draw(canvas, mDrawParams); + } + /** Sets the bubble being rendered in this view. */ void setBubble(BubbleBarBubble bubble) { mBubble = bubble; mBubbleIcon.setImageBitmap(bubble.getIcon()); mAppIcon.setImageBitmap(bubble.getBadge()); + mDotColor = bubble.getDotColor(); + mDotRenderer = new DotRenderer(mBubbleSize, bubble.getDotPath(), DEFAULT_PATH_SIZE); } + /** + * Sets that this bubble represents the overflow. The overflow appears in the list of bubbles + * but does not represent app content, instead it shows recent bubbles that couldn't fit into + * the list of bubbles. It doesn't show an app icon because it is part of system UI / doesn't + * come from an app. + */ void setOverflow(BubbleBarOverflow overflow, Bitmap bitmap) { mBubble = overflow; mBubbleIcon.setImageBitmap(bitmap); - hideBadge(); + mAppIcon.setVisibility(GONE); // Overflow doesn't show the app badge } /** Returns the bubble being rendered in this view. */ @@ -110,38 +171,102 @@ public class BubbleView extends ConstraintLayout { return mBubble; } - /** Shows the app badge on this bubble. */ - void showBadge() { + void updateDotVisibility(boolean animate) { + final float targetScale = shouldDrawDot() ? 1f : 0f; + if (animate) { + animateDotScale(); + } else { + mDotScale = targetScale; + mAnimatingToDotScale = targetScale; + invalidate(); + } + } + + void updateBadgeVisibility() { if (mBubble instanceof BubbleBarOverflow) { // The overflow bubble does not have a badge, so just bail. return; } BubbleBarBubble bubble = (BubbleBarBubble) mBubble; - Bitmap appBadgeBitmap = bubble.getBadge(); - if (appBadgeBitmap == null) { - mAppIcon.setVisibility(GONE); + int translationX = mOnLeft + ? -(bubble.getIcon().getWidth() - appBadgeBitmap.getWidth()) + : 0; + mAppIcon.setTranslationX(translationX); + mAppIcon.setVisibility(isBehindStack() ? GONE : VISIBLE); + } + + /** Sets whether this bubble is in the stack & not the first bubble. **/ + void setBehindStack(boolean behindStack, boolean animate) { + if (behindStack) { + mSuppressionFlags.add(SuppressionFlag.BEHIND_STACK); + } else { + mSuppressionFlags.remove(SuppressionFlag.BEHIND_STACK); + } + updateDotVisibility(animate); + updateBadgeVisibility(); + } + + /** Whether this bubble is in the stack & not the first bubble. **/ + boolean isBehindStack() { + return mSuppressionFlags.contains(SuppressionFlag.BEHIND_STACK); + } + + /** Whether the dot indicating unseen content in a bubble should be shown. */ + private boolean shouldDrawDot() { + boolean bubbleHasUnseenContent = mBubble != null + && mBubble instanceof BubbleBarBubble + && mSuppressionFlags.isEmpty() + && !((BubbleBarBubble) mBubble).getInfo().isNotificationSuppressed(); + + // Always render the dot if it's animating, since it could be animating out. Otherwise, show + // it if the bubble wants to show it, and we aren't suppressing it. + return bubbleHasUnseenContent || mDotIsAnimating; + } + + /** How big the dot should be, fraction from 0 to 1. */ + private void setDotScale(float fraction) { + mDotScale = fraction; + invalidate(); + } + + /** + * Animates the dot to the given scale. + */ + private void animateDotScale() { + float toScale = shouldDrawDot() ? 1f : 0f; + mDotIsAnimating = true; + + // Don't restart the animation if we're already animating to the given value. + if (mAnimatingToDotScale == toScale || !shouldDrawDot()) { + mDotIsAnimating = false; return; } - int translationX; - if (mOnLeft) { - translationX = -(bubble.getIcon().getWidth() - appBadgeBitmap.getWidth()); - } else { - translationX = 0; - } + mAnimatingToDotScale = toScale; - mAppIcon.setTranslationX(translationX); - mAppIcon.setVisibility(VISIBLE); + final boolean showDot = toScale > 0f; + + // Do NOT wait until after animation ends to setShowDot + // to avoid overriding more recent showDot states. + clearAnimation(); + animate() + .setDuration(200) + .setInterpolator(Interpolators.FAST_OUT_SLOW_IN) + .setUpdateListener((valueAnimator) -> { + float fraction = valueAnimator.getAnimatedFraction(); + fraction = showDot ? fraction : 1f - fraction; + setDotScale(fraction); + }).withEndAction(() -> { + setDotScale(showDot ? 1f : 0f); + mDotIsAnimating = false; + }).start(); } - /** Hides the app badge on this bubble. */ - void hideBadge() { - mAppIcon.setVisibility(GONE); - } @Override public String toString() { - return "BubbleView{" + mBubble + "}"; + String toString = mBubble != null ? mBubble.getKey() : "null"; + return "BubbleView{" + toString + "}"; } }