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