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