1 /* 2 * Copyright (C) 2023 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.taskbar.bubbles; 17 18 import android.annotation.Nullable; 19 import android.content.Context; 20 import android.graphics.Bitmap; 21 import android.graphics.Canvas; 22 import android.graphics.Outline; 23 import android.graphics.Rect; 24 import android.text.TextUtils; 25 import android.util.AttributeSet; 26 import android.view.LayoutInflater; 27 import android.view.View; 28 import android.view.ViewOutlineProvider; 29 import android.widget.ImageView; 30 31 import androidx.constraintlayout.widget.ConstraintLayout; 32 33 import com.android.launcher3.R; 34 import com.android.launcher3.icons.DotRenderer; 35 import com.android.launcher3.icons.IconNormalizer; 36 import com.android.wm.shell.animation.Interpolators; 37 38 import java.util.EnumSet; 39 40 // TODO: (b/276978250) This is will be similar to WMShell's BadgedImageView, it'd be nice to share. 41 42 /** 43 * View that displays a bubble icon, along with an app badge on either the left or 44 * right side of the view. 45 */ 46 public class BubbleView extends ConstraintLayout { 47 48 public static final int DEFAULT_PATH_SIZE = 100; 49 50 /** 51 * Flags that suppress the visibility of the 'new' dot or the app badge, for one reason or 52 * another. If any of these flags are set, the dot will not be shown. 53 * If {@link SuppressionFlag#BEHIND_STACK} then the app badge will not be shown. 54 */ 55 enum SuppressionFlag { 56 // TODO: (b/277815200) implement flyout 57 // Suppressed because the flyout is visible - it will morph into the dot via animation. 58 FLYOUT_VISIBLE, 59 // Suppressed because this bubble is behind others in the collapsed stack. 60 BEHIND_STACK, 61 } 62 63 private final EnumSet<SuppressionFlag> mSuppressionFlags = 64 EnumSet.noneOf(SuppressionFlag.class); 65 66 private final ImageView mBubbleIcon; 67 private final ImageView mAppIcon; 68 private final int mBubbleSize; 69 70 private float mDragTranslationX; 71 private float mOffsetX; 72 73 private DotRenderer mDotRenderer; 74 private DotRenderer.DrawParams mDrawParams; 75 private int mDotColor; 76 private Rect mTempBounds = new Rect(); 77 78 // Whether the dot is animating 79 private boolean mDotIsAnimating; 80 // What scale value the dot is animating to 81 private float mAnimatingToDotScale; 82 // The current scale value of the dot 83 private float mDotScale; 84 85 // TODO: (b/273310265) handle RTL 86 // Whether the bubbles are positioned on the left or right side of the screen 87 private boolean mOnLeft = false; 88 89 private BubbleBarItem mBubble; 90 BubbleView(Context context)91 public BubbleView(Context context) { 92 this(context, null); 93 } 94 BubbleView(Context context, AttributeSet attrs)95 public BubbleView(Context context, AttributeSet attrs) { 96 this(context, attrs, 0); 97 } 98 BubbleView(Context context, AttributeSet attrs, int defStyleAttr)99 public BubbleView(Context context, AttributeSet attrs, int defStyleAttr) { 100 this(context, attrs, defStyleAttr, 0); 101 } 102 BubbleView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)103 public BubbleView(Context context, AttributeSet attrs, int defStyleAttr, 104 int defStyleRes) { 105 super(context, attrs, defStyleAttr, defStyleRes); 106 // We manage positioning the badge ourselves 107 setLayoutDirection(LAYOUT_DIRECTION_LTR); 108 109 LayoutInflater.from(context).inflate(R.layout.bubble_view, this); 110 111 mBubbleSize = getResources().getDimensionPixelSize(R.dimen.bubblebar_icon_size); 112 mBubbleIcon = findViewById(R.id.icon_view); 113 mAppIcon = findViewById(R.id.app_icon_view); 114 115 mDrawParams = new DotRenderer.DrawParams(); 116 117 setFocusable(true); 118 setClickable(true); 119 setOutlineProvider(new ViewOutlineProvider() { 120 @Override 121 public void getOutline(View view, Outline outline) { 122 BubbleView.this.getOutline(outline); 123 } 124 }); 125 } 126 getOutline(Outline outline)127 private void getOutline(Outline outline) { 128 final int normalizedSize = IconNormalizer.getNormalizedCircleSize(mBubbleSize); 129 final int inset = (mBubbleSize - normalizedSize) / 2; 130 outline.setOval(inset, inset, inset + normalizedSize, inset + normalizedSize); 131 } 132 133 /** 134 * Set translation-x while this bubble is being dragged. 135 * Translation applied to the view is a sum of {@code translationX} and offset defined by 136 * {@link #setOffsetX(float)}. 137 */ setDragTranslationX(float translationX)138 public void setDragTranslationX(float translationX) { 139 mDragTranslationX = translationX; 140 applyDragTranslation(); 141 } 142 143 /** 144 * Get translation value applied via {@link #setDragTranslationX(float)}. 145 */ getDragTranslationX()146 public float getDragTranslationX() { 147 return mDragTranslationX; 148 } 149 150 /** 151 * Set offset on x-axis while dragging. 152 * Used to counter parent translation in order to keep the dragged view at the current position 153 * on screen. 154 * Translation applied to the view is a sum of {@code offsetX} and translation defined by 155 * {@link #setDragTranslationX(float)} 156 */ setOffsetX(float offsetX)157 public void setOffsetX(float offsetX) { 158 mOffsetX = offsetX; 159 applyDragTranslation(); 160 } 161 applyDragTranslation()162 private void applyDragTranslation() { 163 setTranslationX(mDragTranslationX + mOffsetX); 164 } 165 166 @Override dispatchDraw(Canvas canvas)167 public void dispatchDraw(Canvas canvas) { 168 super.dispatchDraw(canvas); 169 170 if (!shouldDrawDot()) { 171 return; 172 } 173 174 getDrawingRect(mTempBounds); 175 176 mDrawParams.dotColor = mDotColor; 177 mDrawParams.iconBounds = mTempBounds; 178 mDrawParams.leftAlign = mOnLeft; 179 mDrawParams.scale = mDotScale; 180 181 mDotRenderer.draw(canvas, mDrawParams); 182 } 183 184 /** Sets the bubble being rendered in this view. */ setBubble(BubbleBarBubble bubble)185 public void setBubble(BubbleBarBubble bubble) { 186 mBubble = bubble; 187 mBubbleIcon.setImageBitmap(bubble.getIcon()); 188 mAppIcon.setImageBitmap(bubble.getBadge()); 189 mDotColor = bubble.getDotColor(); 190 mDotRenderer = new DotRenderer(mBubbleSize, bubble.getDotPath(), DEFAULT_PATH_SIZE); 191 String contentDesc = bubble.getInfo().getTitle(); 192 if (TextUtils.isEmpty(contentDesc)) { 193 contentDesc = getResources().getString(R.string.bubble_bar_bubble_fallback_description); 194 } 195 String appName = bubble.getInfo().getAppName(); 196 if (!TextUtils.isEmpty(appName)) { 197 contentDesc = getResources().getString(R.string.bubble_bar_bubble_description, 198 contentDesc, appName); 199 } 200 setContentDescription(contentDesc); 201 } 202 203 /** 204 * Sets that this bubble represents the overflow. The overflow appears in the list of bubbles 205 * but does not represent app content, instead it shows recent bubbles that couldn't fit into 206 * the list of bubbles. It doesn't show an app icon because it is part of system UI / doesn't 207 * come from an app. 208 */ setOverflow(BubbleBarOverflow overflow, Bitmap bitmap)209 public void setOverflow(BubbleBarOverflow overflow, Bitmap bitmap) { 210 mBubble = overflow; 211 mBubbleIcon.setImageBitmap(bitmap); 212 mAppIcon.setVisibility(GONE); // Overflow doesn't show the app badge 213 setContentDescription(getResources().getString(R.string.bubble_bar_overflow_description)); 214 } 215 216 /** Returns the bubble being rendered in this view. */ 217 @Nullable getBubble()218 public BubbleBarItem getBubble() { 219 return mBubble; 220 } 221 updateDotVisibility(boolean animate)222 void updateDotVisibility(boolean animate) { 223 final float targetScale = shouldDrawDot() ? 1f : 0f; 224 if (animate) { 225 animateDotScale(); 226 } else { 227 mDotScale = targetScale; 228 mAnimatingToDotScale = targetScale; 229 invalidate(); 230 } 231 } 232 updateBadgeVisibility()233 void updateBadgeVisibility() { 234 if (mBubble instanceof BubbleBarOverflow) { 235 // The overflow bubble does not have a badge, so just bail. 236 return; 237 } 238 BubbleBarBubble bubble = (BubbleBarBubble) mBubble; 239 Bitmap appBadgeBitmap = bubble.getBadge(); 240 int translationX = mOnLeft 241 ? -(bubble.getIcon().getWidth() - appBadgeBitmap.getWidth()) 242 : 0; 243 mAppIcon.setTranslationX(translationX); 244 mAppIcon.setVisibility(isBehindStack() ? GONE : VISIBLE); 245 } 246 247 /** Sets whether this bubble is in the stack & not the first bubble. **/ setBehindStack(boolean behindStack, boolean animate)248 void setBehindStack(boolean behindStack, boolean animate) { 249 if (behindStack) { 250 mSuppressionFlags.add(SuppressionFlag.BEHIND_STACK); 251 } else { 252 mSuppressionFlags.remove(SuppressionFlag.BEHIND_STACK); 253 } 254 updateDotVisibility(animate); 255 updateBadgeVisibility(); 256 } 257 258 /** Whether this bubble is in the stack & not the first bubble. **/ isBehindStack()259 boolean isBehindStack() { 260 return mSuppressionFlags.contains(SuppressionFlag.BEHIND_STACK); 261 } 262 263 /** Whether the dot indicating unseen content in a bubble should be shown. */ shouldDrawDot()264 private boolean shouldDrawDot() { 265 boolean bubbleHasUnseenContent = mBubble != null 266 && mBubble instanceof BubbleBarBubble 267 && mSuppressionFlags.isEmpty() 268 && !((BubbleBarBubble) mBubble).getInfo().isNotificationSuppressed(); 269 270 // Always render the dot if it's animating, since it could be animating out. Otherwise, show 271 // it if the bubble wants to show it, and we aren't suppressing it. 272 return bubbleHasUnseenContent || mDotIsAnimating; 273 } 274 275 /** How big the dot should be, fraction from 0 to 1. */ setDotScale(float fraction)276 private void setDotScale(float fraction) { 277 mDotScale = fraction; 278 invalidate(); 279 } 280 281 /** 282 * Animates the dot to the given scale. 283 */ animateDotScale()284 private void animateDotScale() { 285 float toScale = shouldDrawDot() ? 1f : 0f; 286 mDotIsAnimating = true; 287 288 // Don't restart the animation if we're already animating to the given value. 289 if (mAnimatingToDotScale == toScale || !shouldDrawDot()) { 290 mDotIsAnimating = false; 291 return; 292 } 293 294 mAnimatingToDotScale = toScale; 295 296 final boolean showDot = toScale > 0f; 297 298 // Do NOT wait until after animation ends to setShowDot 299 // to avoid overriding more recent showDot states. 300 clearAnimation(); 301 animate() 302 .setDuration(200) 303 .setInterpolator(Interpolators.FAST_OUT_SLOW_IN) 304 .setUpdateListener((valueAnimator) -> { 305 float fraction = valueAnimator.getAnimatedFraction(); 306 fraction = showDot ? fraction : 1f - fraction; 307 setDotScale(fraction); 308 }).withEndAction(() -> { 309 setDotScale(showDot ? 1f : 0f); 310 mDotIsAnimating = false; 311 }).start(); 312 } 313 314 315 @Override toString()316 public String toString() { 317 String toString = mBubble != null ? mBubble.getKey() : "null"; 318 return "BubbleView{" + toString + "}"; 319 } 320 } 321