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 /** Max width of the flyout, in terms of percent of the screen width. */ 60 private static final float FLYOUT_MAX_WIDTH_PERCENT = .6f; 61 62 /** Translation Y of fade animation. */ 63 private static final float FLYOUT_FADE_Y = 40f; 64 65 private static final long FLYOUT_FADE_OUT_DURATION = 150L; 66 private static final long FLYOUT_FADE_IN_DURATION = 250L; 67 68 // Whether the flyout view should show a pointer to the bubble. 69 private static final boolean SHOW_POINTER = false; 70 71 private final int mFlyoutPadding; 72 private final int mFlyoutSpaceFromBubble; 73 private final int mPointerSize; 74 private int mBubbleSize; 75 76 private final int mFlyoutElevation; 77 private final int mBubbleElevation; 78 private final int mFloatingBackgroundColor; 79 private final float mCornerRadius; 80 81 private final ViewGroup mFlyoutTextContainer; 82 private final ImageView mSenderAvatar; 83 private final TextView mSenderText; 84 private final TextView mMessageText; 85 86 /** Values related to the 'new' dot which we use to figure out where to collapse the flyout. */ 87 private float mNewDotRadius; 88 private float mNewDotSize; 89 private float mOriginalDotSize; 90 91 /** 92 * The paint used to draw the background, whose color changes as the flyout transitions to the 93 * tinted 'new' dot. 94 */ 95 private final Paint mBgPaint = new Paint(ANTI_ALIAS_FLAG | FILTER_BITMAP_FLAG); 96 private final ArgbEvaluator mArgbEvaluator = new ArgbEvaluator(); 97 98 /** 99 * Triangular ShapeDrawables used for the triangle that points from the flyout to the bubble 100 * stack (a chat-bubble effect). 101 */ 102 private final ShapeDrawable mLeftTriangleShape; 103 private final ShapeDrawable mRightTriangleShape; 104 105 /** Whether the flyout arrow is on the left (pointing left) or right (pointing right). */ 106 private boolean mArrowPointingLeft = true; 107 108 /** Color of the 'new' dot that the flyout will transform into. */ 109 private int mDotColor; 110 111 /** The outline of the triangle, used for elevation shadows. */ 112 private final Outline mTriangleOutline = new Outline(); 113 114 /** The bounds of the flyout background, kept up to date as it transitions to the 'new' dot. */ 115 private final RectF mBgRect = new RectF(); 116 117 /** The y position of the flyout, relative to the top of the screen. */ 118 private float mFlyoutY = 0f; 119 120 /** 121 * Percent progress in the transition from flyout to 'new' dot. These two values are the inverse 122 * of each other (if we're 40% transitioned to the dot, we're 60% flyout), but it makes the code 123 * much more readable. 124 */ 125 private float mPercentTransitionedToDot = 1f; 126 private float mPercentStillFlyout = 0f; 127 128 /** 129 * The difference in values between the flyout and the dot. These differences are gradually 130 * added over the course of the animation to transform the flyout into the 'new' dot. 131 */ 132 private float mFlyoutToDotWidthDelta = 0f; 133 private float mFlyoutToDotHeightDelta = 0f; 134 135 /** The translation values when the flyout is completely transitioned into the dot. */ 136 private float mTranslationXWhenDot = 0f; 137 private float mTranslationYWhenDot = 0f; 138 139 /** 140 * The current translation values applied to the flyout background as it transitions into the 141 * 'new' dot. 142 */ 143 private float mBgTranslationX; 144 private float mBgTranslationY; 145 146 private float[] mDotCenter; 147 148 /** The flyout's X translation when at rest (not animating or dragging). */ 149 private float mRestingTranslationX = 0f; 150 151 /** The badge sizes are defined as percentages of the app icon size. Same value as Launcher3. */ 152 private static final float SIZE_PERCENTAGE = 0.228f; 153 154 private static final float DOT_SCALE = 1f; 155 156 /** Callback to run when the flyout is hidden. */ 157 @Nullable private Runnable mOnHide; 158 BubbleFlyoutView(Context context)159 public BubbleFlyoutView(Context context) { 160 super(context); 161 LayoutInflater.from(context).inflate(R.layout.bubble_flyout, this, true); 162 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.colorSurface, 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, float parentWidth, PointF stackPos, boolean hideDot, Runnable onHide)233 void animateUpdate(Bubble.FlyoutMessage flyoutMessage, float parentWidth, PointF stackPos, 234 boolean hideDot, Runnable onHide) { 235 mOnHide = onHide; 236 final Runnable afterFadeOut = () -> { 237 updateFlyoutMessage(flyoutMessage, parentWidth); 238 // Wait for TextViews to layout with updated height. 239 post(() -> { 240 fade(true /* in */, stackPos, hideDot, () -> {} /* after */); 241 } /* after */ ); 242 }; 243 fade(false /* in */, stackPos, hideDot, afterFadeOut); 244 } 245 246 /* 247 * Fade-out above or fade-in from below. 248 */ fade(boolean in, PointF stackPos, boolean hideDot, Runnable afterFade)249 private void fade(boolean in, PointF stackPos, boolean hideDot, Runnable afterFade) { 250 mFlyoutY = stackPos.y + (mBubbleSize - mFlyoutTextContainer.getHeight()) / 2f; 251 252 setAlpha(in ? 0f : 1f); 253 setTranslationY(in ? mFlyoutY + FLYOUT_FADE_Y : mFlyoutY); 254 updateFlyoutX(stackPos.x); 255 setTranslationX(mRestingTranslationX); 256 updateDot(stackPos, hideDot); 257 258 animate() 259 .alpha(in ? 1f : 0f) 260 .setDuration(in ? FLYOUT_FADE_IN_DURATION : FLYOUT_FADE_OUT_DURATION) 261 .setInterpolator(in ? ALPHA_IN : ALPHA_OUT); 262 animate() 263 .translationY(in ? mFlyoutY : mFlyoutY - FLYOUT_FADE_Y) 264 .setDuration(in ? FLYOUT_FADE_IN_DURATION : FLYOUT_FADE_OUT_DURATION) 265 .setInterpolator(in ? ALPHA_IN : ALPHA_OUT) 266 .withEndAction(afterFade); 267 } 268 updateFlyoutMessage(Bubble.FlyoutMessage flyoutMessage, float parentWidth)269 private void updateFlyoutMessage(Bubble.FlyoutMessage flyoutMessage, float parentWidth) { 270 final Drawable senderAvatar = flyoutMessage.senderAvatar; 271 if (senderAvatar != null && flyoutMessage.isGroupChat) { 272 mSenderAvatar.setVisibility(VISIBLE); 273 mSenderAvatar.setImageDrawable(senderAvatar); 274 } else { 275 mSenderAvatar.setVisibility(GONE); 276 mSenderAvatar.setTranslationX(0); 277 mMessageText.setTranslationX(0); 278 mSenderText.setTranslationX(0); 279 } 280 281 final int maxTextViewWidth = 282 (int) (parentWidth * FLYOUT_MAX_WIDTH_PERCENT) - 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, float parentWidth, boolean arrowPointingLeft, int dotColor, @Nullable Runnable onLayoutComplete, @Nullable Runnable onHide, float[] dotCenter, boolean hideDot, BubblePositioner positioner)328 void setupFlyoutStartingAsDot( 329 Bubble.FlyoutMessage flyoutMessage, 330 PointF stackPos, 331 float parentWidth, 332 boolean arrowPointingLeft, 333 int dotColor, 334 @Nullable Runnable onLayoutComplete, 335 @Nullable Runnable onHide, 336 float[] dotCenter, 337 boolean hideDot, 338 BubblePositioner positioner) { 339 340 mBubbleSize = positioner.getBubbleSize(); 341 342 mOriginalDotSize = SIZE_PERCENTAGE * mBubbleSize; 343 mNewDotRadius = (DOT_SCALE * mOriginalDotSize) / 2f; 344 mNewDotSize = mNewDotRadius * 2f; 345 346 updateFlyoutMessage(flyoutMessage, parentWidth); 347 348 mArrowPointingLeft = arrowPointingLeft; 349 mDotColor = dotColor; 350 mOnHide = onHide; 351 mDotCenter = dotCenter; 352 353 setCollapsePercent(1f); 354 355 // Wait for TextViews to layout with updated height. 356 post(() -> { 357 // Flyout is vertically centered with respect to the bubble. 358 mFlyoutY = 359 stackPos.y + (mBubbleSize - mFlyoutTextContainer.getHeight()) / 2f; 360 setTranslationY(mFlyoutY); 361 updateFlyoutX(stackPos.x); 362 updateDot(stackPos, hideDot); 363 if (onLayoutComplete != null) { 364 onLayoutComplete.run(); 365 } 366 }); 367 } 368 369 /** 370 * Hides the flyout and runs the optional callback passed into setupFlyoutStartingAsDot. 371 * The flyout has been animated into the 'new' dot by the time we call this, so no animations 372 * are needed. 373 */ hideFlyout()374 void hideFlyout() { 375 if (mOnHide != null) { 376 mOnHide.run(); 377 mOnHide = null; 378 } 379 380 setVisibility(GONE); 381 } 382 383 /** Sets the percentage that the flyout should be collapsed into dot form. */ setCollapsePercent(float percentCollapsed)384 void setCollapsePercent(float percentCollapsed) { 385 // This is unlikely, but can happen in a race condition where the flyout view hasn't been 386 // laid out and returns 0 for getWidth(). We check for this condition at the sites where 387 // this method is called, but better safe than sorry. 388 if (Float.isNaN(percentCollapsed)) { 389 return; 390 } 391 392 mPercentTransitionedToDot = Math.max(0f, Math.min(percentCollapsed, 1f)); 393 mPercentStillFlyout = (1f - mPercentTransitionedToDot); 394 395 // Move and fade out the text. 396 final float translationX = mPercentTransitionedToDot 397 * (mArrowPointingLeft ? -getWidth() : getWidth()); 398 final float alpha = clampPercentage( 399 (mPercentStillFlyout - (1f - BubbleStackView.FLYOUT_DRAG_PERCENT_DISMISS)) 400 / BubbleStackView.FLYOUT_DRAG_PERCENT_DISMISS); 401 402 mMessageText.setTranslationX(translationX); 403 mMessageText.setAlpha(alpha); 404 405 mSenderText.setTranslationX(translationX); 406 mSenderText.setAlpha(alpha); 407 408 mSenderAvatar.setTranslationX(translationX); 409 mSenderAvatar.setAlpha(alpha); 410 411 // Reduce the elevation towards that of the topmost bubble. 412 setTranslationZ( 413 mFlyoutElevation 414 - (mFlyoutElevation - mBubbleElevation) * mPercentTransitionedToDot); 415 invalidate(); 416 } 417 418 /** Return the flyout's resting X translation (translation when not dragging or animating). */ getRestingTranslationX()419 float getRestingTranslationX() { 420 return mRestingTranslationX; 421 } 422 423 /** Clamps a float to between 0 and 1. */ clampPercentage(float percent)424 private float clampPercentage(float percent) { 425 return Math.min(1f, Math.max(0f, percent)); 426 } 427 428 /** 429 * Renders the background, which is either the rounded 'chat bubble' flyout, or some state 430 * between that and the 'new' dot over the bubbles. 431 */ renderBackground(Canvas canvas)432 private void renderBackground(Canvas canvas) { 433 // Calculate the width, height, and corner radius of the flyout given the current collapsed 434 // percentage. 435 final float width = getWidth() - (mFlyoutToDotWidthDelta * mPercentTransitionedToDot); 436 final float height = getHeight() - (mFlyoutToDotHeightDelta * mPercentTransitionedToDot); 437 final float interpolatedRadius = getInterpolatedRadius(); 438 439 // Translate the flyout background towards the collapsed 'dot' state. 440 mBgTranslationX = mTranslationXWhenDot * mPercentTransitionedToDot; 441 mBgTranslationY = mTranslationYWhenDot * mPercentTransitionedToDot; 442 443 // Set the bounds of the rounded rectangle that serves as either the flyout background or 444 // the collapsed 'dot'. These bounds will also be used to provide the outline for elevation 445 // shadows. In the expanded flyout state, the left and right bounds leave space for the 446 // pointer triangle - as the flyout collapses, this space is reduced since the triangle 447 // retracts into the flyout. 448 mBgRect.set( 449 mPointerSize * mPercentStillFlyout /* left */, 450 0 /* top */, 451 width - mPointerSize * mPercentStillFlyout /* right */, 452 height /* bottom */); 453 454 mBgPaint.setColor( 455 (int) mArgbEvaluator.evaluate( 456 mPercentTransitionedToDot, mFloatingBackgroundColor, mDotColor)); 457 458 canvas.save(); 459 canvas.translate(mBgTranslationX, mBgTranslationY); 460 renderPointerTriangle(canvas, width, height); 461 canvas.drawRoundRect(mBgRect, interpolatedRadius, interpolatedRadius, mBgPaint); 462 canvas.restore(); 463 } 464 465 /** Renders the 'pointer' triangle that points from the flyout to the bubble stack. */ renderPointerTriangle( Canvas canvas, float currentFlyoutWidth, float currentFlyoutHeight)466 private void renderPointerTriangle( 467 Canvas canvas, float currentFlyoutWidth, float currentFlyoutHeight) { 468 if (!SHOW_POINTER) return; 469 canvas.save(); 470 471 // Translation to apply for the 'retraction' effect as the flyout collapses. 472 final float retractionTranslationX = 473 (mArrowPointingLeft ? 1 : -1) * (mPercentTransitionedToDot * mPointerSize * 2f); 474 475 // Place the arrow either at the left side, or the far right, depending on whether the 476 // flyout is on the left or right side. 477 final float arrowTranslationX = 478 mArrowPointingLeft 479 ? retractionTranslationX 480 : currentFlyoutWidth - mPointerSize + retractionTranslationX; 481 482 // Vertically center the arrow at all times. 483 final float arrowTranslationY = currentFlyoutHeight / 2f - mPointerSize / 2f; 484 485 // Draw the appropriate direction of arrow. 486 final ShapeDrawable relevantTriangle = 487 mArrowPointingLeft ? mLeftTriangleShape : mRightTriangleShape; 488 canvas.translate(arrowTranslationX, arrowTranslationY); 489 relevantTriangle.setAlpha((int) (255f * mPercentStillFlyout)); 490 relevantTriangle.draw(canvas); 491 492 // Save the triangle's outline for use in the outline provider, offsetting it to reflect its 493 // current position. 494 relevantTriangle.getOutline(mTriangleOutline); 495 mTriangleOutline.offset((int) arrowTranslationX, (int) arrowTranslationY); 496 canvas.restore(); 497 } 498 499 /** Builds an outline that includes the transformed flyout background and triangle. */ getOutline(Outline outline)500 private void getOutline(Outline outline) { 501 if (!mTriangleOutline.isEmpty() || !SHOW_POINTER) { 502 // Draw the rect into the outline as a path so we can merge the triangle path into it. 503 final Path rectPath = new Path(); 504 final float interpolatedRadius = getInterpolatedRadius(); 505 rectPath.addRoundRect(mBgRect, interpolatedRadius, 506 interpolatedRadius, Path.Direction.CW); 507 outline.setPath(rectPath); 508 509 // Get rid of the triangle path once it has disappeared behind the flyout. 510 if (SHOW_POINTER && mPercentStillFlyout > 0.5f) { 511 outline.mPath.addPath(mTriangleOutline.mPath); 512 } 513 514 // Translate the outline to match the background's position. 515 final Matrix outlineMatrix = new Matrix(); 516 outlineMatrix.postTranslate(getLeft() + mBgTranslationX, getTop() + mBgTranslationY); 517 518 // At the very end, retract the outline into the bubble so the shadow will be pulled 519 // into the flyout-dot as it (visually) becomes part of the bubble. We can't do this by 520 // animating translationZ to zero since then it'll go under the bubbles, which have 521 // elevation. 522 if (mPercentTransitionedToDot > 0.98f) { 523 final float percentBetween99and100 = (mPercentTransitionedToDot - 0.98f) / .02f; 524 final float percentShadowVisible = 1f - percentBetween99and100; 525 526 // Keep it centered. 527 outlineMatrix.postTranslate( 528 mNewDotRadius * percentBetween99and100, 529 mNewDotRadius * percentBetween99and100); 530 outlineMatrix.preScale(percentShadowVisible, percentShadowVisible); 531 } 532 533 outline.mPath.transform(outlineMatrix); 534 } 535 } 536 getInterpolatedRadius()537 private float getInterpolatedRadius() { 538 return mNewDotRadius * mPercentTransitionedToDot 539 + mCornerRadius * (1 - mPercentTransitionedToDot); 540 } 541 } 542