blob: 58f3f2211d81497dc9a77c843045c1fea2f4eb2b [file] [log] [blame]
Joshua Tsuji6549e702019-05-02 13:13:16 -04001/*
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
17package com.android.systemui.bubbles;
18
19import static android.graphics.Paint.ANTI_ALIAS_FLAG;
20import static android.graphics.Paint.FILTER_BITMAP_FLAG;
21
22import android.animation.ArgbEvaluator;
23import android.content.Context;
24import android.content.res.Resources;
25import android.content.res.TypedArray;
26import android.graphics.Canvas;
27import android.graphics.Color;
28import android.graphics.Matrix;
29import android.graphics.Outline;
30import android.graphics.Paint;
31import android.graphics.Path;
32import android.graphics.PointF;
33import android.graphics.RectF;
34import android.graphics.drawable.ShapeDrawable;
35import android.view.LayoutInflater;
36import android.view.View;
37import android.view.ViewGroup;
38import android.view.ViewOutlineProvider;
39import android.widget.FrameLayout;
40import android.widget.TextView;
41
Joshua Tsuji14e68552019-06-06 17:17:08 -040042import androidx.annotation.Nullable;
Joshua Tsuji6549e702019-05-02 13:13:16 -040043
44import com.android.systemui.R;
45import 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 */
51public 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;
Lyn Han1b4f25e2019-06-11 13:56:34 -070059 private final int mBubbleIconBitmapSize;
Lyn Han4f01acc2019-06-12 13:06:31 -070060 private final float mBubbleIconTopPadding;
61
Joshua Tsuji6549e702019-05-02 13:13:16 -040062 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;
Joshua Tsuji6549e702019-05-02 13:13:16 -040069
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;
Lyn Han4f01acc2019-06-12 13:06:31 -070073 private final float mOriginalDotSize;
Joshua Tsuji6549e702019-05-02 13:13:16 -040074
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;
Joshua Tsuji6549e702019-05-02 13:13:16 -0400115
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
Lyn Han61d5d562019-07-01 17:39:38 -0700127 private float[] mDotCenter;
128
Joshua Tsuji6549e702019-05-02 13:13:16 -0400129 /** The flyout's X translation when at rest (not animating or dragging). */
130 private float mRestingTranslationX = 0f;
131
Lyn Han4f01acc2019-06-12 13:06:31 -0700132 /** 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
Lyn Han61d5d562019-07-01 17:39:38 -0700135 private static final float DOT_SCALE = 1f;
Lyn Han4f01acc2019-06-12 13:06:31 -0700136
Joshua Tsuji6549e702019-05-02 13:13:16 -0400137 /** Callback to run when the flyout is hidden. */
Joshua Tsuji14e68552019-06-06 17:17:08 -0400138 @Nullable private Runnable mOnHide;
Joshua Tsuji6549e702019-05-02 13:13:16 -0400139
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);
Lyn Han1b4f25e2019-06-11 13:56:34 -0700151
Joshua Tsuji6549e702019-05-02 13:13:16 -0400152 mBubbleSize = res.getDimensionPixelSize(R.dimen.individual_bubble_size);
Lyn Han1b4f25e2019-06-11 13:56:34 -0700153 mBubbleIconBitmapSize = res.getDimensionPixelSize(R.dimen.bubble_icon_bitmap_size);
Lyn Han4f01acc2019-06-12 13:06:31 -0700154 mBubbleIconTopPadding = (mBubbleSize - mBubbleIconBitmapSize) / 2f;
155
Joshua Tsuji6549e702019-05-02 13:13:16 -0400156 mBubbleElevation = res.getDimensionPixelSize(R.dimen.bubble_elevation);
157 mFlyoutElevation = res.getDimensionPixelSize(R.dimen.bubble_flyout_elevation);
Lyn Han4f01acc2019-06-12 13:06:31 -0700158
159 mOriginalDotSize = SIZE_PERCENTAGE * mBubbleIconBitmapSize;
160 mNewDotRadius = (DOT_SCALE * mOriginalDotSize) / 2f;
Joshua Tsuji6549e702019-05-02 13:13:16 -0400161 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);
Joshua Tsuji6549e702019-05-02 13:13:16 -0400169 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
199 protected void onDraw(Canvas canvas) {
200 renderBackground(canvas);
201 invalidateOutline();
202 super.onDraw(canvas);
203 }
204
Joshua Tsuji14e68552019-06-06 17:17:08 -0400205 /** Configures the flyout, collapsed into to dot form. */
206 void setupFlyoutStartingAsDot(
Joshua Tsuji6549e702019-05-02 13:13:16 -0400207 CharSequence updateMessage, PointF stackPos, float parentWidth,
Joshua Tsuji14e68552019-06-06 17:17:08 -0400208 boolean arrowPointingLeft, int dotColor, @Nullable Runnable onLayoutComplete,
Lyn Han61d5d562019-07-01 17:39:38 -0700209 @Nullable Runnable onHide, float[] dotCenter) {
Joshua Tsuji6549e702019-05-02 13:13:16 -0400210 mArrowPointingLeft = arrowPointingLeft;
211 mDotColor = dotColor;
212 mOnHide = onHide;
Lyn Han61d5d562019-07-01 17:39:38 -0700213 mDotCenter = dotCenter;
Joshua Tsuji6549e702019-05-02 13:13:16 -0400214
Joshua Tsuji14e68552019-06-06 17:17:08 -0400215 setCollapsePercent(1f);
Joshua Tsuji6549e702019-05-02 13:13:16 -0400216
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(() -> {
Lyn Han61d5d562019-07-01 17:39:38 -0700226 float restingTranslationY;
Joshua Tsuji6549e702019-05-02 13:13:16 -0400227 // Multi line flyouts get top-aligned to the bubble.
228 if (mFlyoutText.getLineCount() > 1) {
Lyn Han61d5d562019-07-01 17:39:38 -0700229 restingTranslationY = stackPos.y + mBubbleIconTopPadding;
Joshua Tsuji6549e702019-05-02 13:13:16 -0400230 } else {
231 // Single line flyouts are vertically centered with respect to the bubble.
Lyn Han61d5d562019-07-01 17:39:38 -0700232 restingTranslationY =
233 stackPos.y + (mBubbleSize - mFlyoutTextContainer.getHeight()) / 2f;
Joshua Tsuji6549e702019-05-02 13:13:16 -0400234 }
Lyn Han61d5d562019-07-01 17:39:38 -0700235 setTranslationY(restingTranslationY);
Joshua Tsuji6549e702019-05-02 13:13:16 -0400236
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
Joshua Tsuji6549e702019-05-02 13:13:16 -0400243 // 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.
Lyn Han61d5d562019-07-01 17:39:38 -0700249 final float dotPositionX = stackPos.x + mDotCenter[0] - (mOriginalDotSize / 2f);
250 final float dotPositionY = stackPos.y + mDotCenter[1] - (mOriginalDotSize / 2f);
Joshua Tsuji14e68552019-06-06 17:17:08 -0400251
Lyn Han61d5d562019-07-01 17:39:38 -0700252 final float distanceFromFlyoutLeftToDotCenterX = mRestingTranslationX - dotPositionX;
253 final float distanceFromLayoutTopToDotCenterY = restingTranslationY - dotPositionY;
254
255 mTranslationXWhenDot = -distanceFromFlyoutLeftToDotCenterX;
256 mTranslationYWhenDot = -distanceFromLayoutTopToDotCenterY;
Joshua Tsuji14e68552019-06-06 17:17:08 -0400257 if (onLayoutComplete != null) {
258 onLayoutComplete.run();
259 }
Joshua Tsuji6549e702019-05-02 13:13:16 -0400260 });
261 }
262
263 /**
Joshua Tsuji14e68552019-06-06 17:17:08 -0400264 * 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.
Joshua Tsuji6549e702019-05-02 13:13:16 -0400267 */
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. */
278 void setCollapsePercent(float percentCollapsed) {
Joshua Tsuji8e05aab2019-08-22 14:57:50 -0400279 // 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
Joshua Tsuji6549e702019-05-02 13:13:16 -0400286 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). */
304 float getRestingTranslationX() {
305 return mRestingTranslationX;
306 }
307
308 /** Clamps a float to between 0 and 1. */
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 */
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);
Mady Mellorc713fe32019-07-31 16:39:07 -0700322 final float interpolatedRadius = mNewDotRadius * mPercentTransitionedToDot
323 + mCornerRadius * (1 - mPercentTransitionedToDot);
Joshua Tsuji6549e702019-05-02 13:13:16 -0400324
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);
Mady Mellorc713fe32019-07-31 16:39:07 -0700347 canvas.drawRoundRect(mBgRect, interpolatedRadius, interpolatedRadius, mBgPaint);
Joshua Tsuji6549e702019-05-02 13:13:16 -0400348 canvas.restore();
349 }
350
351 /** Renders the 'pointer' triangle that points from the flyout to the bubble stack. */
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. */
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();
Mady Mellorc713fe32019-07-31 16:39:07 -0700390 final float interpolatedRadius = mNewDotRadius * mPercentTransitionedToDot
391 + mCornerRadius * (1 - mPercentTransitionedToDot);
392 rectPath.addRoundRect(mBgRect, interpolatedRadius,
393 interpolatedRadius, Path.Direction.CW);
Joshua Tsuji6549e702019-05-02 13:13:16 -0400394 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}