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