Home | History | Annotate | Download | only in bubbles
      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 
    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
    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. */
    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      */
    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. */
    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). */
    296     float getRestingTranslationX() {
    297         return mRestingTranslationX;
    298     }
    299 
    300     /** Clamps a float to between 0 and 1. */
    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      */
    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. */
    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. */
    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