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