1 /*
2  * Copyright (C) 2020 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 
17 package com.android.wm.shell.bubbles;
18 
19 import static android.graphics.Paint.ANTI_ALIAS_FLAG;
20 import static android.graphics.Paint.FILTER_BITMAP_FLAG;
21 
22 import static com.android.wm.shell.animation.Interpolators.ALPHA_IN;
23 import static com.android.wm.shell.animation.Interpolators.ALPHA_OUT;
24 
25 import android.animation.ArgbEvaluator;
26 import android.content.Context;
27 import android.content.res.Resources;
28 import android.content.res.TypedArray;
29 import android.graphics.Canvas;
30 import android.graphics.Color;
31 import android.graphics.Matrix;
32 import android.graphics.Outline;
33 import android.graphics.Paint;
34 import android.graphics.Path;
35 import android.graphics.PointF;
36 import android.graphics.RectF;
37 import android.graphics.drawable.Drawable;
38 import android.graphics.drawable.ShapeDrawable;
39 import android.text.TextUtils;
40 import android.util.TypedValue;
41 import android.view.LayoutInflater;
42 import android.view.View;
43 import android.view.ViewGroup;
44 import android.view.ViewOutlineProvider;
45 import android.widget.FrameLayout;
46 import android.widget.ImageView;
47 import android.widget.TextView;
48 
49 import androidx.annotation.Nullable;
50 
51 import com.android.wm.shell.R;
52 import com.android.wm.shell.common.TriangleShape;
53 
54 /**
55  * Flyout view that appears as a 'chat bubble' alongside the bubble stack. The flyout can visually
56  * transform into the 'new' dot, which is used during flyout dismiss animations/gestures.
57  */
58 public class BubbleFlyoutView extends FrameLayout {
59     /** Translation Y of fade animation. */
60     private static final float FLYOUT_FADE_Y = 40f;
61 
62     private static final long FLYOUT_FADE_OUT_DURATION = 150L;
63     private static final long FLYOUT_FADE_IN_DURATION = 250L;
64 
65     // Whether the flyout view should show a pointer to the bubble.
66     private static final boolean SHOW_POINTER = false;
67 
68     private BubblePositioner mPositioner;
69 
70     private final int mFlyoutPadding;
71     private final int mFlyoutSpaceFromBubble;
72     private final int mPointerSize;
73     private int mBubbleSize;
74 
75     private final int mFlyoutElevation;
76     private final int mBubbleElevation;
77     private final int mFloatingBackgroundColor;
78     private final float mCornerRadius;
79 
80     private final ViewGroup mFlyoutTextContainer;
81     private final ImageView mSenderAvatar;
82     private final TextView mSenderText;
83     private final TextView mMessageText;
84 
85     /** Values related to the 'new' dot which we use to figure out where to collapse the flyout. */
86     private float mNewDotRadius;
87     private float mNewDotSize;
88     private float mOriginalDotSize;
89 
90     /**
91      * The paint used to draw the background, whose color changes as the flyout transitions to the
92      * tinted 'new' dot.
93      */
94     private final Paint mBgPaint = new Paint(ANTI_ALIAS_FLAG | FILTER_BITMAP_FLAG);
95     private final ArgbEvaluator mArgbEvaluator = new ArgbEvaluator();
96 
97     /**
98      * Triangular ShapeDrawables used for the triangle that points from the flyout to the bubble
99      * stack (a chat-bubble effect).
100      */
101     private final ShapeDrawable mLeftTriangleShape;
102     private final ShapeDrawable mRightTriangleShape;
103 
104     /** Whether the flyout arrow is on the left (pointing left) or right (pointing right). */
105     private boolean mArrowPointingLeft = true;
106 
107     /** Color of the 'new' dot that the flyout will transform into. */
108     private int mDotColor;
109 
110     /** The outline of the triangle, used for elevation shadows. */
111     private final Outline mTriangleOutline = new Outline();
112 
113     /** The bounds of the flyout background, kept up to date as it transitions to the 'new' dot. */
114     private final RectF mBgRect = new RectF();
115 
116     /** The y position of the flyout, relative to the top of the screen. */
117     private float mFlyoutY = 0f;
118 
119     /**
120      * Percent progress in the transition from flyout to 'new' dot. These two values are the inverse
121      * of each other (if we're 40% transitioned to the dot, we're 60% flyout), but it makes the code
122      * much more readable.
123      */
124     private float mPercentTransitionedToDot = 1f;
125     private float mPercentStillFlyout = 0f;
126 
127     /**
128      * The difference in values between the flyout and the dot. These differences are gradually
129      * added over the course of the animation to transform the flyout into the 'new' dot.
130      */
131     private float mFlyoutToDotWidthDelta = 0f;
132     private float mFlyoutToDotHeightDelta = 0f;
133 
134     /** The translation values when the flyout is completely transitioned into the dot. */
135     private float mTranslationXWhenDot = 0f;
136     private float mTranslationYWhenDot = 0f;
137 
138     /**
139      * The current translation values applied to the flyout background as it transitions into the
140      * 'new' dot.
141      */
142     private float mBgTranslationX;
143     private float mBgTranslationY;
144 
145     private float[] mDotCenter;
146 
147     /** The flyout's X translation when at rest (not animating or dragging). */
148     private float mRestingTranslationX = 0f;
149 
150     /** The badge sizes are defined as percentages of the app icon size. Same value as Launcher3. */
151     private static final float SIZE_PERCENTAGE = 0.228f;
152 
153     private static final float DOT_SCALE = 1f;
154 
155     /** Callback to run when the flyout is hidden. */
156     @Nullable private Runnable mOnHide;
157 
BubbleFlyoutView(Context context, BubblePositioner positioner)158     public BubbleFlyoutView(Context context, BubblePositioner positioner) {
159         super(context);
160         mPositioner = positioner;
161 
162         LayoutInflater.from(context).inflate(R.layout.bubble_flyout, this, true);
163         mFlyoutTextContainer = findViewById(R.id.bubble_flyout_text_container);
164         mSenderText = findViewById(R.id.bubble_flyout_name);
165         mSenderAvatar = findViewById(R.id.bubble_flyout_avatar);
166         mMessageText = mFlyoutTextContainer.findViewById(R.id.bubble_flyout_text);
167 
168         final Resources res = getResources();
169         mFlyoutPadding = res.getDimensionPixelSize(R.dimen.bubble_flyout_padding_x);
170         mFlyoutSpaceFromBubble = res.getDimensionPixelSize(R.dimen.bubble_flyout_space_from_bubble);
171         mPointerSize = SHOW_POINTER
172                 ? res.getDimensionPixelSize(R.dimen.bubble_flyout_pointer_size)
173                 : 0;
174 
175         mBubbleElevation = res.getDimensionPixelSize(R.dimen.bubble_elevation);
176         mFlyoutElevation = res.getDimensionPixelSize(R.dimen.bubble_flyout_elevation);
177 
178         final TypedArray ta = mContext.obtainStyledAttributes(
179                 new int[] {
180                         com.android.internal.R.attr.materialColorSurfaceContainer,
181                         android.R.attr.dialogCornerRadius});
182         mFloatingBackgroundColor = ta.getColor(0, Color.WHITE);
183         mCornerRadius = ta.getDimensionPixelSize(1, 0);
184         ta.recycle();
185 
186         // Add padding for the pointer on either side, onDraw will draw it in this space.
187         setPadding(mPointerSize, 0, mPointerSize, 0);
188         setWillNotDraw(false);
189         setClipChildren(!SHOW_POINTER);
190         setTranslationZ(mFlyoutElevation);
191         setOutlineProvider(new ViewOutlineProvider() {
192             @Override
193             public void getOutline(View view, Outline outline) {
194                 BubbleFlyoutView.this.getOutline(outline);
195             }
196         });
197 
198         // Use locale direction so the text is aligned correctly.
199         setLayoutDirection(LAYOUT_DIRECTION_LOCALE);
200 
201         mBgPaint.setColor(mFloatingBackgroundColor);
202 
203         mLeftTriangleShape =
204                 new ShapeDrawable(TriangleShape.createHorizontal(
205                         mPointerSize, mPointerSize, true /* isPointingLeft */));
206         mLeftTriangleShape.setBounds(0, 0, mPointerSize, mPointerSize);
207         mLeftTriangleShape.getPaint().setColor(mFloatingBackgroundColor);
208 
209         mRightTriangleShape =
210                 new ShapeDrawable(TriangleShape.createHorizontal(
211                         mPointerSize, mPointerSize, false /* isPointingLeft */));
212         mRightTriangleShape.setBounds(0, 0, mPointerSize, mPointerSize);
213         mRightTriangleShape.getPaint().setColor(mFloatingBackgroundColor);
214     }
215 
216     @Override
onDraw(Canvas canvas)217     protected void onDraw(Canvas canvas) {
218         renderBackground(canvas);
219         invalidateOutline();
220         super.onDraw(canvas);
221     }
222 
updateFontSize()223     void updateFontSize() {
224         final float fontSize = mContext.getResources()
225                 .getDimensionPixelSize(com.android.internal.R.dimen.text_size_body_2_material);
226         mMessageText.setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize);
227         mSenderText.setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSize);
228     }
229 
230     /*
231      * Fade animation for consecutive flyouts.
232      */
animateUpdate(Bubble.FlyoutMessage flyoutMessage, PointF stackPos, boolean hideDot, float[] dotCenter, Runnable onHide)233     void animateUpdate(Bubble.FlyoutMessage flyoutMessage, PointF stackPos,
234             boolean hideDot, float[] dotCenter, Runnable onHide) {
235         mOnHide = onHide;
236         mDotCenter = dotCenter;
237         final Runnable afterFadeOut = () -> {
238             updateFlyoutMessage(flyoutMessage);
239             // Wait for TextViews to layout with updated height.
240             post(() -> {
241                 fade(true /* in */, stackPos, hideDot, () -> {} /* after */);
242             } /* after */ );
243         };
244         fade(false /* in */, stackPos, hideDot, afterFadeOut);
245     }
246 
247     /*
248      * Fade-out above or fade-in from below.
249      */
fade(boolean in, PointF stackPos, boolean hideDot, Runnable afterFade)250     private void fade(boolean in, PointF stackPos, boolean hideDot, Runnable afterFade) {
251         mFlyoutY = stackPos.y + (mBubbleSize - mFlyoutTextContainer.getHeight()) / 2f;
252 
253         setAlpha(in ? 0f : 1f);
254         setTranslationY(in ? mFlyoutY + FLYOUT_FADE_Y : mFlyoutY);
255         updateFlyoutX(stackPos.x);
256         setTranslationX(mRestingTranslationX);
257         updateDot(stackPos, hideDot);
258 
259         animate()
260                 .alpha(in ? 1f : 0f)
261                 .setDuration(in ? FLYOUT_FADE_IN_DURATION : FLYOUT_FADE_OUT_DURATION)
262                 .setInterpolator(in ? ALPHA_IN : ALPHA_OUT);
263         animate()
264                 .translationY(in ? mFlyoutY : mFlyoutY - FLYOUT_FADE_Y)
265                 .setDuration(in ? FLYOUT_FADE_IN_DURATION : FLYOUT_FADE_OUT_DURATION)
266                 .setInterpolator(in ? ALPHA_IN : ALPHA_OUT)
267                 .withEndAction(afterFade);
268     }
269 
updateFlyoutMessage(Bubble.FlyoutMessage flyoutMessage)270     private void updateFlyoutMessage(Bubble.FlyoutMessage flyoutMessage) {
271         final Drawable senderAvatar = flyoutMessage.senderAvatar;
272         if (senderAvatar != null && flyoutMessage.isGroupChat) {
273             mSenderAvatar.setVisibility(VISIBLE);
274             mSenderAvatar.setImageDrawable(senderAvatar);
275         } else {
276             mSenderAvatar.setVisibility(GONE);
277             mSenderAvatar.setTranslationX(0);
278             mMessageText.setTranslationX(0);
279             mSenderText.setTranslationX(0);
280         }
281 
282         final int maxTextViewWidth = (int) mPositioner.getMaxFlyoutSize() - mFlyoutPadding * 2;
283 
284         // Name visibility
285         if (!TextUtils.isEmpty(flyoutMessage.senderName)) {
286             mSenderText.setMaxWidth(maxTextViewWidth);
287             mSenderText.setText(flyoutMessage.senderName);
288             mSenderText.setVisibility(VISIBLE);
289         } else {
290             mSenderText.setVisibility(GONE);
291         }
292 
293         // Set the flyout TextView's max width in terms of percent, and then subtract out the
294         // padding so that the entire flyout view will be the desired width (rather than the
295         // TextView being the desired width + extra padding).
296         mMessageText.setMaxWidth(maxTextViewWidth);
297         mMessageText.setText(flyoutMessage.message);
298     }
299 
updateFlyoutX(float stackX)300     void updateFlyoutX(float stackX) {
301         // Calculate the translation required to position the flyout next to the bubble stack,
302         // with the desired padding.
303         mRestingTranslationX = mArrowPointingLeft
304                 ? stackX + mBubbleSize + mFlyoutSpaceFromBubble
305                 : stackX - getWidth() - mFlyoutSpaceFromBubble;
306     }
307 
updateDot(PointF stackPos, boolean hideDot)308     void updateDot(PointF stackPos, boolean hideDot) {
309         // Calculate the difference in size between the flyout and the 'dot' so that we can
310         // transform into the dot later.
311         final float newDotSize = hideDot ? 0f : mNewDotSize;
312         mFlyoutToDotWidthDelta = getWidth() - newDotSize;
313         mFlyoutToDotHeightDelta = getHeight() - newDotSize;
314 
315         // Calculate the translation values needed to be in the correct 'new dot' position.
316         final float adjustmentForScaleAway = hideDot ? 0f : (mOriginalDotSize / 2f);
317         final float dotPositionX = stackPos.x + mDotCenter[0] - adjustmentForScaleAway;
318         final float dotPositionY = stackPos.y + mDotCenter[1] - adjustmentForScaleAway;
319 
320         final float distanceFromFlyoutLeftToDotCenterX = mRestingTranslationX - dotPositionX;
321         final float distanceFromLayoutTopToDotCenterY = mFlyoutY - dotPositionY;
322 
323         mTranslationXWhenDot = -distanceFromFlyoutLeftToDotCenterX;
324         mTranslationYWhenDot = -distanceFromLayoutTopToDotCenterY;
325     }
326 
327     /** Configures the flyout, collapsed into dot form. */
setupFlyoutStartingAsDot( Bubble.FlyoutMessage flyoutMessage, PointF stackPos, boolean arrowPointingLeft, int dotColor, @Nullable Runnable onLayoutComplete, @Nullable Runnable onHide, float[] dotCenter, boolean hideDot)328     void setupFlyoutStartingAsDot(
329             Bubble.FlyoutMessage flyoutMessage,
330             PointF stackPos,
331             boolean arrowPointingLeft,
332             int dotColor,
333             @Nullable Runnable onLayoutComplete,
334             @Nullable Runnable onHide,
335             float[] dotCenter,
336             boolean hideDot)  {
337 
338         mBubbleSize = mPositioner.getBubbleSize();
339 
340         mOriginalDotSize = SIZE_PERCENTAGE * mBubbleSize;
341         mNewDotRadius = (DOT_SCALE * mOriginalDotSize) / 2f;
342         mNewDotSize = mNewDotRadius * 2f;
343 
344         updateFlyoutMessage(flyoutMessage);
345 
346         mArrowPointingLeft = arrowPointingLeft;
347         mDotColor = dotColor;
348         mOnHide = onHide;
349         mDotCenter = dotCenter;
350 
351         setCollapsePercent(1f);
352 
353         // Wait for TextViews to layout with updated height.
354         post(() -> {
355             // Flyout is vertically centered with respect to the bubble.
356             mFlyoutY =
357                     stackPos.y + (mBubbleSize - mFlyoutTextContainer.getHeight()) / 2f;
358             setTranslationY(mFlyoutY);
359             updateFlyoutX(stackPos.x);
360             updateDot(stackPos, hideDot);
361             if (onLayoutComplete != null) {
362                 onLayoutComplete.run();
363             }
364         });
365     }
366 
367     /**
368      * Hides the flyout and runs the optional callback passed into setupFlyoutStartingAsDot.
369      * The flyout has been animated into the 'new' dot by the time we call this, so no animations
370      * are needed.
371      */
hideFlyout()372     void hideFlyout() {
373         if (mOnHide != null) {
374             mOnHide.run();
375             mOnHide = null;
376         }
377 
378         setVisibility(GONE);
379     }
380 
381     /** Sets the percentage that the flyout should be collapsed into dot form. */
setCollapsePercent(float percentCollapsed)382     void setCollapsePercent(float percentCollapsed) {
383         // This is unlikely, but can happen in a race condition where the flyout view hasn't been
384         // laid out and returns 0 for getWidth(). We check for this condition at the sites where
385         // this method is called, but better safe than sorry.
386         if (Float.isNaN(percentCollapsed)) {
387             return;
388         }
389 
390         mPercentTransitionedToDot = Math.max(0f, Math.min(percentCollapsed, 1f));
391         mPercentStillFlyout = (1f - mPercentTransitionedToDot);
392 
393         // Move and fade out the text.
394         final float translationX = mPercentTransitionedToDot
395                 * (mArrowPointingLeft ? -getWidth() : getWidth());
396         final float alpha = clampPercentage(
397                 (mPercentStillFlyout - (1f - BubbleStackView.FLYOUT_DRAG_PERCENT_DISMISS))
398                         / BubbleStackView.FLYOUT_DRAG_PERCENT_DISMISS);
399 
400         mMessageText.setTranslationX(translationX);
401         mMessageText.setAlpha(alpha);
402 
403         mSenderText.setTranslationX(translationX);
404         mSenderText.setAlpha(alpha);
405 
406         mSenderAvatar.setTranslationX(translationX);
407         mSenderAvatar.setAlpha(alpha);
408 
409         // Reduce the elevation towards that of the topmost bubble.
410         setTranslationZ(
411                 mFlyoutElevation
412                         - (mFlyoutElevation - mBubbleElevation) * mPercentTransitionedToDot);
413         invalidate();
414     }
415 
416     /** Return the flyout's resting X translation (translation when not dragging or animating). */
getRestingTranslationX()417     float getRestingTranslationX() {
418         return mRestingTranslationX;
419     }
420 
421     /** Clamps a float to between 0 and 1. */
clampPercentage(float percent)422     private float clampPercentage(float percent) {
423         return Math.min(1f, Math.max(0f, percent));
424     }
425 
426     /**
427      * Renders the background, which is either the rounded 'chat bubble' flyout, or some state
428      * between that and the 'new' dot over the bubbles.
429      */
renderBackground(Canvas canvas)430     private void renderBackground(Canvas canvas) {
431         // Calculate the width, height, and corner radius of the flyout given the current collapsed
432         // percentage.
433         final float width = getWidth() - (mFlyoutToDotWidthDelta * mPercentTransitionedToDot);
434         final float height = getHeight() - (mFlyoutToDotHeightDelta * mPercentTransitionedToDot);
435         final float interpolatedRadius = getInterpolatedRadius();
436 
437         // Translate the flyout background towards the collapsed 'dot' state.
438         mBgTranslationX = mTranslationXWhenDot * mPercentTransitionedToDot;
439         mBgTranslationY = mTranslationYWhenDot * mPercentTransitionedToDot;
440 
441         // Set the bounds of the rounded rectangle that serves as either the flyout background or
442         // the collapsed 'dot'. These bounds will also be used to provide the outline for elevation
443         // shadows. In the expanded flyout state, the left and right bounds leave space for the
444         // pointer triangle - as the flyout collapses, this space is reduced since the triangle
445         // retracts into the flyout.
446         mBgRect.set(
447                 mPointerSize * mPercentStillFlyout /* left */,
448                 0 /* top */,
449                 width - mPointerSize * mPercentStillFlyout /* right */,
450                 height /* bottom */);
451 
452         mBgPaint.setColor(
453                 (int) mArgbEvaluator.evaluate(
454                         mPercentTransitionedToDot, mFloatingBackgroundColor, mDotColor));
455 
456         canvas.save();
457         canvas.translate(mBgTranslationX, mBgTranslationY);
458         renderPointerTriangle(canvas, width, height);
459         canvas.drawRoundRect(mBgRect, interpolatedRadius, interpolatedRadius, mBgPaint);
460         canvas.restore();
461     }
462 
463     /** Renders the 'pointer' triangle that points from the flyout to the bubble stack. */
renderPointerTriangle( Canvas canvas, float currentFlyoutWidth, float currentFlyoutHeight)464     private void renderPointerTriangle(
465             Canvas canvas, float currentFlyoutWidth, float currentFlyoutHeight) {
466         if (!SHOW_POINTER) return;
467         canvas.save();
468 
469         // Translation to apply for the 'retraction' effect as the flyout collapses.
470         final float retractionTranslationX =
471                 (mArrowPointingLeft ? 1 : -1) * (mPercentTransitionedToDot * mPointerSize * 2f);
472 
473         // Place the arrow either at the left side, or the far right, depending on whether the
474         // flyout is on the left or right side.
475         final float arrowTranslationX =
476                 mArrowPointingLeft
477                         ? retractionTranslationX
478                         : currentFlyoutWidth - mPointerSize + retractionTranslationX;
479 
480         // Vertically center the arrow at all times.
481         final float arrowTranslationY = currentFlyoutHeight / 2f - mPointerSize / 2f;
482 
483         // Draw the appropriate direction of arrow.
484         final ShapeDrawable relevantTriangle =
485                 mArrowPointingLeft ? mLeftTriangleShape : mRightTriangleShape;
486         canvas.translate(arrowTranslationX, arrowTranslationY);
487         relevantTriangle.setAlpha((int) (255f * mPercentStillFlyout));
488         relevantTriangle.draw(canvas);
489 
490         // Save the triangle's outline for use in the outline provider, offsetting it to reflect its
491         // current position.
492         relevantTriangle.getOutline(mTriangleOutline);
493         mTriangleOutline.offset((int) arrowTranslationX, (int) arrowTranslationY);
494         canvas.restore();
495     }
496 
497     /** Builds an outline that includes the transformed flyout background and triangle. */
getOutline(Outline outline)498     private void getOutline(Outline outline) {
499         if (!mTriangleOutline.isEmpty() || !SHOW_POINTER) {
500             // Draw the rect into the outline as a path so we can merge the triangle path into it.
501             final Path rectPath = new Path();
502             final float interpolatedRadius = getInterpolatedRadius();
503             rectPath.addRoundRect(mBgRect, interpolatedRadius,
504                     interpolatedRadius, Path.Direction.CW);
505             outline.setPath(rectPath);
506 
507             // Get rid of the triangle path once it has disappeared behind the flyout.
508             if (SHOW_POINTER && mPercentStillFlyout > 0.5f) {
509                 outline.mPath.addPath(mTriangleOutline.mPath);
510             }
511 
512             // Translate the outline to match the background's position.
513             final Matrix outlineMatrix = new Matrix();
514             outlineMatrix.postTranslate(getLeft() + mBgTranslationX, getTop() + mBgTranslationY);
515 
516             // At the very end, retract the outline into the bubble so the shadow will be pulled
517             // into the flyout-dot as it (visually) becomes part of the bubble. We can't do this by
518             // animating translationZ to zero since then it'll go under the bubbles, which have
519             // elevation.
520             if (mPercentTransitionedToDot > 0.98f) {
521                 final float percentBetween99and100 = (mPercentTransitionedToDot - 0.98f) / .02f;
522                 final float percentShadowVisible = 1f - percentBetween99and100;
523 
524                 // Keep it centered.
525                 outlineMatrix.postTranslate(
526                         mNewDotRadius * percentBetween99and100,
527                         mNewDotRadius * percentBetween99and100);
528                 outlineMatrix.preScale(percentShadowVisible, percentShadowVisible);
529             }
530 
531             outline.mPath.transform(outlineMatrix);
532         }
533     }
534 
getInterpolatedRadius()535     private float getInterpolatedRadius() {
536         return mNewDotRadius * mPercentTransitionedToDot
537                 + mCornerRadius * (1 - mPercentTransitionedToDot);
538     }
539 }
540