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