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