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