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.app.Notification; 20 import android.content.Context; 21 import android.graphics.Bitmap; 22 import android.graphics.Canvas; 23 import android.graphics.Color; 24 import android.graphics.Path; 25 import android.graphics.PointF; 26 import android.graphics.Rect; 27 import android.graphics.drawable.BitmapDrawable; 28 import android.os.Bundle; 29 import android.text.TextUtils; 30 import android.util.AttributeSet; 31 import android.view.LayoutInflater; 32 import android.view.accessibility.AccessibilityNodeInfo; 33 import android.widget.ImageView; 34 35 import androidx.constraintlayout.widget.ConstraintLayout; 36 37 import com.android.launcher3.R; 38 import com.android.launcher3.icons.DotRenderer; 39 import com.android.wm.shell.shared.animation.Interpolators; 40 import com.android.wm.shell.shared.bubbles.BubbleBarLocation; 41 import com.android.wm.shell.shared.bubbles.BubbleInfo; 42 43 // TODO: (b/276978250) This is will be similar to WMShell's BadgedImageView, it'd be nice to share. 44 45 /** 46 * View that displays a bubble icon, along with an app badge on either the left or 47 * right side of the view. 48 */ 49 public class BubbleView extends ConstraintLayout { 50 51 public static final int DEFAULT_PATH_SIZE = 100; 52 /** Duration for animating the scale of the dot and badge. */ 53 private static final int SCALE_ANIMATION_DURATION_MS = 200; 54 55 private final ImageView mBubbleIcon; 56 private final ImageView mAppIcon; 57 private int mBubbleSize; 58 59 private float mDragTranslationX; 60 private float mOffsetX; 61 62 private DotRenderer mDotRenderer; 63 private DotRenderer.DrawParams mDrawParams; 64 private int mDotColor; 65 private Rect mTempBounds = new Rect(); 66 67 // Whether the dot is animating 68 private boolean mDotIsAnimating; 69 // What scale value the dot is animating to 70 private float mAnimatingToDotScale; 71 // The current scale value of the dot 72 private float mDotScale; 73 private boolean mDotSuppressedForBubbleUpdate = false; 74 75 // TODO: (b/273310265) handle RTL 76 // Whether the bubbles are positioned on the left or right side of the screen 77 private boolean mOnLeft = false; 78 79 private BubbleBarItem mBubble; 80 private boolean mIsOverflow; 81 82 private Bitmap mIcon; 83 84 @Nullable 85 private Controller mController; 86 87 @Nullable 88 private BubbleBarBubbleIconsFactory mIconFactory = null; 89 BubbleView(Context context)90 public BubbleView(Context context) { 91 this(context, null); 92 } 93 BubbleView(Context context, AttributeSet attrs)94 public BubbleView(Context context, AttributeSet attrs) { 95 this(context, attrs, 0); 96 } 97 BubbleView(Context context, AttributeSet attrs, int defStyleAttr)98 public BubbleView(Context context, AttributeSet attrs, int defStyleAttr) { 99 this(context, attrs, defStyleAttr, 0); 100 } 101 BubbleView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes)102 public BubbleView(Context context, AttributeSet attrs, int defStyleAttr, 103 int defStyleRes) { 104 super(context, attrs, defStyleAttr, defStyleRes); 105 // We manage positioning the badge ourselves 106 setLayoutDirection(LAYOUT_DIRECTION_LTR); 107 108 LayoutInflater.from(context).inflate(R.layout.bubble_view, this); 109 mBubbleIcon = findViewById(R.id.icon_view); 110 mAppIcon = findViewById(R.id.app_icon_view); 111 112 mDrawParams = new DotRenderer.DrawParams(); 113 114 setFocusable(true); 115 setClickable(true); 116 117 // We manage the shadow ourselves when creating the bitmap 118 setOutlineAmbientShadowColor(Color.TRANSPARENT); 119 setOutlineSpotShadowColor(Color.TRANSPARENT); 120 } 121 updateBubbleSizeAndDotRender()122 private void updateBubbleSizeAndDotRender() { 123 int updatedBubbleSize = Math.min(getWidth(), getHeight()); 124 if (updatedBubbleSize == mBubbleSize) return; 125 mBubbleSize = updatedBubbleSize; 126 mIconFactory = new BubbleBarBubbleIconsFactory(mContext, mBubbleSize); 127 updateBubbleIcon(); 128 if (mBubble == null || mBubble instanceof BubbleBarOverflow) return; 129 Path dotPath = ((BubbleBarBubble) mBubble).getDotPath(); 130 mDotRenderer = new DotRenderer(mBubbleSize, dotPath, DEFAULT_PATH_SIZE); 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 onLayout(boolean changed, int left, int top, int right, int bottom)167 protected void onLayout(boolean changed, int left, int top, int right, int bottom) { 168 super.onLayout(changed, left, top, right, bottom); 169 updateBubbleSizeAndDotRender(); 170 } 171 172 @Override dispatchDraw(Canvas canvas)173 public void dispatchDraw(Canvas canvas) { 174 super.dispatchDraw(canvas); 175 176 if (!shouldDrawDot()) { 177 return; 178 } 179 180 getDrawingRect(mTempBounds); 181 182 mDrawParams.dotColor = mDotColor; 183 mDrawParams.iconBounds = mTempBounds; 184 mDrawParams.leftAlign = mOnLeft; 185 mDrawParams.scale = mDotScale; 186 187 mDotRenderer.draw(canvas, mDrawParams); 188 } 189 190 @Override onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info)191 public void onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info) { 192 super.onInitializeAccessibilityNodeInfoInternal(info); 193 info.addAction(AccessibilityNodeInfo.ACTION_COLLAPSE); 194 if (mBubble instanceof BubbleBarBubble) { 195 info.addAction(AccessibilityNodeInfo.ACTION_DISMISS); 196 } 197 if (mController != null) { 198 if (mController.getBubbleBarLocation().isOnLeft(isLayoutRtl())) { 199 info.addAction(new AccessibilityNodeInfo.AccessibilityAction(R.id.action_move_right, 200 getResources().getString(R.string.bubble_bar_action_move_right))); 201 } else { 202 info.addAction(new AccessibilityNodeInfo.AccessibilityAction(R.id.action_move_left, 203 getResources().getString(R.string.bubble_bar_action_move_left))); 204 } 205 } 206 } 207 208 @Override performAccessibilityActionInternal(int action, Bundle arguments)209 public boolean performAccessibilityActionInternal(int action, Bundle arguments) { 210 if (super.performAccessibilityActionInternal(action, arguments)) { 211 return true; 212 } 213 if (action == AccessibilityNodeInfo.ACTION_COLLAPSE) { 214 if (mController != null) { 215 mController.collapse(); 216 } 217 return true; 218 } 219 if (action == AccessibilityNodeInfo.ACTION_DISMISS) { 220 if (mController != null) { 221 mController.dismiss(this); 222 } 223 return true; 224 } 225 if (action == R.id.action_move_left) { 226 if (mController != null) { 227 mController.updateBubbleBarLocation(BubbleBarLocation.LEFT, 228 BubbleBarLocation.UpdateSource.A11Y_ACTION_BUBBLE); 229 } 230 } 231 if (action == R.id.action_move_right) { 232 if (mController != null) { 233 mController.updateBubbleBarLocation(BubbleBarLocation.RIGHT, 234 BubbleBarLocation.UpdateSource.A11Y_ACTION_BUBBLE); 235 } 236 } 237 return false; 238 } 239 setController(@ullable Controller controller)240 void setController(@Nullable Controller controller) { 241 mController = controller; 242 } 243 244 /** Sets the bubble being rendered in this view. */ setBubble(BubbleBarBubble bubble)245 public void setBubble(BubbleBarBubble bubble) { 246 mBubble = bubble; 247 mIcon = bubble.getIcon(); 248 updateBubbleIcon(); 249 if (bubble.getInfo().showAppBadge()) { 250 mAppIcon.setImageBitmap(bubble.getBadge()); 251 } else { 252 mAppIcon.setVisibility(GONE); 253 } 254 mDotColor = bubble.getDotColor(); 255 mDotRenderer = new DotRenderer(mBubbleSize, bubble.getDotPath(), DEFAULT_PATH_SIZE); 256 String contentDesc = bubble.getInfo().getTitle(); 257 if (TextUtils.isEmpty(contentDesc)) { 258 contentDesc = getResources().getString(R.string.bubble_bar_bubble_fallback_description); 259 } 260 String appName = bubble.getInfo().getAppName(); 261 if (!TextUtils.isEmpty(appName)) { 262 contentDesc = getResources().getString(R.string.bubble_bar_bubble_description, 263 contentDesc, appName); 264 } 265 setContentDescription(contentDesc); 266 } 267 updateBubbleIcon()268 private void updateBubbleIcon() { 269 Bitmap icon = null; 270 if (mIcon != null) { 271 icon = mIcon; 272 if (mIconFactory != null) { 273 BitmapDrawable iconDrawable = new BitmapDrawable(getResources(), icon); 274 icon = mIconFactory.createShadowedIconBitmap(iconDrawable, /* scale = */ 1f); 275 } 276 } 277 mBubbleIcon.setImageBitmap(icon); 278 } 279 280 /** 281 * Sets that this bubble represents the overflow. The overflow appears in the list of bubbles 282 * but does not represent app content, instead it shows recent bubbles that couldn't fit into 283 * the list of bubbles. It doesn't show an app icon because it is part of system UI / doesn't 284 * come from an app. 285 */ setOverflow(BubbleBarOverflow overflow, Bitmap bitmap)286 public void setOverflow(BubbleBarOverflow overflow, Bitmap bitmap) { 287 mBubble = overflow; 288 mIsOverflow = true; 289 mIcon = bitmap; 290 updateBubbleIcon(); 291 mAppIcon.setVisibility(GONE); // Overflow doesn't show the app badge 292 setContentDescription(getResources().getString(R.string.bubble_bar_overflow_description)); 293 } 294 295 /** Whether this view represents the overflow button. */ isOverflow()296 public boolean isOverflow() { 297 return mIsOverflow; 298 } 299 300 /** Returns the bubble being rendered in this view. */ 301 @Nullable getBubble()302 public BubbleBarItem getBubble() { 303 return mBubble; 304 } 305 306 /** Updates the dot visibility if it's not suppressed based on whether it has unseen content. */ updateDotVisibility(boolean animate)307 public void updateDotVisibility(boolean animate) { 308 if (mDotSuppressedForBubbleUpdate) { 309 // if the dot is suppressed for an update, there's nothing to do 310 return; 311 } 312 final float targetScale = hasUnseenContent() ? 1f : 0f; 313 if (animate) { 314 animateDotScale(targetScale); 315 } else { 316 mDotScale = targetScale; 317 mAnimatingToDotScale = targetScale; 318 invalidate(); 319 } 320 } 321 setBadgeScale(float fraction)322 void setBadgeScale(float fraction) { 323 if (hasBadge()) { 324 mAppIcon.setScaleX(fraction); 325 mAppIcon.setScaleY(fraction); 326 } 327 } 328 showBadge()329 void showBadge() { 330 animateBadgeScale(1); 331 } 332 hideBadge()333 void hideBadge() { 334 animateBadgeScale(0); 335 } 336 hasBadge()337 private boolean hasBadge() { 338 return mAppIcon.getVisibility() == VISIBLE; 339 } 340 animateBadgeScale(float scale)341 private void animateBadgeScale(float scale) { 342 if (!hasBadge()) { 343 return; 344 } 345 mAppIcon.clearAnimation(); 346 mAppIcon.animate() 347 .setDuration(SCALE_ANIMATION_DURATION_MS) 348 .setInterpolator(Interpolators.FAST_OUT_SLOW_IN) 349 .scaleX(scale) 350 .scaleY(scale) 351 .start(); 352 } 353 354 /** Suppresses drawing the dot due to an update for this bubble. */ suppressDotForBubbleUpdate()355 public void suppressDotForBubbleUpdate() { 356 mDotSuppressedForBubbleUpdate = true; 357 setDotScale(0); 358 } 359 360 /** 361 * Unsuppresses the dot after the bubble update finished animating. 362 * 363 * @param animate whether or not to animate the dot back in 364 */ unsuppressDotForBubbleUpdate(boolean animate)365 public void unsuppressDotForBubbleUpdate(boolean animate) { 366 mDotSuppressedForBubbleUpdate = false; 367 showDotIfNeeded(animate); 368 } 369 hasUnseenContent()370 boolean hasUnseenContent() { 371 return mBubble != null 372 && mBubble instanceof BubbleBarBubble 373 && !((BubbleBarBubble) mBubble).getInfo().isNotificationSuppressed(); 374 } 375 376 /** 377 * Used to determine if we can skip drawing frames. 378 * 379 * <p>Generally we should draw the dot when it is requested to be shown and there is unseen 380 * content. But when the dot is removed, we still want to draw frames so that it can be scaled 381 * out. 382 */ shouldDrawDot()383 private boolean shouldDrawDot() { 384 // if there's no dot there's nothing to draw, unless the dot was removed and we're in the 385 // middle of removing it 386 return hasUnseenContent() || mDotIsAnimating; 387 } 388 389 /** Updates the dot scale to the specified fraction from 0 to 1. */ setDotScale(float fraction)390 private void setDotScale(float fraction) { 391 if (!shouldDrawDot()) { 392 return; 393 } 394 mDotScale = fraction; 395 invalidate(); 396 } 397 showDotIfNeeded(float fraction)398 void showDotIfNeeded(float fraction) { 399 if (!hasUnseenContent()) { 400 return; 401 } 402 setDotScale(fraction); 403 } 404 showDotIfNeeded(boolean animate)405 void showDotIfNeeded(boolean animate) { 406 // only show the dot if we have unseen content and it's not suppressed 407 if (!hasUnseenContent() || mDotSuppressedForBubbleUpdate) { 408 return; 409 } 410 if (animate) { 411 animateDotScale(1f); 412 } else { 413 setDotScale(1f); 414 } 415 } 416 hideDot()417 void hideDot() { 418 animateDotScale(0f); 419 } 420 421 /** Marks this bubble such that it no longer has unseen content, and hides the dot. */ markSeen()422 void markSeen() { 423 if (mBubble instanceof BubbleBarBubble bubble) { 424 BubbleInfo info = bubble.getInfo(); 425 info.setFlags( 426 info.getFlags() | Notification.BubbleMetadata.FLAG_SUPPRESS_NOTIFICATION); 427 hideDot(); 428 } 429 } 430 431 /** Animates the dot to the given scale. */ animateDotScale(float toScale)432 private void animateDotScale(float toScale) { 433 boolean isDotScaleChanging = Float.compare(mDotScale, toScale) != 0; 434 435 // Don't restart the animation if we're already animating to the given value or if the dot 436 // scale is not changing 437 if ((mDotIsAnimating && mAnimatingToDotScale == toScale) || !isDotScaleChanging) { 438 return; 439 } 440 mDotIsAnimating = true; 441 mAnimatingToDotScale = toScale; 442 443 final boolean showDot = toScale > 0f; 444 445 clearAnimation(); 446 animate() 447 .setDuration(SCALE_ANIMATION_DURATION_MS) 448 .setInterpolator(Interpolators.FAST_OUT_SLOW_IN) 449 .setUpdateListener((valueAnimator) -> { 450 float fraction = valueAnimator.getAnimatedFraction(); 451 fraction = showDot ? fraction : 1f - fraction; 452 setDotScale(fraction); 453 }).withEndAction(() -> { 454 setDotScale(showDot ? 1f : 0f); 455 mDotIsAnimating = false; 456 }).start(); 457 } 458 459 /** 460 * Returns the distance from the top left corner of this bubble view to the center of its dot. 461 */ getDotCenter()462 public PointF getDotCenter() { 463 float[] dotPosition = 464 mOnLeft ? mDotRenderer.getLeftDotPosition() : mDotRenderer.getRightDotPosition(); 465 getDrawingRect(mTempBounds); 466 float dotCenterX = mTempBounds.width() * dotPosition[0]; 467 float dotCenterY = mTempBounds.height() * dotPosition[1]; 468 return new PointF(dotCenterX, dotCenterY); 469 } 470 471 /** Returns the dot color. */ getDotColor()472 public int getDotColor() { 473 return mDotColor; 474 } 475 476 @Override toString()477 public String toString() { 478 String toString = mBubble != null ? mBubble.getKey() : "null"; 479 return "BubbleView{" + toString + "}"; 480 } 481 482 /** Interface for BubbleView to communicate with its controller */ 483 public interface Controller { 484 /** Get current bubble bar {@link BubbleBarLocation} */ getBubbleBarLocation()485 BubbleBarLocation getBubbleBarLocation(); 486 487 /** This bubble should be dismissed */ dismiss(BubbleView bubble)488 void dismiss(BubbleView bubble); 489 490 /** Collapse the bubble bar */ collapse()491 void collapse(); 492 493 /** Request bubble bar location to be updated to the given location */ updateBubbleBarLocation(BubbleBarLocation location, @BubbleBarLocation.UpdateSource int source)494 void updateBubbleBarLocation(BubbleBarLocation location, 495 @BubbleBarLocation.UpdateSource int source); 496 } 497 } 498