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