Add a new bubble at limit while expanded

Handles adding a new bubble and removing an old bubble when the bar
is expanded and at the limit.

Demo when bar is on right: http://recall/-/bJtug1HhvXkkeA4MQvIaiP/dxhFKrctdR5I2F6Pvho6u8
Demo when bar is on left: http://recall/-/bJtug1HhvXkkeA4MQvIaiP/er4ZGQfg8OKHZ2aTi9OJ9N

Flag: com.android.wm.shell.enable_bubble_bar
Test: demos
Test: atest BubbleViewAnimatorTest
Fixes: 345795791
Change-Id: I4e75d61c8afdb81340823a1d77e55b15e3fd6bc0
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarController.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarController.java
index 15e4578..f6b1328 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarController.java
@@ -322,27 +322,45 @@
                         || mImeVisibilityChecker.isImeVisible();
 
         BubbleBarBubble bubbleToSelect = null;
-        if (!update.removedBubbles.isEmpty()) {
-            for (int i = 0; i < update.removedBubbles.size(); i++) {
-                RemovedBubble removedBubble = update.removedBubbles.get(i);
-                BubbleBarBubble bubble = mBubbles.remove(removedBubble.getKey());
-                if (bubble != null) {
-                    mBubbleBarViewController.removeBubble(bubble);
-                } else {
-                    Log.w(TAG, "trying to remove bubble that doesn't exist: "
-                            + removedBubble.getKey());
+
+        if (update.addedBubble != null && update.removedBubbles.size() == 1) {
+            // we're adding and removing a bubble at the same time. handle this as a single update.
+            RemovedBubble removedBubble = update.removedBubbles.get(0);
+            BubbleBarBubble bubbleToRemove = mBubbles.remove(removedBubble.getKey());
+            mBubbles.put(update.addedBubble.getKey(), update.addedBubble);
+            if (bubbleToRemove != null) {
+                mBubbleBarViewController.addBubbleAndRemoveBubble(update.addedBubble,
+                        bubbleToRemove, isExpanding, suppressAnimation);
+            } else {
+                mBubbleBarViewController.addBubble(update.addedBubble, isExpanding,
+                        suppressAnimation);
+                Log.w(TAG, "trying to remove bubble that doesn't exist: " + removedBubble.getKey());
+            }
+        } else {
+            if (!update.removedBubbles.isEmpty()) {
+                for (int i = 0; i < update.removedBubbles.size(); i++) {
+                    RemovedBubble removedBubble = update.removedBubbles.get(i);
+                    BubbleBarBubble bubble = mBubbles.remove(removedBubble.getKey());
+                    if (bubble != null) {
+                        mBubbleBarViewController.removeBubble(bubble);
+                    } else {
+                        Log.w(TAG, "trying to remove bubble that doesn't exist: "
+                                + removedBubble.getKey());
+                    }
                 }
             }
-        }
-        if (update.addedBubble != null) {
-            mBubbles.put(update.addedBubble.getKey(), update.addedBubble);
-            mBubbleBarViewController.addBubble(update.addedBubble, isExpanding, suppressAnimation);
-            if (isCollapsed) {
-                // If we're collapsed, the most recently added bubble will be selected.
-                bubbleToSelect = update.addedBubble;
+            if (update.addedBubble != null) {
+                mBubbles.put(update.addedBubble.getKey(), update.addedBubble);
+                mBubbleBarViewController.addBubble(update.addedBubble, isExpanding,
+                        suppressAnimation);
             }
-
         }
+
+        if (update.addedBubble != null && isCollapsed) {
+            // If we're collapsed, the most recently added bubble will be selected.
+            bubbleToSelect = update.addedBubble;
+        }
+
         if (update.currentBubbles != null && !update.currentBubbles.isEmpty()) {
             // Iterate in reverse because new bubbles are added in front and the list is in order.
             for (int i = update.currentBubbles.size() - 1; i >= 0; i--) {
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java
index 0ea5031..07481a2 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java
@@ -626,6 +626,7 @@
     /**
      * Set bubble bar relative pivot value for X and Y, applied as a fraction of view width/height
      * respectively. If the value is not in range of 0 to 1 it will be normalized.
+     *
      * @param x relative X pivot value in range 0..1
      * @param y relative Y pivot value in range 0..1
      */
@@ -665,7 +666,9 @@
     }
 
     /** Add a new bubble to the bubble bar. */
-    public void addBubble(View bubble, FrameLayout.LayoutParams lp) {
+    public void addBubble(View bubble) {
+        FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams((int) mIconSize, (int) mIconSize,
+                Gravity.LEFT);
         if (isExpanded()) {
             // if we're expanded scale the new bubble in
             bubble.setScaleX(0f);
@@ -702,14 +705,58 @@
         }
     }
 
+    /** Add a new bubble and remove an old bubble from the bubble bar. */
+    public void addBubbleAndRemoveBubble(View addedBubble, View removedBubble) {
+        FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams((int) mIconSize, (int) mIconSize,
+                Gravity.LEFT);
+        if (!isExpanded()) {
+            removeView(removedBubble);
+            addView(addedBubble, 0, lp);
+            return;
+        }
+        addedBubble.setScaleX(0f);
+        addedBubble.setScaleY(0f);
+        addView(addedBubble, 0, lp);
+
+        int indexOfSelectedBubble = indexOfChild(mSelectedBubbleView);
+        int indexOfBubbleToRemove = indexOfChild(removedBubble);
+
+        mBubbleAnimator = new BubbleAnimator(mIconSize, mExpandedBarIconsSpacing,
+                getChildCount(), mBubbleBarLocation.isOnLeft(isLayoutRtl()));
+        BubbleAnimator.Listener listener = new BubbleAnimator.Listener() {
+
+            @Override
+            public void onAnimationEnd() {
+                removeView(removedBubble);
+                updateWidth();
+                mBubbleAnimator = null;
+            }
+
+            @Override
+            public void onAnimationCancel() {
+                addedBubble.setScaleX(1);
+                addedBubble.setScaleY(1);
+                removedBubble.setScaleX(0);
+                removedBubble.setScaleY(0);
+            }
+
+            @Override
+            public void onAnimationUpdate(float animatedFraction) {
+                addedBubble.setScaleX(animatedFraction);
+                addedBubble.setScaleY(animatedFraction);
+                removedBubble.setScaleX(1 - animatedFraction);
+                removedBubble.setScaleY(1 - animatedFraction);
+                updateBubblesLayoutProperties(mBubbleBarLocation);
+                invalidate();
+            }
+        };
+        mBubbleAnimator.animateNewAndRemoveOld(indexOfSelectedBubble, indexOfBubbleToRemove,
+                listener);
+    }
+
     // TODO: (b/280605790) animate it
     @Override
     public void addView(View child, int index, ViewGroup.LayoutParams params) {
-        if (getChildCount() + 1 > MAX_BUBBLES) {
-            // the last child view is the overflow bubble and we shouldn't remove that. remove the
-            // second to last child view.
-            removeViewInLayout(getChildAt(getChildCount() - 2));
-        }
         super.addView(child, index, params);
         updateWidth();
         updateBubbleAccessibilityStates();
@@ -911,7 +958,7 @@
         final float iconAndSpacing = getScaledIconSize() + mExpandedBarIconsSpacing;
         float translationX;
         if (mBubbleAnimator != null && mBubbleAnimator.isRunning()) {
-            return mBubbleAnimator.getExpandedBubbleTranslationX(bubbleIndex) + mBubbleBarPadding;
+            return mBubbleAnimator.getBubbleTranslationX(bubbleIndex) + mBubbleBarPadding;
         } else if (onLeft) {
             translationX = mBubbleBarPadding + (bubbleCount - bubbleIndex - 1) * iconAndSpacing;
         } else {
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarViewController.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarViewController.java
index da0826b..dbc78db 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarViewController.java
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarViewController.java
@@ -25,10 +25,8 @@
 import android.util.DisplayMetrics;
 import android.util.Log;
 import android.util.TypedValue;
-import android.view.Gravity;
 import android.view.MotionEvent;
 import android.view.View;
-import android.widget.FrameLayout;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -386,7 +384,7 @@
     /**
      * Removes the provided bubble from the bubble bar.
      */
-    public void removeBubble(BubbleBarItem b) {
+    public void removeBubble(BubbleBarBubble b) {
         if (b != null) {
             mBarView.removeBubble(b.getView());
         } else {
@@ -394,13 +392,23 @@
         }
     }
 
+    /** Adds a new bubble and removes an old bubble at the same time. */
+    public void addBubbleAndRemoveBubble(BubbleBarBubble addedBubble,
+            BubbleBarBubble removedBubble, boolean isExpanding, boolean suppressAnimation) {
+        mBarView.addBubbleAndRemoveBubble(addedBubble.getView(), removedBubble.getView());
+        addedBubble.getView().setOnClickListener(mBubbleClickListener);
+        mBubbleDragController.setupBubbleView(addedBubble.getView());
+        if (!suppressAnimation) {
+            animateBubbleNotification(addedBubble, isExpanding);
+        }
+    }
+
     /**
      * Adds the provided bubble to the bubble bar.
      */
     public void addBubble(BubbleBarItem b, boolean isExpanding, boolean suppressAnimation) {
         if (b != null) {
-            mBarView.addBubble(
-                    b.getView(), new FrameLayout.LayoutParams(mIconSize, mIconSize, Gravity.LEFT));
+            mBarView.addBubble(b.getView());
             b.getView().setOnClickListener(mBubbleClickListener);
             mBubbleDragController.setupBubbleView(b.getView());
 
diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/animation/BubbleAnimator.kt b/quickstep/src/com/android/launcher3/taskbar/bubbles/animation/BubbleAnimator.kt
index 7672743..8af8ffb 100644
--- a/quickstep/src/com/android/launcher3/taskbar/bubbles/animation/BubbleAnimator.kt
+++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/animation/BubbleAnimator.kt
@@ -56,6 +56,20 @@
         animator.start()
     }
 
+    fun animateNewAndRemoveOld(
+        selectedBubbleIndex: Int,
+        removedBubbleIndex: Int,
+        listener: Listener
+    ) {
+        animator = createAnimator(listener)
+        state =
+            State.AddingAndRemoving(
+                selectedBubbleIndex = selectedBubbleIndex,
+                removedBubbleIndex = removedBubbleIndex
+            )
+        animator.start()
+    }
+
     private fun createAnimator(listener: Listener): ValueAnimator {
         val animator = ValueAnimator.ofFloat(0f, 1f).setDuration(ANIMATION_DURATION_MS)
         animator.addUpdateListener { animation ->
@@ -83,28 +97,35 @@
     }
 
     /**
-     * The translation X of the bubble at index [bubbleIndex] according to the progress of the
-     * animation.
+     * The translation X of the bubble at index [bubbleIndex] when the bubble bar is expanded
+     * according to the progress of this animation.
      *
      * Callers should verify that the animation is running before calling this.
      *
      * @see isRunning
      */
-    fun getExpandedBubbleTranslationX(bubbleIndex: Int): Float {
+    fun getBubbleTranslationX(bubbleIndex: Int): Float {
         return when (val state = state) {
             State.Idle -> 0f
             is State.AddingBubble ->
-                getExpandedBubbleTranslationXWhileScalingBubble(
+                getBubbleTranslationXWhileScalingBubble(
                     bubbleIndex = bubbleIndex,
                     scalingBubbleIndex = 0,
                     bubbleScale = animator.animatedFraction
                 )
             is State.RemovingBubble ->
-                getExpandedBubbleTranslationXWhileScalingBubble(
+                getBubbleTranslationXWhileScalingBubble(
                     bubbleIndex = bubbleIndex,
                     scalingBubbleIndex = state.bubbleIndex,
                     bubbleScale = 1 - animator.animatedFraction
                 )
+            is State.AddingAndRemoving ->
+                getBubbleTranslationXWhileAddingBubbleAtLimit(
+                    bubbleIndex = bubbleIndex,
+                    removedBubbleIndex = state.removedBubbleIndex,
+                    addedBubbleScale = animator.animatedFraction,
+                    removedBubbleScale = 1 - animator.animatedFraction
+                )
         }
     }
 
@@ -121,6 +142,14 @@
                 State.Idle -> 0f
                 is State.AddingBubble -> animator.animatedFraction
                 is State.RemovingBubble -> 1 - animator.animatedFraction
+                is State.AddingAndRemoving -> {
+                    // since we're adding a bubble and removing another bubble, their sizes together
+                    // equal to a single bubble. the width is the same as having bubbleCount - 1
+                    // bubbles at full scale.
+                    val totalSpace = (bubbleCount - 2) * expandedBarIconSpacing
+                    val totalIconSize = (bubbleCount - 1) * iconSize
+                    return totalIconSize + totalSpace
+                }
             }
         // When this animator is running the bubble bar is expanded so it's safe to assume that we
         // have at least 2 bubbles, but should update the logic to support optional overflow.
@@ -144,7 +173,7 @@
             State.Idle -> 0f
             is State.AddingBubble -> {
                 val tx =
-                    getExpandedBubbleTranslationXWhileScalingBubble(
+                    getBubbleTranslationXWhileScalingBubble(
                         bubbleIndex = state.selectedBubbleIndex,
                         scalingBubbleIndex = 0,
                         bubbleScale = animator.animatedFraction
@@ -152,6 +181,17 @@
                 tx + iconSize / 2f
             }
             is State.RemovingBubble -> getArrowPositionWhenRemovingBubble(state)
+            is State.AddingAndRemoving -> {
+                // we never remove the selected bubble, so the arrow stays pointing to its center
+                val tx =
+                    getBubbleTranslationXWhileAddingBubbleAtLimit(
+                        bubbleIndex = state.selectedBubbleIndex,
+                        removedBubbleIndex = state.removedBubbleIndex,
+                        addedBubbleScale = animator.animatedFraction,
+                        removedBubbleScale = 1 - animator.animatedFraction
+                    )
+                tx + iconSize / 2f
+            }
         }
     }
 
@@ -160,7 +200,7 @@
             // if we're not removing the selected bubble, the selected bubble doesn't change so just
             // return the translation X of the selected bubble and add half icon
             val tx =
-                getExpandedBubbleTranslationXWhileScalingBubble(
+                getBubbleTranslationXWhileScalingBubble(
                     bubbleIndex = state.selectedBubbleIndex,
                     scalingBubbleIndex = state.bubbleIndex,
                     bubbleScale = 1 - animator.animatedFraction
@@ -208,7 +248,7 @@
      * @param scalingBubbleIndex the index of the bubble that is animating
      * @param bubbleScale the current scale of the animating bubble
      */
-    private fun getExpandedBubbleTranslationXWhileScalingBubble(
+    private fun getBubbleTranslationXWhileScalingBubble(
         bubbleIndex: Int,
         scalingBubbleIndex: Int,
         bubbleScale: Float
@@ -256,6 +296,68 @@
         }
     }
 
+    private fun getBubbleTranslationXWhileAddingBubbleAtLimit(
+        bubbleIndex: Int,
+        removedBubbleIndex: Int,
+        addedBubbleScale: Float,
+        removedBubbleScale: Float
+    ): Float {
+        val iconAndSpacing = iconSize + expandedBarIconSpacing
+        // the bubbles are scaling from the center, so we need to adjust their translation so
+        // that the distance to the adjacent bubble scales at the same rate.
+        val addedBubblePivotAdjustment = -(1 - addedBubbleScale) * iconSize / 2f
+        val removedBubblePivotAdjustment = -(1 - removedBubbleScale) * iconSize / 2f
+
+        return if (onLeft) {
+            // this is how many bubbles there are to the left of the current bubble.
+            // when the bubble bar is on the right the added bubble is the right-most bubble so it
+            // doesn't affect the translation of any other bubble.
+            // when the removed bubble is to the left of the current bubble, we need to subtract it
+            // from bubblesToLeft and use removedBubbleScale instead when calculating the
+            // translation.
+            val bubblesToLeft = bubbleCount - bubbleIndex - 1
+            when {
+                bubbleIndex == 0 ->
+                    // this is the added bubble and it's the right-most bubble. account for all the
+                    // other bubbles -- including the removed bubble -- and adjust for the added
+                    // bubble pivot.
+                    (bubblesToLeft - 1 + removedBubbleScale) * iconAndSpacing +
+                        addedBubblePivotAdjustment
+                bubbleIndex < removedBubbleIndex ->
+                    // the removed bubble is to the left so account for it
+                    (bubblesToLeft - 1 + removedBubbleScale) * iconAndSpacing
+                bubbleIndex == removedBubbleIndex -> {
+                    // this is the removed bubble. all the bubbles to the left are at full scale
+                    // but we need to scale the spacing between the removed bubble and the bubble to
+                    // its left because the removed bubble disappears towards the left side
+                    val totalIconSize = bubblesToLeft * iconSize
+                    val totalSpacing =
+                        (bubblesToLeft - 1 + removedBubbleScale) * expandedBarIconSpacing
+                    totalIconSize + totalSpacing + removedBubblePivotAdjustment
+                }
+                else ->
+                    // both added and removed bubbles are to the right so they don't affect the tx
+                    bubblesToLeft * iconAndSpacing
+            }
+        } else {
+            when {
+                bubbleIndex == 0 -> addedBubblePivotAdjustment // we always add bubbles at index 0
+                bubbleIndex < removedBubbleIndex ->
+                    // the bar is on the right and the removed bubble is on the right. the current
+                    // bubble is unaffected by the removed bubble. only need to factor in the added
+                    // bubble's scale.
+                    iconAndSpacing * (bubbleIndex - 1 + addedBubbleScale)
+                bubbleIndex == removedBubbleIndex ->
+                    // the bar is on the right, and this is the animating bubble.
+                    iconAndSpacing * (bubbleIndex - 1 + addedBubbleScale) +
+                        removedBubblePivotAdjustment
+                else ->
+                    // both the added and the removed bubbles are to the left of the current bubble
+                    iconAndSpacing * (bubbleIndex - 2 + addedBubbleScale + removedBubbleScale)
+            }
+        }
+    }
+
     val isRunning: Boolean
         get() = state != State.Idle
 
@@ -277,6 +379,10 @@
             /** Whether the bubble being removed is also the last bubble. */
             val removingLastBubble: Boolean
         ) : State
+
+        /** A new bubble is being added and an old bubble is being removed from the bubble bar. */
+        data class AddingAndRemoving(val selectedBubbleIndex: Int, val removedBubbleIndex: Int) :
+            State
     }
 
     /** Callbacks for the animation. */
diff --git a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/animation/BubbleAnimatorTest.kt b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/animation/BubbleAnimatorTest.kt
index 20bd617..d5a76a2 100644
--- a/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/animation/BubbleAnimatorTest.kt
+++ b/quickstep/tests/multivalentTests/src/com/android/launcher3/taskbar/bubbles/animation/BubbleAnimatorTest.kt
@@ -80,6 +80,31 @@
         assertThat(bubbleAnimator.isRunning).isFalse()
     }
 
+    @Test
+    fun animateNewAndRemoveOld_isRunning() {
+        bubbleAnimator =
+            BubbleAnimator(
+                iconSize = 40f,
+                expandedBarIconSpacing = 10f,
+                bubbleCount = 5,
+                onLeft = false
+            )
+        val listener = TestBubbleAnimatorListener()
+        InstrumentationRegistry.getInstrumentation().runOnMainSync {
+            bubbleAnimator.animateNewAndRemoveOld(
+                selectedBubbleIndex = 3,
+                removedBubbleIndex = 2,
+                listener
+            )
+        }
+
+        assertThat(bubbleAnimator.isRunning).isTrue()
+        InstrumentationRegistry.getInstrumentation().runOnMainSync {
+            animatorTestRule.advanceTimeBy(250)
+        }
+        assertThat(bubbleAnimator.isRunning).isFalse()
+    }
+
     private class TestBubbleAnimatorListener : BubbleAnimator.Listener {
 
         override fun onAnimationUpdate(animatedFraction: Float) {}