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