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