Bubble v2 animation changes.

Including:
- expanded view expands/collapses from top of itself
- small icon on avatar shows on left side when bubble is on right side
- when expand on bottom, bubble move up a bit so that expanded view doesn't go off screen. It also go back to previous position when collapse.
- remove animation for collapse when move expanded bubble

This change should not enable bubble v2 for anyone.

Bug: 67605985
Test: manual
PiperOrigin-RevId: 177974562
Change-Id: Id83f3f744b717d51fbe58e58769ac2cd2810d2b5
diff --git a/java/com/android/incallui/NewReturnToCallController.java b/java/com/android/incallui/NewReturnToCallController.java
index 399b185..7a1abee 100644
--- a/java/com/android/incallui/NewReturnToCallController.java
+++ b/java/com/android/incallui/NewReturnToCallController.java
@@ -29,8 +29,6 @@
 import com.android.dialer.common.LogUtil;
 import com.android.dialer.configprovider.ConfigProviderBindings;
 import com.android.dialer.lettertile.LetterTileDrawable;
-import com.android.dialer.logging.DialerImpression;
-import com.android.dialer.logging.Logger;
 import com.android.dialer.telecom.TelecomUtil;
 import com.android.incallui.ContactInfoCache.ContactCacheEntry;
 import com.android.incallui.ContactInfoCache.ContactInfoCacheCallback;
@@ -43,8 +41,6 @@
 import com.android.incallui.speakerbuttonlogic.SpeakerButtonInfo;
 import com.android.incallui.speakerbuttonlogic.SpeakerButtonInfo.IconSize;
 import com.android.newbubble.NewBubble;
-import com.android.newbubble.NewBubble.BubbleExpansionStateListener;
-import com.android.newbubble.NewBubble.ExpansionState;
 import com.android.newbubble.NewBubbleInfo;
 import com.android.newbubble.NewBubbleInfo.Action;
 import java.lang.ref.WeakReference;
@@ -150,45 +146,6 @@
       return null;
     }
     NewBubble returnToCallBubble = NewBubble.createBubble(context, generateBubbleInfo());
-    returnToCallBubble.setBubbleExpansionStateListener(
-        new BubbleExpansionStateListener() {
-          @Override
-          public void onBubbleExpansionStateChanged(
-              @ExpansionState int expansionState, boolean isUserAction) {
-            if (!isUserAction) {
-              return;
-            }
-
-            DialerCall call = CallList.getInstance().getActiveOrBackgroundCall();
-            switch (expansionState) {
-              case ExpansionState.START_EXPANDING:
-                if (call != null) {
-                  Logger.get(context)
-                      .logCallImpression(
-                          DialerImpression.Type.BUBBLE_PRIMARY_BUTTON_EXPAND,
-                          call.getUniqueCallId(),
-                          call.getTimeAddedMs());
-                } else {
-                  Logger.get(context)
-                      .logImpression(DialerImpression.Type.BUBBLE_PRIMARY_BUTTON_EXPAND);
-                }
-                break;
-              case ExpansionState.START_COLLAPSING:
-                if (call != null) {
-                  Logger.get(context)
-                      .logCallImpression(
-                          DialerImpression.Type.BUBBLE_COLLAPSE_BY_USER,
-                          call.getUniqueCallId(),
-                          call.getTimeAddedMs());
-                } else {
-                  Logger.get(context).logImpression(DialerImpression.Type.BUBBLE_COLLAPSE_BY_USER);
-                }
-                break;
-              default:
-                break;
-            }
-          }
-        });
     returnToCallBubble.show();
     return returnToCallBubble;
   }
diff --git a/java/com/android/newbubble/NewBubble.java b/java/com/android/newbubble/NewBubble.java
index e690f4b..ef3a971 100644
--- a/java/com/android/newbubble/NewBubble.java
+++ b/java/com/android/newbubble/NewBubble.java
@@ -17,12 +17,15 @@
 package com.android.newbubble;
 
 import android.animation.Animator;
+import android.animation.Animator.AnimatorListener;
 import android.animation.AnimatorSet;
 import android.animation.ObjectAnimator;
+import android.animation.ValueAnimator;
 import android.annotation.SuppressLint;
 import android.app.PendingIntent.CanceledException;
 import android.content.Context;
 import android.content.Intent;
+import android.graphics.Path;
 import android.graphics.PixelFormat;
 import android.graphics.drawable.Animatable;
 import android.graphics.drawable.Drawable;
@@ -51,10 +54,16 @@
 import android.view.WindowManager.LayoutParams;
 import android.view.animation.AnticipateInterpolator;
 import android.view.animation.OvershootInterpolator;
+import android.widget.FrameLayout;
 import android.widget.ImageView;
 import android.widget.TextView;
 import android.widget.ViewAnimator;
+import com.android.dialer.common.LogUtil;
+import com.android.dialer.logging.DialerImpression;
+import com.android.dialer.logging.Logger;
 import com.android.dialer.util.DrawableConverter;
+import com.android.incallui.call.CallList;
+import com.android.incallui.call.DialerCall;
 import com.android.newbubble.NewBubbleInfo.Action;
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
@@ -96,11 +105,14 @@
   private CharSequence textAfterShow;
   private int collapseEndAction;
 
-  @VisibleForTesting ViewHolder viewHolder;
+  ViewHolder viewHolder;
   private ViewPropertyAnimator collapseAnimation;
   private Integer overrideGravity;
   private ViewPropertyAnimator exitAnimator;
 
+  private int leftBoundary;
+  private int savedYPosition = -1;
+
   private final Runnable collapseRunnable =
       new Runnable() {
         @Override
@@ -110,17 +122,11 @@
             // Always reset here since text shouldn't keep showing.
             hideAndReset();
           } else {
-            doResize(
-                () ->
-                    viewHolder
-                        .getPrimaryButton()
-                        .setDisplayedChild(ViewHolder.CHILD_INDEX_AVATAR_AND_ICON));
+            viewHolder.getPrimaryButton().setDisplayedChild(ViewHolder.CHILD_INDEX_AVATAR_AND_ICON);
           }
         }
       };
 
-  private BubbleExpansionStateListener bubbleExpansionStateListener;
-
   /** Type of action after bubble collapse */
   @Retention(RetentionPolicy.SOURCE)
   @IntDef({CollapseEnd.NOTHING, CollapseEnd.HIDE})
@@ -206,15 +212,20 @@
     windowManager = context.getSystemService(WindowManager.class);
 
     viewHolder = new ViewHolder(context);
+
+    leftBoundary =
+        context.getResources().getDimensionPixelOffset(R.dimen.bubble_off_screen_size_horizontal)
+            - context
+                .getResources()
+                .getDimensionPixelSize(R.dimen.bubble_shadow_padding_size_horizontal);
   }
 
   /** Expands the main bubble menu. */
   public void expand(boolean isUserAction) {
-    if (bubbleExpansionStateListener != null) {
-      bubbleExpansionStateListener.onBubbleExpansionStateChanged(
-          ExpansionState.START_EXPANDING, isUserAction);
+    if (isUserAction) {
+      logBasicOrCallImpression(DialerImpression.Type.BUBBLE_PRIMARY_BUTTON_EXPAND);
     }
-    doResize(() -> viewHolder.setDrawerVisibility(View.VISIBLE));
+    viewHolder.setDrawerVisibility(View.INVISIBLE);
     View expandedView = viewHolder.getExpandedView();
     expandedView
         .getViewTreeObserver()
@@ -222,13 +233,62 @@
             new OnPreDrawListener() {
               @Override
               public boolean onPreDraw() {
-                // Animate expanded view to move from above primary button to its final position
+                // Move the whole bubble up so that expanded view is still in screen
+                int moveUpDistance = viewHolder.getMoveUpDistance();
+                if (moveUpDistance != 0) {
+                  savedYPosition = windowParams.y;
+                }
+
+                // Calculate the move-to-middle distance
+                int deltaX =
+                    (int) viewHolder.getRoot().findViewById(R.id.bubble_primary_container).getX();
+                float k = (float) moveUpDistance / deltaX;
+                if (isDrawingFromRight()) {
+                  deltaX = -deltaX;
+                }
+
+                // Do X-move and Y-move together
+
+                final int startX = windowParams.x - deltaX;
+                final int startY = windowParams.y;
+                ValueAnimator animator = ValueAnimator.ofFloat(startX, windowParams.x);
+                animator.setInterpolator(new LinearOutSlowInInterpolator());
+                animator.addUpdateListener(
+                    (valueAnimator) -> {
+                      // Update windowParams and the root layout.
+                      // We can't do ViewPropertyAnimation since it clips children.
+                      float newX = (float) valueAnimator.getAnimatedValue();
+                      if (moveUpDistance != 0) {
+                        windowParams.y = startY - (int) (Math.abs(newX - (float) startX) * k);
+                      }
+                      windowParams.x = (int) newX;
+                      windowManager.updateViewLayout(viewHolder.getRoot(), windowParams);
+                    });
+                animator.addListener(
+                    new AnimatorListener() {
+                      @Override
+                      public void onAnimationEnd(Animator animation) {
+                        // Show expanded view
+                        expandedView.setVisibility(View.VISIBLE);
+                        expandedView.setTranslationY(-expandedView.getHeight());
+                        expandedView
+                            .animate()
+                            .setInterpolator(new LinearOutSlowInInterpolator())
+                            .translationY(0);
+                      }
+
+                      @Override
+                      public void onAnimationStart(Animator animation) {}
+
+                      @Override
+                      public void onAnimationCancel(Animator animation) {}
+
+                      @Override
+                      public void onAnimationRepeat(Animator animation) {}
+                    });
+                animator.start();
+
                 expandedView.getViewTreeObserver().removeOnPreDrawListener(this);
-                expandedView.setTranslationY(-viewHolder.getRoot().getHeight());
-                expandedView
-                    .animate()
-                    .setInterpolator(new LinearOutSlowInInterpolator())
-                    .translationY(0);
                 return false;
               }
             });
@@ -236,6 +296,115 @@
     expanded = true;
   }
 
+  @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+  public void startCollapse(
+      @CollapseEnd int endAction, boolean isUserAction, boolean shouldRecoverYPosition) {
+    View expandedView = viewHolder.getExpandedView();
+    if (expandedView.getVisibility() != View.VISIBLE || collapseAnimation != null) {
+      // Drawer is already collapsed or animation is running.
+      return;
+    }
+
+    overrideGravity = isDrawingFromRight() ? Gravity.RIGHT : Gravity.LEFT;
+    setFocused(false);
+
+    if (collapseEndAction == CollapseEnd.NOTHING) {
+      collapseEndAction = endAction;
+    }
+    if (isUserAction && collapseEndAction == CollapseEnd.NOTHING) {
+      logBasicOrCallImpression(DialerImpression.Type.BUBBLE_COLLAPSE_BY_USER);
+    }
+    // Animate expanded view to move from its position to above primary button and hide
+    collapseAnimation =
+        expandedView
+            .animate()
+            .translationY(-expandedView.getHeight())
+            .setInterpolator(new FastOutLinearInInterpolator())
+            .withEndAction(
+                () -> {
+                  collapseAnimation = null;
+                  expanded = false;
+
+                  if (textShowing) {
+                    // Will do resize once the text is done.
+                    return;
+                  }
+
+                  // Set drawer visibility to INVISIBLE instead of GONE to keep primary button fixed
+                  viewHolder.setDrawerVisibility(View.INVISIBLE);
+
+                  // Do X-move and Y-move together
+                  int deltaX =
+                      (int) viewHolder.getRoot().findViewById(R.id.bubble_primary_container).getX();
+                  int startX = windowParams.x;
+                  int startY = windowParams.y;
+                  float k =
+                      (savedYPosition != -1 && shouldRecoverYPosition)
+                          ? (savedYPosition - startY) / (float) deltaX
+                          : 0;
+                  Path path = new Path();
+                  path.moveTo(windowParams.x, windowParams.y);
+                  path.lineTo(
+                      windowParams.x - deltaX,
+                      (savedYPosition != -1 && shouldRecoverYPosition)
+                          ? savedYPosition
+                          : windowParams.y);
+                  // The position is not useful after collapse
+                  savedYPosition = -1;
+
+                  ValueAnimator animator = ValueAnimator.ofFloat(startX, startX - deltaX);
+                  animator.setInterpolator(new LinearOutSlowInInterpolator());
+                  animator.addUpdateListener(
+                      (valueAnimator) -> {
+                        // Update windowParams and the root layout.
+                        // We can't do ViewPropertyAnimation since it clips children.
+                        float newX = (float) valueAnimator.getAnimatedValue();
+                        if (k != 0) {
+                          windowParams.y = startY + (int) (Math.abs(newX - (float) startX) * k);
+                        }
+                        windowParams.x = (int) newX;
+                        windowManager.updateViewLayout(viewHolder.getRoot(), windowParams);
+                      });
+                  animator.addListener(
+                      new AnimatorListener() {
+                        @Override
+                        public void onAnimationEnd(Animator animation) {
+                          // If collapse on the right side, the primary button move left a bit after
+                          // drawer
+                          // visibility becoming GONE. To avoid it, we create a new ViewHolder.
+                          replaceViewHolder();
+                        }
+
+                        @Override
+                        public void onAnimationStart(Animator animation) {}
+
+                        @Override
+                        public void onAnimationCancel(Animator animation) {}
+
+                        @Override
+                        public void onAnimationRepeat(Animator animation) {}
+                      });
+                  animator.start();
+
+                  // If this collapse was to come before a hide, do it now.
+                  if (collapseEndAction == CollapseEnd.HIDE) {
+                    hide();
+                  }
+                  collapseEndAction = CollapseEnd.NOTHING;
+
+                  // Resume normal gravity after any resizing is done.
+                  handler.postDelayed(
+                      () -> {
+                        overrideGravity = null;
+                        if (!viewHolder.isMoving()) {
+                          viewHolder.undoGravityOverride();
+                        }
+                      },
+                      // Need to wait twice as long for resize and layout
+                      WINDOW_REDRAW_DELAY_MILLIS * 2);
+                });
+  }
+
   /**
    * Make the bubble visible. Will show a short entrance animation as it enters. If the bubble is
    * already showing this method does nothing.
@@ -269,8 +438,7 @@
                   | LayoutParams.FLAG_LAYOUT_NO_LIMITS,
               PixelFormat.TRANSLUCENT);
       windowParams.gravity = Gravity.TOP | Gravity.LEFT;
-      windowParams.x =
-          context.getResources().getDimensionPixelOffset(R.dimen.bubble_safe_margin_horizontal);
+      windowParams.x = leftBoundary;
       windowParams.y = currentInfo.getStartingYPosition();
       windowParams.height = LayoutParams.WRAP_CONTENT;
       windowParams.width = LayoutParams.WRAP_CONTENT;
@@ -392,7 +560,8 @@
   public void showText(@NonNull CharSequence text) {
     textShowing = true;
     if (expanded) {
-      startCollapse(CollapseEnd.NOTHING, false);
+      startCollapse(
+          CollapseEnd.NOTHING, false /* isUserAction */, false /* shouldRecoverYPosition */);
       doShowText(text);
     } else {
       // Need to transition from old bounds to new bounds manually
@@ -409,68 +578,65 @@
         return;
       }
 
-      doResize(
-          () -> {
-            doShowText(text);
-            // Hide the text so we can animate it in
-            viewHolder.getPrimaryText().setAlpha(0);
+      doShowText(text);
+      // Hide the text so we can animate it in
+      viewHolder.getPrimaryText().setAlpha(0);
 
-            ViewAnimator primaryButton = viewHolder.getPrimaryButton();
-            // Cancel the automatic transition scheduled in doShowText
-            TransitionManager.endTransitions((ViewGroup) primaryButton.getParent());
-            primaryButton
-                .getViewTreeObserver()
-                .addOnPreDrawListener(
-                    new OnPreDrawListener() {
-                      @Override
-                      public boolean onPreDraw() {
-                        primaryButton.getViewTreeObserver().removeOnPreDrawListener(this);
+      ViewAnimator primaryButton = viewHolder.getPrimaryButton();
+      // Cancel the automatic transition scheduled in doShowText
+      TransitionManager.endTransitions((ViewGroup) primaryButton.getParent());
+      primaryButton
+          .getViewTreeObserver()
+          .addOnPreDrawListener(
+              new OnPreDrawListener() {
+                @Override
+                public boolean onPreDraw() {
+                  primaryButton.getViewTreeObserver().removeOnPreDrawListener(this);
 
-                        // Prepare and capture end values, always use the size of primaryText since
-                        // its invisibility makes primaryButton smaller than expected
-                        TransitionValues endValues = new TransitionValues();
-                        endValues.values.put(
-                            NewChangeOnScreenBounds.PROPNAME_WIDTH,
-                            viewHolder.getPrimaryText().getWidth());
-                        endValues.values.put(
-                            NewChangeOnScreenBounds.PROPNAME_HEIGHT,
-                            viewHolder.getPrimaryText().getHeight());
-                        endValues.view = primaryButton;
-                        transition.addTarget(endValues.view);
-                        transition.captureEndValues(endValues);
+                  // Prepare and capture end values, always use the size of primaryText since
+                  // its invisibility makes primaryButton smaller than expected
+                  TransitionValues endValues = new TransitionValues();
+                  endValues.values.put(
+                      NewChangeOnScreenBounds.PROPNAME_WIDTH,
+                      viewHolder.getPrimaryText().getWidth());
+                  endValues.values.put(
+                      NewChangeOnScreenBounds.PROPNAME_HEIGHT,
+                      viewHolder.getPrimaryText().getHeight());
+                  endValues.view = primaryButton;
+                  transition.addTarget(endValues.view);
+                  transition.captureEndValues(endValues);
 
-                        // animate the primary button bounds change
-                        Animator bounds =
-                            transition.createAnimator(primaryButton, startValues, endValues);
+                  // animate the primary button bounds change
+                  Animator bounds =
+                      transition.createAnimator(primaryButton, startValues, endValues);
 
-                        // Animate the text in
-                        Animator alpha =
-                            ObjectAnimator.ofFloat(viewHolder.getPrimaryText(), View.ALPHA, 1f);
+                  // Animate the text in
+                  Animator alpha =
+                      ObjectAnimator.ofFloat(viewHolder.getPrimaryText(), View.ALPHA, 1f);
 
-                        AnimatorSet set = new AnimatorSet();
-                        set.play(bounds).before(alpha);
-                        set.start();
-                        return false;
-                      }
-                    });
-          });
+                  AnimatorSet set = new AnimatorSet();
+                  set.play(bounds).before(alpha);
+                  set.start();
+                  return false;
+                }
+              });
     }
     handler.removeCallbacks(collapseRunnable);
     handler.postDelayed(collapseRunnable, SHOW_TEXT_DURATION_MILLIS);
   }
 
-  public void setBubbleExpansionStateListener(
-      BubbleExpansionStateListener bubbleExpansionStateListener) {
-    this.bubbleExpansionStateListener = bubbleExpansionStateListener;
-  }
-
   @Nullable
   Integer getGravityOverride() {
     return overrideGravity;
   }
 
   void onMoveStart() {
-    startCollapse(CollapseEnd.NOTHING, true);
+    if (viewHolder.getExpandedView().getVisibility() == View.VISIBLE) {
+      viewHolder.setDrawerVisibility(View.INVISIBLE);
+    }
+    expanded = false;
+    savedYPosition = -1;
+
     viewHolder
         .getPrimaryButton()
         .animate()
@@ -482,11 +648,6 @@
 
   void onMoveFinish() {
     viewHolder.getPrimaryButton().animate().translationZ(0);
-    // If it's GONE, no resize is necessary. If it's VISIBLE, it will get cleaned up when the
-    // collapse animation finishes
-    if (viewHolder.getExpandedView().getVisibility() == View.INVISIBLE) {
-      doResize(null);
-    }
   }
 
   void primaryButtonClick() {
@@ -494,12 +655,22 @@
       return;
     }
     if (expanded) {
-      startCollapse(CollapseEnd.NOTHING, true);
+      startCollapse(
+          CollapseEnd.NOTHING, true /* isUserAction */, true /* shouldRecoverYPosition */);
     } else {
       expand(true);
     }
   }
 
+  void onLeftRightSwitch(boolean onRight) {
+    // Set layout direction so the small icon is not partially hidden.
+    View primaryIcon = viewHolder.getPrimaryIcon();
+    int newGravity = (onRight ? Gravity.LEFT : Gravity.RIGHT) | Gravity.BOTTOM;
+    FrameLayout.LayoutParams layoutParams =
+        new FrameLayout.LayoutParams(primaryIcon.getWidth(), primaryIcon.getHeight(), newGravity);
+    primaryIcon.setLayoutParams(layoutParams);
+  }
+
   LayoutParams getWindowParams() {
     return windowParams;
   }
@@ -532,7 +703,7 @@
     }
 
     if (expanded) {
-      startCollapse(CollapseEnd.HIDE, false);
+      startCollapse(CollapseEnd.HIDE, false /* isUserAction */, false /* shouldRecoverYPosition */);
       return;
     }
 
@@ -618,34 +789,39 @@
     }
   }
 
-  private void doResize(@Nullable Runnable operation) {
-    // If we're resizing on the right side of the screen, there is an implicit move operation
-    // necessary. The WindowManager does not sync the move and resize operations, so serious jank
-    // would occur. To fix this, instead of resizing the window, we create a new one and destroy
-    // the old one. There is a short delay before destroying the old view to ensure the new one has
-    // had time to draw.
+  /**
+   * Create a new ViewHolder object to replace the old one.It only happens when not moving and
+   * collapsed.
+   */
+  void replaceViewHolder() {
+    LogUtil.enterBlock("NewBubble.replaceViewHolder");
     ViewHolder oldViewHolder = viewHolder;
-    if (isDrawingFromRight()) {
-      viewHolder = new ViewHolder(oldViewHolder.getRoot().getContext());
-      update();
-      viewHolder
-          .getPrimaryButton()
-          .setDisplayedChild(oldViewHolder.getPrimaryButton().getDisplayedChild());
-      viewHolder.getPrimaryText().setText(oldViewHolder.getPrimaryText().getText());
-    }
 
-    if (operation != null) {
-      operation.run();
-    }
+    // Create a new ViewHolder and copy needed info.
+    viewHolder = new ViewHolder(oldViewHolder.getRoot().getContext());
+    viewHolder
+        .getPrimaryButton()
+        .setDisplayedChild(oldViewHolder.getPrimaryButton().getDisplayedChild());
+    viewHolder.getPrimaryText().setText(oldViewHolder.getPrimaryText().getText());
 
-    if (isDrawingFromRight()) {
-      swapViewHolders(oldViewHolder);
-    }
-  }
+    int size = context.getResources().getDimensionPixelSize(R.dimen.bubble_small_icon_size);
+    viewHolder
+        .getPrimaryIcon()
+        .setLayoutParams(
+            new FrameLayout.LayoutParams(
+                size,
+                size,
+                Gravity.BOTTOM | (isDrawingFromRight() ? Gravity.LEFT : Gravity.RIGHT)));
 
-  private void swapViewHolders(ViewHolder oldViewHolder) {
+    update();
+
+    // Add new view at its horizontal boundary
     ViewGroup root = viewHolder.getRoot();
+    windowParams.x = leftBoundary;
+    windowParams.gravity = Gravity.TOP | (isDrawingFromRight() ? Gravity.RIGHT : Gravity.LEFT);
     windowManager.addView(root, windowParams);
+
+    // Remove the old view after delay
     root.getViewTreeObserver()
         .addOnPreDrawListener(
             new OnPreDrawListener() {
@@ -661,63 +837,8 @@
             });
   }
 
-  @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
-  public void startCollapse(@CollapseEnd int endAction, boolean isUserAction) {
-    View expandedView = viewHolder.getExpandedView();
-    if (expandedView.getVisibility() != View.VISIBLE || collapseAnimation != null) {
-      // Drawer is already collapsed or animation is running.
-      return;
-    }
-
-    overrideGravity = isDrawingFromRight() ? Gravity.RIGHT : Gravity.LEFT;
-    setFocused(false);
-
-    if (collapseEndAction == CollapseEnd.NOTHING) {
-      collapseEndAction = endAction;
-    }
-    if (bubbleExpansionStateListener != null && collapseEndAction == CollapseEnd.NOTHING) {
-      bubbleExpansionStateListener.onBubbleExpansionStateChanged(
-          ExpansionState.START_COLLAPSING, isUserAction);
-    }
-    // Animate expanded view to move from its position to above primary button and hide
-    collapseAnimation =
-        expandedView
-            .animate()
-            .translationY(-viewHolder.getRoot().getHeight())
-            .setInterpolator(new FastOutLinearInInterpolator())
-            .withEndAction(
-                () -> {
-                  collapseAnimation = null;
-                  expanded = false;
-
-                  if (textShowing) {
-                    // Will do resize once the text is done.
-                    return;
-                  }
-
-                  // Hide the drawer and resize if possible.
-                  viewHolder.setDrawerVisibility(View.INVISIBLE);
-                  if (!viewHolder.isMoving() || !isDrawingFromRight()) {
-                    doResize(() -> viewHolder.setDrawerVisibility(View.GONE));
-                  }
-
-                  // If this collapse was to come before a hide, do it now.
-                  if (collapseEndAction == CollapseEnd.HIDE) {
-                    hide();
-                  }
-                  collapseEndAction = CollapseEnd.NOTHING;
-
-                  // Resume normal gravity after any resizing is done.
-                  handler.postDelayed(
-                      () -> {
-                        overrideGravity = null;
-                        if (!viewHolder.isMoving()) {
-                          viewHolder.undoGravityOverride();
-                        }
-                      },
-                      // Need to wait twice as long for resize and layout
-                      WINDOW_REDRAW_DELAY_MILLIS * 2);
-                });
+  int getDrawerVisibility() {
+    return viewHolder.getExpandedView().getVisibility();
   }
 
   private boolean isDrawingFromRight() {
@@ -741,6 +862,16 @@
     updatePrimaryIconAnimation();
   }
 
+  private void logBasicOrCallImpression(DialerImpression.Type impressionType) {
+    DialerCall call = CallList.getInstance().getActiveOrBackgroundCall();
+    if (call != null) {
+      Logger.get(context)
+          .logCallImpression(impressionType, call.getUniqueCallId(), call.getTimeAddedMs());
+    } else {
+      Logger.get(context).logImpression(impressionType);
+    }
+  }
+
   @VisibleForTesting
   class ViewHolder {
 
@@ -779,7 +910,8 @@
       root.setOnBackPressedListener(
           () -> {
             if (visibility == Visibility.SHOWING && expanded) {
-              startCollapse(CollapseEnd.NOTHING, true);
+              startCollapse(
+                  CollapseEnd.NOTHING, true /* isUserAction */, true /* shouldRecoverYPosition */);
               return true;
             }
             return false;
@@ -794,7 +926,8 @@
       root.setOnTouchListener(
           (v, event) -> {
             if (expanded && event.getActionMasked() == MotionEvent.ACTION_OUTSIDE) {
-              startCollapse(CollapseEnd.NOTHING, true);
+              startCollapse(
+                  CollapseEnd.NOTHING, true /* isUserAction */, true /* shouldRecoverYPosition */);
               return true;
             }
             return false;
@@ -812,6 +945,16 @@
       moveHandler.setClickable(clickable);
     }
 
+    public int getMoveUpDistance() {
+      int deltaAllowed =
+          expandedView.getHeight()
+              - context
+                      .getResources()
+                      .getDimensionPixelOffset(R.dimen.bubble_button_padding_vertical)
+                  * 2;
+      return moveHandler.getMoveUpDistance(deltaAllowed);
+    }
+
     public ViewGroup getRoot() {
       return root;
     }
@@ -864,9 +1007,4 @@
       moveHandler.undoGravityOverride();
     }
   }
-
-  /** Listener for bubble expansion state change. */
-  public interface BubbleExpansionStateListener {
-    void onBubbleExpansionStateChanged(@ExpansionState int expansionState, boolean isUserAction);
-  }
 }
diff --git a/java/com/android/newbubble/NewMoveHandler.java b/java/com/android/newbubble/NewMoveHandler.java
index 189ad84..9cb1f1e 100644
--- a/java/com/android/newbubble/NewMoveHandler.java
+++ b/java/com/android/newbubble/NewMoveHandler.java
@@ -48,6 +48,8 @@
   private final int maxX;
   private final int maxY;
   private final int bubbleSize;
+  private final int bubbleShadowPaddingHorizontal;
+  private final int bubbleExpandedViewWidth;
   private final float touchSlopSquared;
 
   private boolean clickable = true;
@@ -70,8 +72,14 @@
         @Override
         public float getValue(LayoutParams windowParams) {
           int realX = windowParams.x;
-          realX = realX + bubbleSize / 2;
+          // Get bubble center position from real position
+          if (bubble.getDrawerVisibility() == View.INVISIBLE) {
+            realX += bubbleExpandedViewWidth / 2 + bubbleShadowPaddingHorizontal * 2;
+          } else {
+            realX += bubbleSize / 2 + bubbleShadowPaddingHorizontal;
+          }
           if (relativeToRight(windowParams)) {
+            // If gravity is right, get distant from bubble center position to screen right edge
             int displayWidth = context.getResources().getDisplayMetrics().widthPixels;
             realX = displayWidth - realX;
           }
@@ -88,12 +96,19 @@
           } else {
             onRight = (gravityOverride & Gravity.RIGHT) == Gravity.RIGHT;
           }
-          int centeringOffset = bubbleSize / 2;
+          // Get real position from bubble center position
+          int centeringOffset;
+          if (bubble.getDrawerVisibility() == View.INVISIBLE) {
+            centeringOffset = bubbleExpandedViewWidth / 2 + bubbleShadowPaddingHorizontal * 2;
+          } else {
+            centeringOffset = bubbleSize / 2 + bubbleShadowPaddingHorizontal;
+          }
           windowParams.x =
               (int) (onRight ? (displayWidth - value - centeringOffset) : value - centeringOffset);
           windowParams.gravity = Gravity.TOP | (onRight ? Gravity.RIGHT : Gravity.LEFT);
           if (bubble.isVisible()) {
             windowManager.updateViewLayout(bubble.getRootView(), windowParams);
+            bubble.onLeftRightSwitch(onRight);
           }
         }
       };
@@ -120,8 +135,13 @@
     windowManager = context.getSystemService(WindowManager.class);
 
     bubbleSize = context.getResources().getDimensionPixelSize(R.dimen.bubble_size);
+    bubbleShadowPaddingHorizontal =
+        context.getResources().getDimensionPixelSize(R.dimen.bubble_shadow_padding_size_horizontal);
+    bubbleExpandedViewWidth =
+        context.getResources().getDimensionPixelSize(R.dimen.bubble_expanded_width);
+    // The following value is based on bubble center
     minX =
-        context.getResources().getDimensionPixelOffset(R.dimen.bubble_safe_margin_horizontal)
+        context.getResources().getDimensionPixelOffset(R.dimen.bubble_off_screen_size_horizontal)
             + bubbleSize / 2;
     minY =
         context.getResources().getDimensionPixelOffset(R.dimen.bubble_safe_margin_vertical)
@@ -156,6 +176,12 @@
     moveYAnimation.animateToFinalPosition(yProperty.getValue(bubble.getWindowParams()));
   }
 
+  public int getMoveUpDistance(int deltaAllowed) {
+    int currentY = (int) yProperty.getValue(bubble.getWindowParams());
+    int currentDelta = maxY - currentY;
+    return currentDelta >= deltaAllowed ? 0 : deltaAllowed - currentDelta;
+  }
+
   @Override
   public boolean onTouch(View v, MotionEvent event) {
     float eventX = event.getRawX();
@@ -222,6 +248,14 @@
       moveXAnimation = new SpringAnimation(bubble.getWindowParams(), xProperty);
       moveXAnimation.setSpring(new SpringForce());
       moveXAnimation.getSpring().setDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY);
+      // Moving when expanded makes expanded view INVISIBLE, and the whole view is not at the
+      // boundary. It's time to create a viewHolder.
+      moveXAnimation.addEndListener(
+          (animation, canceled, value, velocity) -> {
+            if (!isMoving && bubble.getDrawerVisibility() == View.INVISIBLE) {
+              bubble.replaceViewHolder();
+            }
+          });
     }
 
     if (moveYAnimation == null) {
diff --git a/java/com/android/newbubble/res/layout/new_bubble_base.xml b/java/com/android/newbubble/res/layout/new_bubble_base.xml
index 8cac982..8d47716 100644
--- a/java/com/android/newbubble/res/layout/new_bubble_base.xml
+++ b/java/com/android/newbubble/res/layout/new_bubble_base.xml
@@ -19,7 +19,8 @@
     xmlns:tools="http://schemas.android.com/tools"
     android:layout_width="wrap_content"
     android:layout_height="wrap_content"
-    android:clipChildren="false"
+    android:clipChildren="true"
+    android:clipToPadding="false"
     tools:theme="@style/Theme.AppCompat">
   <RelativeLayout
       android:id="@+id/bubble_primary_container"
@@ -41,7 +42,8 @@
         android:measureAllChildren="false"
         android:elevation="@dimen/bubble_elevation"
         tools:backgroundTint="#FF0000AA">
-      <RelativeLayout
+      <FrameLayout
+          android:id="@+id/bubble_icon_container"
           android:layout_width="wrap_content"
           android:layout_height="wrap_content">
         <ImageView
@@ -53,8 +55,7 @@
             android:id="@+id/bubble_icon_primary"
             android:layout_width="@dimen/bubble_small_icon_size"
             android:layout_height="@dimen/bubble_small_icon_size"
-            android:layout_alignBottom="@id/bubble_icon_avatar"
-            android:layout_alignEnd="@id/bubble_icon_avatar"
+            android:layout_gravity="bottom|right"
             android:padding="@dimen/bubble_small_icon_padding"
             android:tint="@android:color/white"
             android:tintMode="src_in"
@@ -62,7 +63,7 @@
             android:measureAllChildren="false"
             tools:backgroundTint="#FF0000AA"
             tools:src="@android:drawable/ic_btn_speak_now"/>
-      </RelativeLayout>
+      </FrameLayout>
       <TextView
           android:id="@+id/bubble_text"
           android:layout_width="wrap_content"
@@ -75,67 +76,77 @@
           tools:text="Call ended"/>
     </ViewAnimator>
   </RelativeLayout>
+  <!-- The RelativeLayout below serves as boundary for @id/bubble_expanded_layout during animation -->
   <RelativeLayout
-      android:id="@+id/bubble_expanded_layout"
-      android:layout_width="@dimen/bubble_expanded_width"
+      android:layout_width="wrap_content"
       android:layout_height="wrap_content"
-      android:layout_below="@id/bubble_primary_container"
-      android:layout_marginStart="@dimen/bubble_shadow_padding_size_horizontal_double"
-      android:layout_marginEnd="@dimen/bubble_shadow_padding_size_horizontal_double"
       android:layout_marginTop="@dimen/bubble_shadow_padding_size_vertical_minus"
-      android:layout_marginBottom="@dimen/bubble_shadow_padding_size_vertical"
-      android:visibility="gone"
-      tools:visibility="visible">
+      android:clipChildren="true"
+      android:clipToPadding="false"
+      android:layout_below="@id/bubble_primary_container">
     <RelativeLayout
-        android:id="@+id/bubble_triangle"
-        android:layout_width="12dp"
-        android:layout_height="12dp"
-        android:layout_marginTop="7dp"
-        android:layout_marginBottom="-6dp"
-        android:layout_centerHorizontal="true"
-        android:background="@color/background_dialer_white"
-        android:elevation="@dimen/bubble_expanded_elevation"
-        android:rotation="45">
-    </RelativeLayout>
-    <RelativeLayout
-        android:layout_width="match_parent"
+        android:id="@+id/bubble_expanded_layout"
+        android:layout_width="wrap_content"
         android:layout_height="wrap_content"
-        android:layout_below="@id/bubble_triangle"
+        android:paddingStart="@dimen/bubble_shadow_padding_size_horizontal_double"
+        android:paddingEnd="@dimen/bubble_shadow_padding_size_horizontal_double"
+        android:paddingBottom="@dimen/bubble_shadow_padding_size_vertical"
+        android:clipChildren="false"
         android:clipToPadding="false"
-        android:background="@drawable/bubble_background_with_radius"
-        android:elevation="@dimen/bubble_expanded_elevation"
-        android:layoutDirection="inherit">
-      <com.android.newbubble.NewCheckableButton
-          android:id="@+id/bubble_button_full_screen"
-          android:layout_marginTop="8dp"
-          android:textColor="@color/bubble_button_color_grey"
-          android:background="@drawable/bubble_ripple_pill_up"
-          android:drawableTint="@color/bubble_button_color_grey"
-          style="@style/CheckableButton"/>
-      <com.android.newbubble.NewCheckableButton
-          android:id="@+id/bubble_button_mute"
-          android:layout_below="@id/bubble_button_full_screen"
-          android:textColor="@color/bubble_button_color_grey"
+        android:visibility="gone"
+        tools:visibility="visible">
+      <RelativeLayout
+          android:id="@+id/bubble_triangle"
+          android:layout_width="12dp"
+          android:layout_height="12dp"
+          android:layout_marginTop="7dp"
+          android:layout_marginBottom="-6dp"
+          android:layout_centerHorizontal="true"
           android:background="@color/background_dialer_white"
-          android:drawableTint="@color/bubble_button_color_grey"
-          style="@style/CheckableButtonWithSelectableItemBackground"/>
-      <com.android.newbubble.NewCheckableButton
-          android:id="@+id/bubble_button_audio_route"
-          android:layout_below="@id/bubble_button_mute"
-          android:textColor="@color/bubble_button_color_grey"
-          android:background="@color/background_dialer_white"
-          android:drawableTint="@color/bubble_button_color_grey"
-          style="@style/CheckableButtonWithSelectableItemBackground"/>
-      <com.android.newbubble.NewCheckableButton
-          android:id="@+id/bubble_button_end_call"
-          android:layout_below="@id/bubble_button_audio_route"
-          android:layout_marginTop="@dimen/bubble_expanded_separator_height"
-          android:textColor="@color/bubble_button_color_white"
-          android:background="@drawable/bubble_pill_down"
-          android:backgroundTint="@color/dialer_end_call_button_color"
-          android:foreground="?attr/selectableItemBackground"
-          android:drawableTint="@color/bubble_button_color_white"
-          style="@style/CheckableButton"/>
+          android:elevation="@dimen/bubble_expanded_elevation"
+          android:rotation="45">
+      </RelativeLayout>
+      <RelativeLayout
+          android:layout_width="wrap_content"
+          android:layout_height="wrap_content"
+          android:layout_below="@id/bubble_triangle"
+          android:background="@drawable/bubble_background_with_radius"
+          android:elevation="@dimen/bubble_expanded_elevation"
+          android:clipChildren="false"
+          android:clipToPadding="false"
+          android:layoutDirection="inherit">
+        <com.android.newbubble.NewCheckableButton
+            android:id="@+id/bubble_button_full_screen"
+            android:layout_marginTop="8dp"
+            android:textColor="@color/bubble_button_color_grey"
+            android:background="@drawable/bubble_ripple_pill_up"
+            android:drawableTint="@color/bubble_button_color_grey"
+            style="@style/CheckableButton"/>
+        <com.android.newbubble.NewCheckableButton
+            android:id="@+id/bubble_button_mute"
+            android:layout_below="@id/bubble_button_full_screen"
+            android:textColor="@color/bubble_button_color_grey"
+            android:background="@color/background_dialer_white"
+            android:drawableTint="@color/bubble_button_color_grey"
+            style="@style/CheckableButtonWithSelectableItemBackground"/>
+        <com.android.newbubble.NewCheckableButton
+            android:id="@+id/bubble_button_audio_route"
+            android:layout_below="@id/bubble_button_mute"
+            android:textColor="@color/bubble_button_color_grey"
+            android:background="@color/background_dialer_white"
+            android:drawableTint="@color/bubble_button_color_grey"
+            style="@style/CheckableButtonWithSelectableItemBackground"/>
+        <com.android.newbubble.NewCheckableButton
+            android:id="@+id/bubble_button_end_call"
+            android:layout_below="@id/bubble_button_audio_route"
+            android:layout_marginTop="@dimen/bubble_expanded_separator_height"
+            android:textColor="@color/bubble_button_color_white"
+            android:background="@drawable/bubble_pill_down"
+            android:backgroundTint="@color/dialer_end_call_button_color"
+            android:foreground="?attr/selectableItemBackground"
+            android:drawableTint="@color/bubble_button_color_white"
+            style="@style/CheckableButton"/>
+      </RelativeLayout>
     </RelativeLayout>
   </RelativeLayout>
 </RelativeLayout>
diff --git a/java/com/android/newbubble/res/values/values.xml b/java/com/android/newbubble/res/values/values.xml
index 6dda61d..71f813a 100644
--- a/java/com/android/newbubble/res/values/values.xml
+++ b/java/com/android/newbubble/res/values/values.xml
@@ -24,8 +24,11 @@
   <dimen name="bubble_button_icon_padding">16dp</dimen>
   <dimen name="bubble_button_padding_vertical">12dp</dimen>
   <dimen name="bubble_button_padding_horizontal">16dp</dimen>
-  <dimen name="bubble_safe_margin_horizontal">-16dp</dimen>
-  <dimen name="bubble_safe_margin_vertical">64dp</dimen>
+
+  <dimen name="bubble_off_screen_size_horizontal">-4dp</dimen>
+  <!-- 64dp - 16dp(bubble_shadow_padding_size_vertical) -->
+  <dimen name="bubble_safe_margin_vertical">48dp</dimen>
+
   <dimen name="bubble_shadow_padding_size_vertical">16dp</dimen>
   <dimen name="bubble_shadow_padding_size_vertical_minus">-16dp</dimen>
   <dimen name="bubble_shadow_padding_size_horizontal">12dp</dimen>