| /* |
| * Copyright (C) 2019 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.systemui.bubbles; |
| |
| import static android.graphics.Paint.ANTI_ALIAS_FLAG; |
| import static android.graphics.Paint.FILTER_BITMAP_FLAG; |
| |
| import android.animation.ArgbEvaluator; |
| import android.content.Context; |
| import android.content.res.Resources; |
| import android.content.res.TypedArray; |
| import android.graphics.Canvas; |
| import android.graphics.Color; |
| import android.graphics.Matrix; |
| import android.graphics.Outline; |
| import android.graphics.Paint; |
| import android.graphics.Path; |
| import android.graphics.PointF; |
| import android.graphics.RectF; |
| import android.graphics.drawable.ShapeDrawable; |
| import android.view.LayoutInflater; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.view.ViewOutlineProvider; |
| import android.widget.FrameLayout; |
| import android.widget.TextView; |
| |
| import androidx.dynamicanimation.animation.DynamicAnimation; |
| import androidx.dynamicanimation.animation.SpringAnimation; |
| |
| import com.android.systemui.R; |
| import com.android.systemui.recents.TriangleShape; |
| |
| /** |
| * Flyout view that appears as a 'chat bubble' alongside the bubble stack. The flyout can visually |
| * transform into the 'new' dot, which is used during flyout dismiss animations/gestures. |
| */ |
| public class BubbleFlyoutView extends FrameLayout { |
| /** Max width of the flyout, in terms of percent of the screen width. */ |
| private static final float FLYOUT_MAX_WIDTH_PERCENT = .6f; |
| |
| private final int mFlyoutPadding; |
| private final int mFlyoutSpaceFromBubble; |
| private final int mPointerSize; |
| private final int mBubbleSize; |
| private final int mFlyoutElevation; |
| private final int mBubbleElevation; |
| private final int mFloatingBackgroundColor; |
| private final float mCornerRadius; |
| |
| private final ViewGroup mFlyoutTextContainer; |
| private final TextView mFlyoutText; |
| /** Spring animation for the flyout. */ |
| private final SpringAnimation mFlyoutSpring = |
| new SpringAnimation(this, DynamicAnimation.TRANSLATION_X); |
| |
| /** Values related to the 'new' dot which we use to figure out where to collapse the flyout. */ |
| private final float mNewDotRadius; |
| private final float mNewDotSize; |
| private final float mNewDotOffsetFromBubbleBounds; |
| |
| /** |
| * The paint used to draw the background, whose color changes as the flyout transitions to the |
| * tinted 'new' dot. |
| */ |
| private final Paint mBgPaint = new Paint(ANTI_ALIAS_FLAG | FILTER_BITMAP_FLAG); |
| private final ArgbEvaluator mArgbEvaluator = new ArgbEvaluator(); |
| |
| /** |
| * Triangular ShapeDrawables used for the triangle that points from the flyout to the bubble |
| * stack (a chat-bubble effect). |
| */ |
| private final ShapeDrawable mLeftTriangleShape; |
| private final ShapeDrawable mRightTriangleShape; |
| |
| /** Whether the flyout arrow is on the left (pointing left) or right (pointing right). */ |
| private boolean mArrowPointingLeft = true; |
| |
| /** Color of the 'new' dot that the flyout will transform into. */ |
| private int mDotColor; |
| |
| /** The outline of the triangle, used for elevation shadows. */ |
| private final Outline mTriangleOutline = new Outline(); |
| |
| /** The bounds of the flyout background, kept up to date as it transitions to the 'new' dot. */ |
| private final RectF mBgRect = new RectF(); |
| |
| /** |
| * Percent progress in the transition from flyout to 'new' dot. These two values are the inverse |
| * of each other (if we're 40% transitioned to the dot, we're 60% flyout), but it makes the code |
| * much more readable. |
| */ |
| private float mPercentTransitionedToDot = 1f; |
| private float mPercentStillFlyout = 0f; |
| |
| /** |
| * The difference in values between the flyout and the dot. These differences are gradually |
| * added over the course of the animation to transform the flyout into the 'new' dot. |
| */ |
| private float mFlyoutToDotWidthDelta = 0f; |
| private float mFlyoutToDotHeightDelta = 0f; |
| private float mFlyoutToDotCornerRadiusDelta; |
| |
| /** The translation values when the flyout is completely transitioned into the dot. */ |
| private float mTranslationXWhenDot = 0f; |
| private float mTranslationYWhenDot = 0f; |
| |
| /** |
| * The current translation values applied to the flyout background as it transitions into the |
| * 'new' dot. |
| */ |
| private float mBgTranslationX; |
| private float mBgTranslationY; |
| |
| /** The flyout's X translation when at rest (not animating or dragging). */ |
| private float mRestingTranslationX = 0f; |
| |
| /** Callback to run when the flyout is hidden. */ |
| private Runnable mOnHide; |
| |
| public BubbleFlyoutView(Context context) { |
| super(context); |
| LayoutInflater.from(context).inflate(R.layout.bubble_flyout, this, true); |
| |
| mFlyoutTextContainer = findViewById(R.id.bubble_flyout_text_container); |
| mFlyoutText = mFlyoutTextContainer.findViewById(R.id.bubble_flyout_text); |
| |
| final Resources res = getResources(); |
| mFlyoutPadding = res.getDimensionPixelSize(R.dimen.bubble_flyout_padding_x); |
| mFlyoutSpaceFromBubble = res.getDimensionPixelSize(R.dimen.bubble_flyout_space_from_bubble); |
| mPointerSize = res.getDimensionPixelSize(R.dimen.bubble_flyout_pointer_size); |
| mBubbleSize = res.getDimensionPixelSize(R.dimen.individual_bubble_size); |
| mBubbleElevation = res.getDimensionPixelSize(R.dimen.bubble_elevation); |
| mFlyoutElevation = res.getDimensionPixelSize(R.dimen.bubble_flyout_elevation); |
| mNewDotOffsetFromBubbleBounds = BadgeRenderer.getDotCenterOffset(context); |
| mNewDotRadius = BadgeRenderer.getDotRadius(mNewDotOffsetFromBubbleBounds); |
| mNewDotSize = mNewDotRadius * 2f; |
| |
| final TypedArray ta = mContext.obtainStyledAttributes( |
| new int[] { |
| android.R.attr.colorBackgroundFloating, |
| android.R.attr.dialogCornerRadius}); |
| mFloatingBackgroundColor = ta.getColor(0, Color.WHITE); |
| mCornerRadius = ta.getDimensionPixelSize(1, 0); |
| mFlyoutToDotCornerRadiusDelta = mNewDotRadius - mCornerRadius; |
| ta.recycle(); |
| |
| // Add padding for the pointer on either side, onDraw will draw it in this space. |
| setPadding(mPointerSize, 0, mPointerSize, 0); |
| setWillNotDraw(false); |
| setClipChildren(false); |
| setTranslationZ(mFlyoutElevation); |
| setOutlineProvider(new ViewOutlineProvider() { |
| @Override |
| public void getOutline(View view, Outline outline) { |
| BubbleFlyoutView.this.getOutline(outline); |
| } |
| }); |
| |
| mBgPaint.setColor(mFloatingBackgroundColor); |
| |
| mLeftTriangleShape = |
| new ShapeDrawable(TriangleShape.createHorizontal( |
| mPointerSize, mPointerSize, true /* isPointingLeft */)); |
| mLeftTriangleShape.setBounds(0, 0, mPointerSize, mPointerSize); |
| mLeftTriangleShape.getPaint().setColor(mFloatingBackgroundColor); |
| |
| mRightTriangleShape = |
| new ShapeDrawable(TriangleShape.createHorizontal( |
| mPointerSize, mPointerSize, false /* isPointingLeft */)); |
| mRightTriangleShape.setBounds(0, 0, mPointerSize, mPointerSize); |
| mRightTriangleShape.getPaint().setColor(mFloatingBackgroundColor); |
| } |
| |
| @Override |
| protected void onDraw(Canvas canvas) { |
| renderBackground(canvas); |
| invalidateOutline(); |
| super.onDraw(canvas); |
| } |
| |
| /** Configures the flyout and animates it in. */ |
| void showFlyout( |
| CharSequence updateMessage, PointF stackPos, float parentWidth, |
| boolean arrowPointingLeft, int dotColor, Runnable onHide) { |
| mArrowPointingLeft = arrowPointingLeft; |
| mDotColor = dotColor; |
| mOnHide = onHide; |
| |
| setCollapsePercent(0f); |
| setAlpha(0f); |
| setVisibility(VISIBLE); |
| |
| // Set the flyout TextView's max width in terms of percent, and then subtract out the |
| // padding so that the entire flyout view will be the desired width (rather than the |
| // TextView being the desired width + extra padding). |
| mFlyoutText.setMaxWidth( |
| (int) (parentWidth * FLYOUT_MAX_WIDTH_PERCENT) - mFlyoutPadding * 2); |
| mFlyoutText.setText(updateMessage); |
| |
| // Wait for the TextView to lay out so we know its line count. |
| post(() -> { |
| // Multi line flyouts get top-aligned to the bubble. |
| if (mFlyoutText.getLineCount() > 1) { |
| setTranslationY(stackPos.y); |
| } else { |
| // Single line flyouts are vertically centered with respect to the bubble. |
| setTranslationY( |
| stackPos.y + (mBubbleSize - mFlyoutTextContainer.getHeight()) / 2f); |
| } |
| |
| // Calculate the translation required to position the flyout next to the bubble stack, |
| // with the desired padding. |
| mRestingTranslationX = mArrowPointingLeft |
| ? stackPos.x + mBubbleSize + mFlyoutSpaceFromBubble |
| : stackPos.x - getWidth() - mFlyoutSpaceFromBubble; |
| |
| // Translate towards the stack slightly. |
| setTranslationX( |
| mRestingTranslationX + (arrowPointingLeft ? -mBubbleSize : mBubbleSize)); |
| |
| // Fade in the entire flyout and spring it to its normal position. |
| animate().alpha(1f); |
| mFlyoutSpring.animateToFinalPosition(mRestingTranslationX); |
| |
| // Calculate the difference in size between the flyout and the 'dot' so that we can |
| // transform into the dot later. |
| mFlyoutToDotWidthDelta = getWidth() - mNewDotSize; |
| mFlyoutToDotHeightDelta = getHeight() - mNewDotSize; |
| |
| // Calculate the translation values needed to be in the correct 'new dot' position. |
| final float distanceFromFlyoutLeftToDotCenterX = |
| mFlyoutSpaceFromBubble + mNewDotOffsetFromBubbleBounds / 2; |
| if (mArrowPointingLeft) { |
| mTranslationXWhenDot = -distanceFromFlyoutLeftToDotCenterX - mNewDotRadius; |
| } else { |
| mTranslationXWhenDot = |
| getWidth() + distanceFromFlyoutLeftToDotCenterX - mNewDotRadius; |
| } |
| |
| mTranslationYWhenDot = |
| getHeight() / 2f |
| - mNewDotRadius |
| - mBubbleSize / 2f |
| + mNewDotOffsetFromBubbleBounds / 2; |
| }); |
| } |
| |
| /** |
| * Hides the flyout and runs the optional callback passed into showFlyout. The flyout has been |
| * animated into the 'new' dot by the time we call this, so no animations are needed. |
| */ |
| void hideFlyout() { |
| if (mOnHide != null) { |
| mOnHide.run(); |
| mOnHide = null; |
| } |
| |
| setVisibility(GONE); |
| } |
| |
| /** Sets the percentage that the flyout should be collapsed into dot form. */ |
| void setCollapsePercent(float percentCollapsed) { |
| mPercentTransitionedToDot = Math.max(0f, Math.min(percentCollapsed, 1f)); |
| mPercentStillFlyout = (1f - mPercentTransitionedToDot); |
| |
| // Move and fade out the text. |
| mFlyoutText.setTranslationX( |
| (mArrowPointingLeft ? -getWidth() : getWidth()) * mPercentTransitionedToDot); |
| mFlyoutText.setAlpha(clampPercentage( |
| (mPercentStillFlyout - (1f - BubbleStackView.FLYOUT_DRAG_PERCENT_DISMISS)) |
| / BubbleStackView.FLYOUT_DRAG_PERCENT_DISMISS)); |
| |
| // Reduce the elevation towards that of the topmost bubble. |
| setTranslationZ( |
| mFlyoutElevation |
| - (mFlyoutElevation - mBubbleElevation) * mPercentTransitionedToDot); |
| invalidate(); |
| } |
| |
| /** Return the flyout's resting X translation (translation when not dragging or animating). */ |
| float getRestingTranslationX() { |
| return mRestingTranslationX; |
| } |
| |
| /** Clamps a float to between 0 and 1. */ |
| private float clampPercentage(float percent) { |
| return Math.min(1f, Math.max(0f, percent)); |
| } |
| |
| /** |
| * Renders the background, which is either the rounded 'chat bubble' flyout, or some state |
| * between that and the 'new' dot over the bubbles. |
| */ |
| private void renderBackground(Canvas canvas) { |
| // Calculate the width, height, and corner radius of the flyout given the current collapsed |
| // percentage. |
| final float width = getWidth() - (mFlyoutToDotWidthDelta * mPercentTransitionedToDot); |
| final float height = getHeight() - (mFlyoutToDotHeightDelta * mPercentTransitionedToDot); |
| final float cornerRadius = mCornerRadius |
| - (mFlyoutToDotCornerRadiusDelta * mPercentTransitionedToDot); |
| |
| // Translate the flyout background towards the collapsed 'dot' state. |
| mBgTranslationX = mTranslationXWhenDot * mPercentTransitionedToDot; |
| mBgTranslationY = mTranslationYWhenDot * mPercentTransitionedToDot; |
| |
| // Set the bounds of the rounded rectangle that serves as either the flyout background or |
| // the collapsed 'dot'. These bounds will also be used to provide the outline for elevation |
| // shadows. In the expanded flyout state, the left and right bounds leave space for the |
| // pointer triangle - as the flyout collapses, this space is reduced since the triangle |
| // retracts into the flyout. |
| mBgRect.set( |
| mPointerSize * mPercentStillFlyout /* left */, |
| 0 /* top */, |
| width - mPointerSize * mPercentStillFlyout /* right */, |
| height /* bottom */); |
| |
| mBgPaint.setColor( |
| (int) mArgbEvaluator.evaluate( |
| mPercentTransitionedToDot, mFloatingBackgroundColor, mDotColor)); |
| |
| canvas.save(); |
| canvas.translate(mBgTranslationX, mBgTranslationY); |
| renderPointerTriangle(canvas, width, height); |
| canvas.drawRoundRect(mBgRect, cornerRadius, cornerRadius, mBgPaint); |
| canvas.restore(); |
| } |
| |
| /** Renders the 'pointer' triangle that points from the flyout to the bubble stack. */ |
| private void renderPointerTriangle( |
| Canvas canvas, float currentFlyoutWidth, float currentFlyoutHeight) { |
| canvas.save(); |
| |
| // Translation to apply for the 'retraction' effect as the flyout collapses. |
| final float retractionTranslationX = |
| (mArrowPointingLeft ? 1 : -1) * (mPercentTransitionedToDot * mPointerSize * 2f); |
| |
| // Place the arrow either at the left side, or the far right, depending on whether the |
| // flyout is on the left or right side. |
| final float arrowTranslationX = |
| mArrowPointingLeft |
| ? retractionTranslationX |
| : currentFlyoutWidth - mPointerSize + retractionTranslationX; |
| |
| // Vertically center the arrow at all times. |
| final float arrowTranslationY = currentFlyoutHeight / 2f - mPointerSize / 2f; |
| |
| // Draw the appropriate direction of arrow. |
| final ShapeDrawable relevantTriangle = |
| mArrowPointingLeft ? mLeftTriangleShape : mRightTriangleShape; |
| canvas.translate(arrowTranslationX, arrowTranslationY); |
| relevantTriangle.setAlpha((int) (255f * mPercentStillFlyout)); |
| relevantTriangle.draw(canvas); |
| |
| // Save the triangle's outline for use in the outline provider, offsetting it to reflect its |
| // current position. |
| relevantTriangle.getOutline(mTriangleOutline); |
| mTriangleOutline.offset((int) arrowTranslationX, (int) arrowTranslationY); |
| |
| canvas.restore(); |
| } |
| |
| /** Builds an outline that includes the transformed flyout background and triangle. */ |
| private void getOutline(Outline outline) { |
| if (!mTriangleOutline.isEmpty()) { |
| // Draw the rect into the outline as a path so we can merge the triangle path into it. |
| final Path rectPath = new Path(); |
| rectPath.addRoundRect(mBgRect, mCornerRadius, mCornerRadius, Path.Direction.CW); |
| outline.setConvexPath(rectPath); |
| |
| // Get rid of the triangle path once it has disappeared behind the flyout. |
| if (mPercentStillFlyout > 0.5f) { |
| outline.mPath.addPath(mTriangleOutline.mPath); |
| } |
| |
| // Translate the outline to match the background's position. |
| final Matrix outlineMatrix = new Matrix(); |
| outlineMatrix.postTranslate(getLeft() + mBgTranslationX, getTop() + mBgTranslationY); |
| |
| // At the very end, retract the outline into the bubble so the shadow will be pulled |
| // into the flyout-dot as it (visually) becomes part of the bubble. We can't do this by |
| // animating translationZ to zero since then it'll go under the bubbles, which have |
| // elevation. |
| if (mPercentTransitionedToDot > 0.98f) { |
| final float percentBetween99and100 = (mPercentTransitionedToDot - 0.98f) / .02f; |
| final float percentShadowVisible = 1f - percentBetween99and100; |
| |
| // Keep it centered. |
| outlineMatrix.postTranslate( |
| mNewDotRadius * percentBetween99and100, |
| mNewDotRadius * percentBetween99and100); |
| outlineMatrix.preScale(percentShadowVisible, percentShadowVisible); |
| } |
| |
| outline.mPath.transform(outlineMatrix); |
| } |
| } |
| } |