Consecutive flyout animation

- fade out old message
- fade in new message
- vertical-center flyout w.r.t bubble

Bug: 170267642

Test: send single/group message
  => dot to flyout (and reverse) animation ok

Test: send consecutive messages from same/diff bubble
  => fade animation ok

Test: send multi-line message, then single line message
  => flyout updates vertical-center w.r.t bubble

Test: drag flyout to dot, tap flyout, repeat tests on right side
  => no regressions

Change-Id: I3e87c0ffebd27e1974b12ef4ad69bd1f627122ab
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleFlyoutView.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleFlyoutView.java
index 69f7828..009114f 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleFlyoutView.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleFlyoutView.java
@@ -18,6 +18,8 @@
 
 import static android.graphics.Paint.ANTI_ALIAS_FLAG;
 import static android.graphics.Paint.FILTER_BITMAP_FLAG;
+import static com.android.systemui.Interpolators.ALPHA_IN;
+import static com.android.systemui.Interpolators.ALPHA_OUT;
 
 import android.animation.ArgbEvaluator;
 import android.content.Context;
@@ -56,6 +58,11 @@
     /** Max width of the flyout, in terms of percent of the screen width. */
     private static final float FLYOUT_MAX_WIDTH_PERCENT = .6f;
 
+    /** Translation Y of fade animation. */
+    private static final float FLYOUT_FADE_Y = 40f;
+
+    private static final long FLYOUT_FADE_DURATION = 200L;
+
     private final int mFlyoutPadding;
     private final int mFlyoutSpaceFromBubble;
     private final int mPointerSize;
@@ -104,6 +111,9 @@
     /** The bounds of the flyout background, kept up to date as it transitions to the 'new' dot. */
     private final RectF mBgRect = new RectF();
 
+    /** The y position of the flyout, relative to the top of the screen. */
+    private float mFlyoutY = 0f;
+
     /**
      * 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
@@ -221,18 +231,33 @@
         mSenderText.setTextSize(TypedValue.COMPLEX_UNIT_PX, newFontSize);
     }
 
-    /** Configures the flyout, collapsed into to dot form. */
-    void setupFlyoutStartingAsDot(
-            Bubble.FlyoutMessage flyoutMessage,
-            PointF stackPos,
-            float parentWidth,
-            boolean arrowPointingLeft,
-            int dotColor,
-            @Nullable Runnable onLayoutComplete,
-            @Nullable Runnable onHide,
-            float[] dotCenter,
-            boolean hideDot) {
+    /*
+     * Fade animation for consecutive flyouts.
+     */
+    void animateUpdate(Bubble.FlyoutMessage flyoutMessage, float parentWidth, float stackY) {
+        fade(false /* in */);
+        updateFlyoutMessage(flyoutMessage, parentWidth);
+        // Wait for TextViews to layout with updated height.
+        post(() -> {
+            mFlyoutY = stackY + (mBubbleSize - mFlyoutTextContainer.getHeight()) / 2f;
+            fade(true /* in */);
+        });
+    }
 
+    private void fade(boolean in) {
+        setAlpha(in ? 0f : 1f);
+        setTranslationY(in ? mFlyoutY : mFlyoutY + FLYOUT_FADE_Y);
+        animate()
+                .alpha(in ? 1f : 0f)
+                .setDuration(FLYOUT_FADE_DURATION)
+                .setInterpolator(in ? ALPHA_IN : ALPHA_OUT);
+        animate()
+                .translationY(in ? mFlyoutY : mFlyoutY - FLYOUT_FADE_Y)
+                .setDuration(FLYOUT_FADE_DURATION)
+                .setInterpolator(in ? ALPHA_IN : ALPHA_OUT);
+    }
+
+    private void updateFlyoutMessage(Bubble.FlyoutMessage flyoutMessage, float parentWidth) {
         final Drawable senderAvatar = flyoutMessage.senderAvatar;
         if (senderAvatar != null && flyoutMessage.isGroupChat) {
             mSenderAvatar.setVisibility(VISIBLE);
@@ -256,6 +281,27 @@
             mSenderText.setVisibility(GONE);
         }
 
+        // 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).
+        mMessageText.setMaxWidth(maxTextViewWidth);
+        mMessageText.setText(flyoutMessage.message);
+    }
+
+    /** Configures the flyout, collapsed into dot form. */
+    void setupFlyoutStartingAsDot(
+            Bubble.FlyoutMessage flyoutMessage,
+            PointF stackPos,
+            float parentWidth,
+            boolean arrowPointingLeft,
+            int dotColor,
+            @Nullable Runnable onLayoutComplete,
+            @Nullable Runnable onHide,
+            float[] dotCenter,
+            boolean hideDot)  {
+
+        updateFlyoutMessage(flyoutMessage, parentWidth);
+
         mArrowPointingLeft = arrowPointingLeft;
         mDotColor = dotColor;
         mOnHide = onHide;
@@ -263,24 +309,12 @@
 
         setCollapsePercent(1f);
 
-        // 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).
-        mMessageText.setMaxWidth(maxTextViewWidth);
-        mMessageText.setText(flyoutMessage.message);
-
-        // Wait for the TextView to lay out so we know its line count.
+        // Wait for TextViews to layout with updated height.
         post(() -> {
-            float restingTranslationY;
-            // Multi line flyouts get top-aligned to the bubble.
-            if (mMessageText.getLineCount() > 1) {
-                restingTranslationY = stackPos.y + mBubbleIconTopPadding;
-            } else {
-                // Single line flyouts are vertically centered with respect to the bubble.
-                restingTranslationY =
-                        stackPos.y + (mBubbleSize - mFlyoutTextContainer.getHeight()) / 2f;
-            }
-            setTranslationY(restingTranslationY);
+            // Flyout is vertically centered with respect to the bubble.
+            mFlyoutY =
+                    stackPos.y + (mBubbleSize - mFlyoutTextContainer.getHeight()) / 2f;
+            setTranslationY(mFlyoutY);
 
             // Calculate the translation required to position the flyout next to the bubble stack,
             // with the desired padding.
@@ -300,7 +334,7 @@
             final float dotPositionY = stackPos.y + mDotCenter[1] - adjustmentForScaleAway;
 
             final float distanceFromFlyoutLeftToDotCenterX = mRestingTranslationX - dotPositionX;
-            final float distanceFromLayoutTopToDotCenterY = restingTranslationY - dotPositionY;
+            final float distanceFromLayoutTopToDotCenterY = mFlyoutY - dotPositionY;
 
             mTranslationXWhenDot = -distanceFromFlyoutLeftToDotCenterX;
             mTranslationYWhenDot = -distanceFromLayoutTopToDotCenterY;
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
index e83954b..9109c06e 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
@@ -2196,11 +2196,7 @@
         return getStatusBarHeight() + mBubbleSize + mBubblePaddingTop;
     }
 
-    /**
-     * Animates in the flyout for the given bubble, if available, and then hides it after some time.
-     */
-    @VisibleForTesting
-    void animateInFlyoutForBubble(Bubble bubble) {
+    private boolean shouldShowFlyout(Bubble bubble) {
         Bubble.FlyoutMessage flyoutMessage = bubble.getFlyoutMessage();
         final BadgedImageView bubbleView = bubble.getIconView();
         if (flyoutMessage == null
@@ -2212,11 +2208,22 @@
                 || mIsGestureInProgress
                 || mBubbleToExpandAfterFlyoutCollapse != null
                 || bubbleView == null) {
-            if (bubbleView != null) {
+            if (bubbleView != null && mFlyout.getVisibility() != VISIBLE) {
                 bubbleView.removeDotSuppressionFlag(BadgedImageView.SuppressionFlag.FLYOUT_VISIBLE);
             }
             // Skip the message if none exists, we're expanded or animating expansion, or we're
             // about to expand a bubble from the previous tapped flyout, or if bubble view is null.
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Animates in the flyout for the given bubble, if available, and then hides it after some time.
+     */
+    @VisibleForTesting
+    void animateInFlyoutForBubble(Bubble bubble) {
+        if (!shouldShowFlyout(bubble)) {
             return;
         }
 
@@ -2234,25 +2241,22 @@
             }
 
             // Stop suppressing the dot now that the flyout has morphed into the dot.
-            bubbleView.removeDotSuppressionFlag(
+            bubble.getIconView().removeDotSuppressionFlag(
                     BadgedImageView.SuppressionFlag.FLYOUT_VISIBLE);
 
-            mFlyout.setVisibility(INVISIBLE);
-
             // Hide the stack after a delay, if needed.
             updateTemporarilyInvisibleAnimation(false /* hideImmediately */);
         };
-        mFlyout.setVisibility(INVISIBLE);
 
         // Suppress the dot when we are animating the flyout.
-        bubbleView.addDotSuppressionFlag(
+        bubble.getIconView().addDotSuppressionFlag(
                 BadgedImageView.SuppressionFlag.FLYOUT_VISIBLE);
 
         // Start flyout expansion. Post in case layout isn't complete and getWidth returns 0.
         post(() -> {
             // An auto-expanding bubble could have been posted during the time it takes to
             // layout.
-            if (isExpanded()) {
+            if (isExpanded() || bubble.getIconView() == null) {
                 return;
             }
             final Runnable expandFlyoutAfterDelay = () -> {
@@ -2269,18 +2273,21 @@
                 mFlyout.postDelayed(mAnimateInFlyout, 200);
             };
 
-            if (bubble.getIconView() == null) {
-                return;
-            }
 
-            mFlyout.setupFlyoutStartingAsDot(flyoutMessage,
-                    mStackAnimationController.getStackPosition(), getWidth(),
-                    mStackAnimationController.isStackOnLeftSide(),
-                    bubble.getIconView().getDotColor() /* dotColor */,
-                    expandFlyoutAfterDelay /* onLayoutComplete */,
-                    mAfterFlyoutHidden,
-                    bubble.getIconView().getDotCenter(),
-                    !bubble.showDot());
+            if (mFlyout.getVisibility() == View.VISIBLE) {
+                mFlyout.animateUpdate(bubble.getFlyoutMessage(), getWidth(),
+                        mStackAnimationController.getStackPosition().y);
+            } else {
+                mFlyout.setVisibility(INVISIBLE);
+                mFlyout.setupFlyoutStartingAsDot(bubble.getFlyoutMessage(),
+                        mStackAnimationController.getStackPosition(), getWidth(),
+                        mStackAnimationController.isStackOnLeftSide(),
+                        bubble.getIconView().getDotColor() /* dotColor */,
+                        expandFlyoutAfterDelay /* onLayoutComplete */,
+                        mAfterFlyoutHidden,
+                        bubble.getIconView().getDotCenter(),
+                        !bubble.showDot());
+            }
             mFlyout.bringToFront();
         });
         mFlyout.removeCallbacks(mHideFlyout);