• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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