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