blob: 71f68c16bd8d09138ce6bccfc2d569220d339b05 [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
42import androidx.dynamicanimation.animation.DynamicAnimation;
43import androidx.dynamicanimation.animation.SpringAnimation;
44
45import com.android.systemui.R;
46import 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 */
52public 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
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
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. */
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 */
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. */
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). */
296 float getRestingTranslationX() {
297 return mRestingTranslationX;
298 }
299
300 /** Clamps a float to between 0 and 1. */
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 */
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. */
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. */
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}