Animation for bubbles home gesture

Move bubbles expanded view up when user is performing the home gesture.
This gives immediate visual feedback to the user when swiping up to
collapse the expanded bubble.
Updating the expanded view collapse animation so that it works well
together with swipe up gesture.

Bug: 170163464
Test: atest PlatformScenarioTests:android.platform.test.scenario.sysui.bubble
Test: atest frameworks/base/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles
Change-Id: I834ef8de73dc4f9682583fa727ef42db21c19a7f
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/animation/Interpolators.java b/libs/WindowManager/Shell/src/com/android/wm/shell/animation/Interpolators.java
index 2aead93..a0dde6a 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/animation/Interpolators.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/animation/Interpolators.java
@@ -53,6 +53,19 @@
     public static final Interpolator LINEAR_OUT_SLOW_IN = new PathInterpolator(0f, 0f, 0.2f, 1f);
 
     /**
+     * The accelerated emphasized interpolator. Used for hero / emphasized movement of content that
+     * is disappearing e.g. when moving off screen.
+     */
+    public static final Interpolator EMPHASIZED_ACCELERATE = new PathInterpolator(
+            0.3f, 0f, 0.8f, 0.15f);
+
+    /**
+     * The decelerating emphasized interpolator. Used for hero / emphasized movement of content that
+     * is appearing e.g. when coming from off screen
+     */
+    public static final Interpolator EMPHASIZED_DECELERATE = new PathInterpolator(
+            0.05f, 0.7f, 0.1f, 1f);
+    /**
      * Interpolator to be used when animating a move based on a click. Pair with enough duration.
      */
     public static final Interpolator TOUCH_RESPONSE = new PathInterpolator(0.3f, 0f, 0.1f, 1f);
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleController.java
index 8cfeefe..c4a497a 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleController.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleController.java
@@ -1512,7 +1512,7 @@
         public void onImeVisibilityChanged(boolean imeVisible, int imeHeight) {
             mBubblePositioner.setImeVisible(imeVisible, imeHeight);
             if (mStackView != null) {
-                mStackView.animateForIme(imeVisible);
+                mStackView.setImeVisible(imeVisible);
             }
         }
     }
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleDebugConfig.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleDebugConfig.java
index dc2ace9..dce6b56 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleDebugConfig.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleDebugConfig.java
@@ -46,6 +46,9 @@
     static final boolean DEBUG_OVERFLOW = false;
     static final boolean DEBUG_USER_EDUCATION = false;
     static final boolean DEBUG_POSITIONER = false;
+    public static final boolean DEBUG_COLLAPSE_ANIMATOR = false;
+    static final boolean DEBUG_BUBBLE_GESTURE = false;
+    public static boolean DEBUG_EXPANDED_VIEW_DRAGGING = false;
 
     private static final boolean FORCE_SHOW_USER_EDUCATION = false;
     private static final String FORCE_SHOW_USER_EDUCATION_SETTING =
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleExpandedView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleExpandedView.java
index a089585..1eb6f73 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleExpandedView.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleExpandedView.java
@@ -43,10 +43,13 @@
 import android.graphics.Outline;
 import android.graphics.Paint;
 import android.graphics.Picture;
+import android.graphics.PointF;
 import android.graphics.Rect;
 import android.graphics.drawable.ShapeDrawable;
 import android.os.RemoteException;
 import android.util.AttributeSet;
+import android.util.FloatProperty;
+import android.util.IntProperty;
 import android.util.Log;
 import android.util.TypedValue;
 import android.view.LayoutInflater;
@@ -75,6 +78,48 @@
 public class BubbleExpandedView extends LinearLayout {
     private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleExpandedView" : TAG_BUBBLES;
 
+    /** {@link IntProperty} for updating bottom clip */
+    public static final IntProperty<BubbleExpandedView> BOTTOM_CLIP_PROPERTY =
+            new IntProperty<BubbleExpandedView>("bottomClip") {
+                @Override
+                public void setValue(BubbleExpandedView expandedView, int value) {
+                    expandedView.setBottomClip(value);
+                }
+
+                @Override
+                public Integer get(BubbleExpandedView expandedView) {
+                    return expandedView.mBottomClip;
+                }
+            };
+
+    /** {@link FloatProperty} for updating taskView or overflow alpha */
+    public static final FloatProperty<BubbleExpandedView> CONTENT_ALPHA =
+            new FloatProperty<BubbleExpandedView>("contentAlpha") {
+                @Override
+                public void setValue(BubbleExpandedView expandedView, float value) {
+                    expandedView.setContentAlpha(value);
+                }
+
+                @Override
+                public Float get(BubbleExpandedView expandedView) {
+                    return expandedView.getContentAlpha();
+                }
+            };
+
+    /** {@link FloatProperty} for updating manage button alpha */
+    public static final FloatProperty<BubbleExpandedView> MANAGE_BUTTON_ALPHA =
+            new FloatProperty<BubbleExpandedView>("manageButtonAlpha") {
+                @Override
+                public void setValue(BubbleExpandedView expandedView, float value) {
+                    expandedView.mManageButton.setAlpha(value);
+                }
+
+                @Override
+                public Float get(BubbleExpandedView expandedView) {
+                    return expandedView.mManageButton.getAlpha();
+                }
+            };
+
     // The triangle pointing to the expanded view
     private View mPointerView;
     @Nullable private int[] mExpandedViewContainerLocation;
@@ -90,7 +135,7 @@
 
     /**
      * Whether we want the {@code TaskView}'s content to be visible (alpha = 1f). If
-     * {@link #mIsAlphaAnimating} is true, this may not reflect the {@code TaskView}'s actual alpha
+     * {@link #mIsAnimating} is true, this may not reflect the {@code TaskView}'s actual alpha
      * value until the animation ends.
      */
     private boolean mIsContentVisible = false;
@@ -99,12 +144,13 @@
      * Whether we're animating the {@code TaskView}'s alpha value. If so, we will hold off on
      * applying alpha changes from {@link #setContentVisibility} until the animation ends.
      */
-    private boolean mIsAlphaAnimating = false;
+    private boolean mIsAnimating = false;
 
     private int mPointerWidth;
     private int mPointerHeight;
     private float mPointerRadius;
     private float mPointerOverlap;
+    private final PointF mPointerPos = new PointF();
     private CornerPathEffect mPointerEffect;
     private ShapeDrawable mCurrentPointer;
     private ShapeDrawable mTopPointer;
@@ -113,11 +159,13 @@
     private float mCornerRadius = 0f;
     private int mBackgroundColorFloating;
     private boolean mUsingMaxHeight;
-
+    private int mTopClip = 0;
+    private int mBottomClip = 0;
     @Nullable private Bubble mBubble;
     private PendingIntent mPendingIntent;
     // TODO(b/170891664): Don't use a flag, set the BubbleOverflow object instead
     private boolean mIsOverflow;
+    private boolean mIsClipping;
 
     private BubbleController mController;
     private BubbleStackView mStackView;
@@ -268,7 +316,8 @@
         mExpandedViewContainer.setOutlineProvider(new ViewOutlineProvider() {
             @Override
             public void getOutline(View view, Outline outline) {
-                outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), mCornerRadius);
+                Rect clip = new Rect(0, mTopClip, view.getWidth(), view.getHeight() - mBottomClip);
+                outline.setRoundRect(clip, mCornerRadius);
             }
         });
         mExpandedViewContainer.setClipToOutline(true);
@@ -300,9 +349,9 @@
             // they should not collapse the stack (which all other touches on areas around the AV
             // would do).
             if (motionEvent.getRawY() >= avBounds.top
-                            && motionEvent.getRawY() <= avBounds.bottom
-                            && (motionEvent.getRawX() < avBounds.left
-                                || motionEvent.getRawX() > avBounds.right)) {
+                    && motionEvent.getRawY() <= avBounds.bottom
+                    && (motionEvent.getRawX() < avBounds.left
+                    || motionEvent.getRawX() > avBounds.right)) {
                 return true;
             }
 
@@ -384,7 +433,7 @@
     }
 
     void applyThemeAttrs() {
-        final TypedArray ta = mContext.obtainStyledAttributes(new int[] {
+        final TypedArray ta = mContext.obtainStyledAttributes(new int[]{
                 android.R.attr.dialogCornerRadius,
                 android.R.attr.colorBackgroundFloating});
         boolean supportsRoundedCorners = ScreenDecorationsUtils.supportsRoundedCornersOnWindows(
@@ -429,7 +478,7 @@
      * ordering surfaces during animations. When content is drawn on top of the app (e.g. bubble
      * being dragged out, the manage menu) this is set to false, otherwise it should be true.
      */
-    void setSurfaceZOrderedOnTop(boolean onTop) {
+    public void setSurfaceZOrderedOnTop(boolean onTop) {
         if (mTaskView == null) {
             return;
         }
@@ -510,12 +559,12 @@
     }
 
     /**
-     * Whether we are currently animating the {@code TaskView}'s alpha value. If this is set to
+     * Whether we are currently animating the {@code TaskView}. If this is set to
      * true, calls to {@link #setContentVisibility} will not be applied until this is set to false
      * again.
      */
-    void setAlphaAnimating(boolean animating) {
-        mIsAlphaAnimating = animating;
+    public void setAnimating(boolean animating) {
+        mIsAnimating = animating;
 
         // If we're done animating, apply the correct
         if (!animating) {
@@ -524,15 +573,128 @@
     }
 
     /**
-     * Sets the alpha of the underlying {@code TaskView}, since changing the expanded view's alpha
-     * does not affect the {@code TaskView} since it uses a Surface.
+     * Get alpha from underlying {@code TaskView} if this view is for a bubble.
+     * Or get alpha for the overflow view if this view is for overflow.
+     *
+     * @return alpha for the content being shown
      */
-    void setTaskViewAlpha(float alpha) {
+    public float getContentAlpha() {
+        if (mIsOverflow) {
+            return mOverflowView.getAlpha();
+        }
         if (mTaskView != null) {
+            return mTaskView.getAlpha();
+        }
+        return 1f;
+    }
+
+    /**
+     * Set alpha of the underlying {@code TaskView} if this view is for a bubble.
+     * Or set alpha for the overflow view if this view is for overflow.
+     *
+     * Changing expanded view's alpha does not affect the {@code TaskView} since it uses a Surface.
+     */
+    public void setContentAlpha(float alpha) {
+        if (mIsOverflow) {
+            mOverflowView.setAlpha(alpha);
+        } else if (mTaskView != null) {
             mTaskView.setAlpha(alpha);
         }
-        mPointerView.setAlpha(alpha);
-        setAlpha(alpha);
+    }
+
+    /**
+     * Set translation Y for the expanded view content.
+     * Excludes manage button and pointer.
+     */
+    public void setContentTranslationY(float translationY) {
+        mExpandedViewContainer.setTranslationY(translationY);
+
+        // Left or right pointer can become detached when moving the view up
+        if (translationY <= 0 && (isShowingLeftPointer() || isShowingRightPointer())) {
+            // Y coordinate where the pointer would start to get detached from the expanded view.
+            // Takes into account bottom clipping and rounded corners
+            float detachPoint =
+                    mExpandedViewContainer.getBottom() - mBottomClip - mCornerRadius + translationY;
+            float pointerBottom = mPointerPos.y + mPointerHeight;
+            // If pointer bottom is past detach point, move it in by that many pixels
+            float horizontalShift = 0;
+            if (pointerBottom > detachPoint) {
+                horizontalShift = pointerBottom - detachPoint;
+            }
+            if (isShowingLeftPointer()) {
+                // Move left pointer right
+                movePointerBy(horizontalShift, 0);
+            } else {
+                // Move right pointer left
+                movePointerBy(-horizontalShift, 0);
+            }
+            // Hide pointer if it is moved by entire width
+            mPointerView.setVisibility(
+                    horizontalShift > mPointerWidth ? View.INVISIBLE : View.VISIBLE);
+        }
+    }
+
+    /**
+     * Update alpha value for the manage button
+     */
+    public void setManageButtonAlpha(float alpha) {
+        mManageButton.setAlpha(alpha);
+    }
+
+    /**
+     * Set {@link #setTranslationY(float) translationY} for the manage button
+     */
+    public void setManageButtonTranslationY(float translationY) {
+        mManageButton.setTranslationY(translationY);
+    }
+
+    /**
+     * Set top clipping for the view
+     */
+    public void setTopClip(int clip) {
+        mTopClip = clip;
+        onContainerClipUpdate();
+    }
+
+    /**
+     * Set bottom clipping for the view
+     */
+    public void setBottomClip(int clip) {
+        mBottomClip = clip;
+        onContainerClipUpdate();
+    }
+
+    private void onContainerClipUpdate() {
+        if (mTopClip == 0 && mBottomClip == 0) {
+            if (mIsClipping) {
+                mIsClipping = false;
+                if (mTaskView != null) {
+                    mTaskView.setClipBounds(null);
+                    mTaskView.setEnableSurfaceClipping(false);
+                }
+                mExpandedViewContainer.invalidateOutline();
+            }
+        } else {
+            if (!mIsClipping) {
+                mIsClipping = true;
+                if (mTaskView != null) {
+                    mTaskView.setEnableSurfaceClipping(true);
+                }
+            }
+            mExpandedViewContainer.invalidateOutline();
+            if (mTaskView != null) {
+                mTaskView.setClipBounds(new Rect(0, mTopClip, mTaskView.getWidth(),
+                        mTaskView.getHeight() - mBottomClip));
+            }
+        }
+    }
+
+    /**
+     * Move pointer from base position
+     */
+    public void movePointerBy(float x, float y) {
+        mPointerView.setTranslationX(mPointerPos.x + x);
+        mPointerView.setTranslationY(mPointerPos.y + y);
     }
 
     /**
@@ -543,13 +705,13 @@
      * Note that this contents visibility doesn't affect visibility at {@link android.view.View},
      * and setting {@code false} actually means rendering the contents in transparent.
      */
-    void setContentVisibility(boolean visibility) {
+    public void setContentVisibility(boolean visibility) {
         if (DEBUG_BUBBLE_EXPANDED_VIEW) {
             Log.d(TAG, "setContentVisibility: visibility=" + visibility
                     + " bubble=" + getBubbleKey());
         }
         mIsContentVisible = visibility;
-        if (mTaskView != null && !mIsAlphaAnimating) {
+        if (mTaskView != null && !mIsAnimating) {
             mTaskView.setAlpha(visibility ? 1f : 0f);
             mPointerView.setAlpha(visibility ? 1f : 0f);
         }
@@ -560,6 +722,44 @@
         return mTaskView;
     }
 
+    @VisibleForTesting
+    public BubbleOverflowContainerView getOverflow() {
+        return mOverflowView;
+    }
+
+
+    /**
+     * Return content height: taskView or overflow.
+     * Takes into account clippings set by {@link #setTopClip(int)} and {@link #setBottomClip(int)}
+     *
+     * @return if bubble is for overflow, return overflow height, otherwise return taskView height
+     */
+    public int getContentHeight() {
+        if (mIsOverflow) {
+            return mOverflowView.getHeight() - mTopClip - mBottomClip;
+        }
+        if (mTaskView != null) {
+            return mTaskView.getHeight() - mTopClip - mBottomClip;
+        }
+        return 0;
+    }
+
+    /**
+     * Return bottom position of the content on screen
+     *
+     * @return if bubble is for overflow, return value for overflow, otherwise taskView
+     */
+    public int getContentBottomOnScreen() {
+        Rect out = new Rect();
+        if (mIsOverflow) {
+            mOverflowView.getBoundsOnScreen(out);
+        }
+        if (mTaskView != null) {
+            mTaskView.getBoundsOnScreen(out);
+        }
+        return out.bottom;
+    }
+
     int getTaskId() {
         return mTaskId;
     }
@@ -728,28 +928,48 @@
         post(() -> {
             mCurrentPointer = showVertically ? onLeft ? mLeftPointer : mRightPointer : mTopPointer;
             updatePointerView();
-            float pointerY;
-            float pointerX;
             if (showVertically) {
-                pointerY = bubbleCenter - (mPointerWidth / 2f);
-                pointerX = onLeft
+                mPointerPos.y = bubbleCenter - (mPointerWidth / 2f);
+                mPointerPos.x = onLeft
                         ? -mPointerHeight + mPointerOverlap
                         : getWidth() - mPaddingRight - mPointerOverlap;
             } else {
-                pointerY = mPointerOverlap;
-                pointerX = bubbleCenter - (mPointerWidth / 2f);
+                mPointerPos.y = mPointerOverlap;
+                mPointerPos.x = bubbleCenter - (mPointerWidth / 2f);
             }
             if (animate) {
-                mPointerView.animate().translationX(pointerX).translationY(pointerY).start();
+                mPointerView.animate().translationX(mPointerPos.x).translationY(
+                        mPointerPos.y).start();
             } else {
-                mPointerView.setTranslationY(pointerY);
-                mPointerView.setTranslationX(pointerX);
+                mPointerView.setTranslationY(mPointerPos.y);
+                mPointerView.setTranslationX(mPointerPos.x);
                 mPointerView.setVisibility(VISIBLE);
             }
         });
     }
 
     /**
+     * Return true if pointer is shown on the left
+     */
+    public boolean isShowingLeftPointer() {
+        return mCurrentPointer == mLeftPointer;
+    }
+
+    /**
+     * Return true if pointer is shown on the right
+     */
+    public boolean isShowingRightPointer() {
+        return mCurrentPointer == mRightPointer;
+    }
+
+    /**
+     * Return width of the current pointer
+     */
+    public int getPointerWidth() {
+        return mPointerWidth;
+    }
+
+    /**
      * Position of the manage button displayed in the expanded view. Used for placing user
      * education about the manage button.
      */
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblePositioner.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblePositioner.java
index 7cfacbc..7d60114 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblePositioner.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblePositioner.java
@@ -344,6 +344,14 @@
         return mImeVisible ? mImeHeight : 0;
     }
 
+    /** Return top position of the IME if it's visible */
+    public int getImeTop() {
+        if (mImeVisible) {
+            return getScreenRect().bottom - getImeHeight() - getInsets().bottom;
+        }
+        return 0;
+    }
+
     /** Sets whether the IME is visible. **/
     public void setImeVisible(boolean visible, int height) {
         mImeVisible = visible;
@@ -706,4 +714,21 @@
     public void setPinnedLocation(PointF point) {
         mPinLocation = point;
     }
+
+    /**
+     * Navigation bar has an area where system gestures can be started from.
+     *
+     * @return {@link Rect} for system navigation bar gesture zone
+     */
+    public Rect getNavBarGestureZone() {
+        // Gesture zone height from the bottom
+        int gestureZoneHeight = mContext.getResources().getDimensionPixelSize(
+                com.android.internal.R.dimen.navigation_bar_gesture_height);
+        Rect screen = getScreenRect();
+        return new Rect(
+                screen.left,
+                screen.bottom - gestureZoneHeight,
+                screen.right,
+                screen.bottom);
+    }
 }
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleStackView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleStackView.java
index b7c5eb0..941bcab 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleStackView.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleStackView.java
@@ -44,6 +44,7 @@
 import android.graphics.RectF;
 import android.graphics.drawable.ColorDrawable;
 import android.os.Bundle;
+import android.os.SystemProperties;
 import android.provider.Settings;
 import android.util.Log;
 import android.view.Choreographer;
@@ -56,6 +57,7 @@
 import android.view.ViewGroup;
 import android.view.ViewOutlineProvider;
 import android.view.ViewTreeObserver;
+import android.view.WindowManagerPolicyConstants;
 import android.view.accessibility.AccessibilityNodeInfo;
 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
 import android.widget.FrameLayout;
@@ -75,8 +77,12 @@
 import com.android.wm.shell.R;
 import com.android.wm.shell.animation.Interpolators;
 import com.android.wm.shell.animation.PhysicsAnimator;
+import com.android.wm.shell.bubbles.BubblesNavBarMotionEventHandler.MotionEventListener;
 import com.android.wm.shell.bubbles.animation.AnimatableScaleMatrix;
 import com.android.wm.shell.bubbles.animation.ExpandedAnimationController;
+import com.android.wm.shell.bubbles.animation.ExpandedViewAnimationController;
+import com.android.wm.shell.bubbles.animation.ExpandedViewAnimationControllerImpl;
+import com.android.wm.shell.bubbles.animation.ExpandedViewAnimationControllerStub;
 import com.android.wm.shell.bubbles.animation.PhysicsAnimationLayout;
 import com.android.wm.shell.bubbles.animation.StackAnimationController;
 import com.android.wm.shell.common.FloatingContentCoordinator;
@@ -97,6 +103,12 @@
  */
 public class BubbleStackView extends FrameLayout
         implements ViewTreeObserver.OnComputeInternalInsetsListener {
+    /**
+     * Set to {@code true} to enable home gesture handling in bubbles
+     */
+    public static final boolean HOME_GESTURE_ENABLED =
+            SystemProperties.getBoolean("persist.wm.debug.bubbles_home_gesture", false);
+
     private static final String TAG = TAG_WITH_CLASS_NAME ? "BubbleStackView" : TAG_BUBBLES;
 
     /** How far the flyout needs to be dragged before it's dismissed regardless of velocity. */
@@ -148,7 +160,7 @@
      * Handler to use for all delayed animations - this way, we can easily cancel them before
      * starting a new animation.
      */
-    private final ShellExecutor mDelayedAnimationExecutor;
+    private final ShellExecutor mMainExecutor;
     private Runnable mDelayedAnimation;
 
     /**
@@ -197,6 +209,7 @@
     private PhysicsAnimationLayout mBubbleContainer;
     private StackAnimationController mStackAnimationController;
     private ExpandedAnimationController mExpandedAnimationController;
+    private ExpandedViewAnimationController mExpandedViewAnimationController;
 
     private View mScrim;
     private View mManageMenuScrim;
@@ -276,6 +289,9 @@
      */
     private int mPointerIndexDown = -1;
 
+    @Nullable
+    private BubblesNavBarGestureTracker mBubblesNavBarGestureTracker;
+
     /** Description of current animation controller state. */
     public void dump(PrintWriter pw, String[] args) {
         pw.println("Stack view state:");
@@ -693,6 +709,67 @@
         }
     };
 
+    /** Touch listener set on the whole view that forwards event to the swipe up listener. */
+    private final RelativeTouchListener mContainerSwipeListener = new RelativeTouchListener() {
+        @Override
+        public boolean onDown(@NonNull View v, @NonNull MotionEvent ev) {
+            // Pass move event on to swipe listener
+            mSwipeUpListener.onDown(ev.getX(), ev.getY());
+            return true;
+        }
+
+        @Override
+        public void onMove(@NonNull View v, @NonNull MotionEvent ev, float viewInitialX,
+                float viewInitialY, float dx, float dy) {
+            // Pass move event on to swipe listener
+            mSwipeUpListener.onMove(dx, dy);
+        }
+
+        @Override
+        public void onUp(@NonNull View v, @NonNull MotionEvent ev, float viewInitialX,
+                float viewInitialY, float dx, float dy, float velX, float velY) {
+            // Pass up even on to swipe listener
+            mSwipeUpListener.onUp(velX, velY);
+        }
+    };
+
+    /** MotionEventListener that listens from home gesture swipe event. */
+    private final MotionEventListener mSwipeUpListener = new MotionEventListener() {
+        @Override
+        public void onDown(float x, float y) {}
+
+        @Override
+        public void onMove(float dx, float dy) {
+            if ((mManageEduView != null && mManageEduView.getVisibility() == VISIBLE)
+                    || isStackEduShowing()) {
+                return;
+            }
+
+            if (mShowingManage) {
+                showManageMenu(false /* show */);
+            }
+            // Only allow up
+            float collapsed = Math.min(dy, 0);
+            mExpandedViewAnimationController.updateDrag((int) -collapsed);
+        }
+
+        @Override
+        public void onCancel() {
+            mExpandedViewAnimationController.animateBackToExpanded();
+        }
+
+        @Override
+        public void onUp(float velX, float velY) {
+            mExpandedViewAnimationController.setSwipeVelocity(velY);
+            if (mExpandedViewAnimationController.shouldCollapse()) {
+                // Update data first and start the animation when we are processing change
+                mBubbleData.setExpanded(false);
+            } else {
+                mExpandedViewAnimationController.animateBackToExpanded();
+            }
+        }
+    };
+
     /** Click listener set on the flyout, which expands the stack when the flyout is tapped. */
     private OnClickListener mFlyoutClickListener = new OnClickListener() {
         @Override
@@ -766,7 +843,7 @@
             ShellExecutor mainExecutor) {
         super(context);
 
-        mDelayedAnimationExecutor = mainExecutor;
+        mMainExecutor = mainExecutor;
         mBubbleController = bubbleController;
         mBubbleData = data;
 
@@ -796,6 +873,14 @@
 
         mExpandedAnimationController = new ExpandedAnimationController(mPositioner,
                 onBubbleAnimatedOut, this);
+
+        if (HOME_GESTURE_ENABLED) {
+            mExpandedViewAnimationController =
+                    new ExpandedViewAnimationControllerImpl(context, mPositioner);
+        } else {
+            mExpandedViewAnimationController = new ExpandedViewAnimationControllerStub();
+        }
+
         mSurfaceSynchronizer = synchronizer != null ? synchronizer : DEFAULT_SURFACE_SYNCHRONIZER;
 
         // Force LTR by default since most of the Bubbles UI is positioned manually by the user, or
@@ -971,7 +1056,7 @@
                 if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) {
                     // We need to be Z ordered on top in order for alpha animations to work.
                     mExpandedBubble.getExpandedView().setSurfaceZOrderedOnTop(true);
-                    mExpandedBubble.getExpandedView().setAlphaAnimating(true);
+                    mExpandedBubble.getExpandedView().setAnimating(true);
                 }
             }
 
@@ -985,14 +1070,15 @@
                         // = 0f remains in effect.
                         && !mExpandedViewTemporarilyHidden) {
                     mExpandedBubble.getExpandedView().setSurfaceZOrderedOnTop(false);
-                    mExpandedBubble.getExpandedView().setAlphaAnimating(false);
+                    mExpandedBubble.getExpandedView().setAnimating(false);
                 }
             }
         });
         mExpandedViewAlphaAnimator.addUpdateListener(valueAnimator -> {
             if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) {
-                mExpandedBubble.getExpandedView().setTaskViewAlpha(
-                        (float) valueAnimator.getAnimatedValue());
+                float alpha = (float) valueAnimator.getAnimatedValue();
+                mExpandedBubble.getExpandedView().setContentAlpha(alpha);
+                mExpandedBubble.getExpandedView().setAlpha(alpha);
             }
         });
 
@@ -1795,6 +1881,7 @@
     private void showNewlySelectedBubble(BubbleViewProvider bubbleToSelect) {
         final BubbleViewProvider previouslySelected = mExpandedBubble;
         mExpandedBubble = bubbleToSelect;
+        mExpandedViewAnimationController.setExpandedView(mExpandedBubble.getExpandedView());
 
         if (mIsExpanded) {
             hideCurrentInputMethod();
@@ -1848,7 +1935,12 @@
         mBubbleController.getSysuiProxy().onStackExpandChanged(shouldExpand);
 
         if (mIsExpanded) {
-            animateCollapse();
+            stopMonitoringSwipeUpGesture();
+            if (HOME_GESTURE_ENABLED) {
+                animateCollapse();
+            } else {
+                animateCollapseWithScale();
+            }
             logBubbleEvent(mExpandedBubble, FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__COLLAPSED);
         } else {
             animateExpansion();
@@ -1856,10 +1948,37 @@
             logBubbleEvent(mExpandedBubble, FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__EXPANDED);
             logBubbleEvent(mExpandedBubble,
                     FrameworkStatsLog.BUBBLE_UICHANGED__ACTION__STACK_EXPANDED);
+            if (HOME_GESTURE_ENABLED) {
+                startMonitoringSwipeUpGesture();
+            }
         }
         notifyExpansionChanged(mExpandedBubble, mIsExpanded);
     }
 
+    private void startMonitoringSwipeUpGesture() {
+        stopMonitoringSwipeUpGesture();
+
+        if (isGestureNavEnabled()) {
+            mBubblesNavBarGestureTracker = new BubblesNavBarGestureTracker(mContext, mPositioner);
+            mBubblesNavBarGestureTracker.start(mSwipeUpListener);
+            setOnTouchListener(mContainerSwipeListener);
+        }
+    }
+
+    private boolean isGestureNavEnabled() {
+        return mContext.getResources().getInteger(
+                com.android.internal.R.integer.config_navBarInteractionMode)
+                == WindowManagerPolicyConstants.NAV_BAR_MODE_GESTURAL;
+    }
+
+    private void stopMonitoringSwipeUpGesture() {
+        if (mBubblesNavBarGestureTracker != null) {
+            mBubblesNavBarGestureTracker.stop();
+            mBubblesNavBarGestureTracker = null;
+            setOnTouchListener(null);
+        }
+    }
+
     /**
      * Called when back press occurs while bubbles are expanded.
      */
@@ -2072,11 +2191,12 @@
         mExpandedViewContainer.setAnimationMatrix(mExpandedViewContainerMatrix);
 
         if (mExpandedBubble.getExpandedView() != null) {
-            mExpandedBubble.getExpandedView().setTaskViewAlpha(0f);
+            mExpandedBubble.getExpandedView().setContentAlpha(0f);
+            mExpandedBubble.getExpandedView().setAlpha(0f);
 
             // We'll be starting the alpha animation after a slight delay, so set this flag early
             // here.
-            mExpandedBubble.getExpandedView().setAlphaAnimating(true);
+            mExpandedBubble.getExpandedView().setAnimating(true);
         }
 
         mDelayedAnimation = () -> {
@@ -2114,10 +2234,10 @@
                     })
                     .start();
         };
-        mDelayedAnimationExecutor.executeDelayed(mDelayedAnimation, startDelay);
+        mMainExecutor.executeDelayed(mDelayedAnimation, startDelay);
     }
 
-    private void animateCollapse() {
+    private void animateCollapseWithScale() {
         cancelDelayedExpandCollapseSwitchAnimations();
 
         if (mManageEduView != null && mManageEduView.getVisibility() == VISIBLE) {
@@ -2217,6 +2337,68 @@
                 .start();
     }
 
+    private void animateCollapse() {
+        cancelDelayedExpandCollapseSwitchAnimations();
+
+        if (mManageEduView != null && mManageEduView.getVisibility() == VISIBLE) {
+            mManageEduView.hide();
+        }
+
+        mIsExpanded = false;
+        mIsExpansionAnimating = true;
+
+        showScrim(false);
+
+        mBubbleContainer.cancelAllAnimations();
+
+        // If we were in the middle of swapping, the animating-out surface would have been scaling
+        // to zero - finish it off.
+        PhysicsAnimator.getInstance(mAnimatingOutSurfaceContainer).cancel();
+        mAnimatingOutSurfaceContainer.setScaleX(0f);
+        mAnimatingOutSurfaceContainer.setScaleY(0f);
+
+        // Let the expanded animation controller know that it shouldn't animate child adds/reorders
+        // since we're about to animate collapsed.
+        mExpandedAnimationController.notifyPreparingToCollapse();
+
+        final Runnable collapseBackToStack = () -> mExpandedAnimationController.collapseBackToStack(
+                mStackAnimationController
+                        .getStackPositionAlongNearestHorizontalEdge()
+                /* collapseTo */,
+                () -> mBubbleContainer.setActiveController(mStackAnimationController));
+
+        final Runnable after = () -> {
+            final BubbleViewProvider previouslySelected = mExpandedBubble;
+            // TODO(b/231350255): investigate why this call is needed here
+            beforeExpandedViewAnimation();
+            if (mManageEduView != null) {
+                mManageEduView.hide();
+            }
+
+            if (DEBUG_BUBBLE_STACK_VIEW) {
+                Log.d(TAG, "animateCollapse");
+                Log.d(TAG, BubbleDebugConfig.formatBubblesString(getBubblesOnScreen(),
+                        mExpandedBubble));
+            }
+            updateOverflowVisibility();
+            updateZOrder();
+            updateBadges(true /* setBadgeForCollapsedStack */);
+            afterExpandedViewAnimation();
+            if (previouslySelected != null) {
+                previouslySelected.setTaskViewVisibility(false);
+            }
+            mExpandedViewAnimationController.reset();
+        };
+        mExpandedViewAnimationController.animateCollapse(collapseBackToStack, after);
+        if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) {
+            // When the animation completes, we should no longer be showing the content.
+            // This won't actually update content visibility immediately, if we are currently
+            // animating. But updates the internal state for the content to be hidden after
+            // animation completes.
+            mExpandedBubble.getExpandedView().setContentVisibility(false);
+        }
+    }
+
     private void animateSwitchBubbles() {
         // If we're no longer expanded, this is meaningless.
         if (!mIsExpanded) {
@@ -2278,7 +2460,7 @@
 
         mExpandedViewContainer.setAnimationMatrix(mExpandedViewContainerMatrix);
 
-        mDelayedAnimationExecutor.executeDelayed(() -> {
+        mMainExecutor.executeDelayed(() -> {
             if (!mIsExpanded) {
                 mIsBubbleSwitchAnimating = false;
                 return;
@@ -2309,7 +2491,7 @@
      * animating flags for those animations.
      */
     private void cancelDelayedExpandCollapseSwitchAnimations() {
-        mDelayedAnimationExecutor.removeCallbacks(mDelayedAnimation);
+        mMainExecutor.removeCallbacks(mDelayedAnimation);
 
         mIsExpansionAnimating = false;
         mIsBubbleSwitchAnimating = false;
@@ -2333,9 +2515,18 @@
     /**
      * Updates the stack based for IME changes. When collapsed it'll move the stack if it
      * overlaps where they IME would be. When expanded it'll shift the expanded bubbles
-     * if they might overlap with the IME (this only happens for large screens).
+     * if they might overlap with the IME (this only happens for large screens)
+     * and clip the expanded view.
      */
-    public void animateForIme(boolean visible) {
+    public void setImeVisible(boolean visible) {
+        if (HOME_GESTURE_ENABLED) {
+            setImeVisibleInternal(visible);
+        } else {
+            setImeVisibleWithoutClipping(visible);
+        }
+    }
+
+    private void setImeVisibleWithoutClipping(boolean visible) {
         if ((mIsExpansionAnimating || mIsBubbleSwitchAnimating) && mIsExpanded) {
             // This will update the animation so the bubbles move to position for the IME
             mExpandedAnimationController.expandFromStack(() -> {
@@ -2386,6 +2577,62 @@
         }
     }
 
+    private void setImeVisibleInternal(boolean visible) {
+        if ((mIsExpansionAnimating || mIsBubbleSwitchAnimating) && mIsExpanded) {
+            // This will update the animation so the bubbles move to position for the IME
+            mExpandedAnimationController.expandFromStack(() -> {
+                updatePointerPosition(false /* forIme */);
+                afterExpandedViewAnimation();
+                mExpandedViewAnimationController.animateForImeVisibilityChange(visible);
+            } /* after */);
+            return;
+        }
+
+        if (!mIsExpanded && getBubbleCount() > 0) {
+            final float stackDestinationY =
+                    mStackAnimationController.animateForImeVisibility(visible);
+
+            // How far the stack is animating due to IME, we'll just animate the flyout by that
+            // much too.
+            final float stackDy =
+                    stackDestinationY - mStackAnimationController.getStackPosition().y;
+
+            // If the flyout is visible, translate it along with the bubble stack.
+            if (mFlyout.getVisibility() == VISIBLE) {
+                PhysicsAnimator.getInstance(mFlyout)
+                        .spring(DynamicAnimation.TRANSLATION_Y,
+                                mFlyout.getTranslationY() + stackDy,
+                                FLYOUT_IME_ANIMATION_SPRING_CONFIG)
+                        .start();
+            }
+        }
+
+        if (mIsExpanded) {
+            mExpandedViewAnimationController.animateForImeVisibilityChange(visible);
+            if (mPositioner.showBubblesVertically()
+                    && mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) {
+                float selectedY = mPositioner.getExpandedBubbleXY(getState().selectedIndex,
+                        getState()).y;
+                float newExpandedViewTop = mPositioner.getExpandedViewY(mExpandedBubble, selectedY);
+                mExpandedBubble.getExpandedView().setImeVisible(visible);
+                if (!mExpandedBubble.getExpandedView().isUsingMaxHeight()) {
+                    mExpandedViewContainer.animate().translationY(newExpandedViewTop);
+                }
+                List<Animator> animList = new ArrayList();
+                for (int i = 0; i < mBubbleContainer.getChildCount(); i++) {
+                    View child = mBubbleContainer.getChildAt(i);
+                    float transY = mPositioner.getExpandedBubbleXY(i, getState()).y;
+                    ObjectAnimator anim = ObjectAnimator.ofFloat(child, TRANSLATION_Y, transY);
+                    animList.add(anim);
+                }
+                updatePointerPosition(true /* forIme */);
+                AnimatorSet set = new AnimatorSet();
+                set.playTogether(animList);
+                set.start();
+            }
+        }
+    }
+
     @Override
     public boolean dispatchTouchEvent(MotionEvent ev) {
         if (ev.getAction() != MotionEvent.ACTION_DOWN && ev.getActionIndex() != mPointerIndexDown) {
@@ -2821,7 +3068,7 @@
                 && mExpandedBubble.getExpandedView() != null) {
             BubbleExpandedView bev = mExpandedBubble.getExpandedView();
             bev.setContentVisibility(false);
-            bev.setAlphaAnimating(!mIsExpansionAnimating);
+            bev.setAnimating(!mIsExpansionAnimating);
             mExpandedViewContainerMatrix.setScaleX(0f);
             mExpandedViewContainerMatrix.setScaleY(0f);
             mExpandedViewContainerMatrix.setTranslate(0f, 0f);
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblesNavBarGestureTracker.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblesNavBarGestureTracker.java
new file mode 100644
index 0000000..e7beeeb
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblesNavBarGestureTracker.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.wm.shell.bubbles;
+
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_BUBBLES;
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME;
+
+import android.content.Context;
+import android.hardware.input.InputManager;
+import android.util.Log;
+import android.view.Choreographer;
+import android.view.InputChannel;
+import android.view.InputEventReceiver;
+import android.view.InputMonitor;
+
+import androidx.annotation.Nullable;
+
+import com.android.wm.shell.bubbles.BubblesNavBarMotionEventHandler.MotionEventListener;
+
+/**
+ * Set up tracking bubbles gestures that begin in navigation bar
+ */
+class BubblesNavBarGestureTracker {
+
+    private static final String TAG = TAG_WITH_CLASS_NAME ? "BubblesGestureTracker" : TAG_BUBBLES;
+
+    private static final String GESTURE_MONITOR = "bubbles-gesture";
+    private final Context mContext;
+    private final BubblePositioner mPositioner;
+
+    @Nullable
+    private InputMonitor mInputMonitor;
+    @Nullable
+    private InputEventReceiver mInputEventReceiver;
+
+    BubblesNavBarGestureTracker(Context context, BubblePositioner positioner) {
+        mContext = context;
+        mPositioner = positioner;
+    }
+
+    /**
+     * Start tracking gestures
+     *
+     * @param listener listener that is notified of touch events
+     */
+    void start(MotionEventListener listener) {
+        if (BubbleDebugConfig.DEBUG_BUBBLE_GESTURE) {
+            Log.d(TAG, "start monitoring bubbles swipe up gesture");
+        }
+
+        stopInternal();
+
+        mInputMonitor = InputManager.getInstance().monitorGestureInput(GESTURE_MONITOR,
+                mContext.getDisplayId());
+        InputChannel inputChannel = mInputMonitor.getInputChannel();
+
+        BubblesNavBarMotionEventHandler motionEventHandler =
+                new BubblesNavBarMotionEventHandler(mContext, mPositioner,
+                        this::onInterceptTouch, listener);
+        mInputEventReceiver = new BubblesNavBarInputEventReceiver(inputChannel,
+                Choreographer.getInstance(), motionEventHandler);
+    }
+
+    void stop() {
+        if (BubbleDebugConfig.DEBUG_BUBBLE_GESTURE) {
+            Log.d(TAG, "stop monitoring bubbles swipe up gesture");
+        }
+        stopInternal();
+    }
+
+    private void stopInternal() {
+        if (mInputEventReceiver != null) {
+            mInputEventReceiver.dispose();
+            mInputEventReceiver = null;
+        }
+        if (mInputMonitor != null) {
+            mInputMonitor.dispose();
+            mInputMonitor = null;
+        }
+    }
+
+    private void onInterceptTouch() {
+        if (BubbleDebugConfig.DEBUG_BUBBLE_GESTURE) {
+            Log.d(TAG, "intercept touch event");
+        }
+        if (mInputMonitor != null) {
+            mInputMonitor.pilferPointers();
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblesNavBarInputEventReceiver.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblesNavBarInputEventReceiver.java
new file mode 100644
index 0000000..45037b8
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblesNavBarInputEventReceiver.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.wm.shell.bubbles;
+
+import android.os.Looper;
+import android.view.BatchedInputEventReceiver;
+import android.view.Choreographer;
+import android.view.InputChannel;
+import android.view.InputEvent;
+import android.view.MotionEvent;
+
+/**
+ * Bubbles {@link BatchedInputEventReceiver} for monitoring touches from navbar gesture area
+ */
+class BubblesNavBarInputEventReceiver extends BatchedInputEventReceiver {
+
+    private final BubblesNavBarMotionEventHandler mMotionEventHandler;
+
+    BubblesNavBarInputEventReceiver(InputChannel inputChannel,
+            Choreographer choreographer, BubblesNavBarMotionEventHandler motionEventHandler) {
+        super(inputChannel, Looper.myLooper(), choreographer);
+        mMotionEventHandler = motionEventHandler;
+    }
+
+    @Override
+    public void onInputEvent(InputEvent event) {
+        boolean handled = false;
+        try {
+            if (!(event instanceof MotionEvent)) {
+                return;
+            }
+            handled = mMotionEventHandler.onMotionEvent((MotionEvent) event);
+        } finally {
+            finishInputEvent(event, handled);
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblesNavBarMotionEventHandler.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblesNavBarMotionEventHandler.java
new file mode 100644
index 0000000..844526ca
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblesNavBarMotionEventHandler.java
@@ -0,0 +1,175 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.wm.shell.bubbles;
+
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.DEBUG_BUBBLE_GESTURE;
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_BUBBLES;
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME;
+
+import android.content.Context;
+import android.graphics.PointF;
+import android.util.Log;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+import android.view.ViewConfiguration;
+
+import androidx.annotation.Nullable;
+
+/**
+ * Handles {@link MotionEvent}s for bubbles that begin in the nav bar area
+ */
+class BubblesNavBarMotionEventHandler {
+    private static final String TAG =
+            TAG_WITH_CLASS_NAME ? "BubblesNavBarMotionEventHandler" : TAG_BUBBLES;
+    private static final int VELOCITY_UNITS = 1000;
+
+    private final Runnable mOnInterceptTouch;
+    private final MotionEventListener mMotionEventListener;
+    private final int mTouchSlop;
+    private final BubblePositioner mPositioner;
+    private final PointF mTouchDown = new PointF();
+    private boolean mTrackingTouches;
+    private boolean mInterceptingTouches;
+    @Nullable
+    private VelocityTracker mVelocityTracker;
+
+    BubblesNavBarMotionEventHandler(Context context, BubblePositioner positioner,
+            Runnable onInterceptTouch, MotionEventListener motionEventListener) {
+        mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
+        mPositioner = positioner;
+        mOnInterceptTouch = onInterceptTouch;
+        mMotionEventListener = motionEventListener;
+    }
+
+    /**
+     * Handle {@link MotionEvent} and forward it to {@code motionEventListener} defined in
+     * constructor
+     *
+     * @return {@code true} if this {@link MotionEvent} is handled (it started in the gesture area)
+     */
+    public boolean onMotionEvent(MotionEvent motionEvent) {
+        float dx = motionEvent.getX() - mTouchDown.x;
+        float dy = motionEvent.getY() - mTouchDown.y;
+
+        switch (motionEvent.getAction()) {
+            case MotionEvent.ACTION_DOWN:
+                if (isInGestureRegion(motionEvent)) {
+                    mTouchDown.set(motionEvent.getX(), motionEvent.getY());
+                    mMotionEventListener.onDown(motionEvent.getX(), motionEvent.getY());
+                    mTrackingTouches = true;
+                    return true;
+                }
+                break;
+            case MotionEvent.ACTION_MOVE:
+                if (mTrackingTouches) {
+                    if (!mInterceptingTouches && Math.hypot(dx, dy) > mTouchSlop) {
+                        mInterceptingTouches = true;
+                        mOnInterceptTouch.run();
+                    }
+                    if (mInterceptingTouches) {
+                        getVelocityTracker().addMovement(motionEvent);
+                        mMotionEventListener.onMove(dx, dy);
+                    }
+                    return true;
+                }
+                break;
+            case MotionEvent.ACTION_CANCEL:
+                if (mTrackingTouches) {
+                    mMotionEventListener.onCancel();
+                    finishTracking();
+                    return true;
+                }
+                break;
+            case MotionEvent.ACTION_UP:
+                if (mTrackingTouches) {
+                    if (mInterceptingTouches) {
+                        getVelocityTracker().computeCurrentVelocity(VELOCITY_UNITS);
+                        mMotionEventListener.onUp(getVelocityTracker().getXVelocity(),
+                                getVelocityTracker().getYVelocity());
+                    }
+                    finishTracking();
+                    return true;
+                }
+                break;
+        }
+        return false;
+    }
+
+    private boolean isInGestureRegion(MotionEvent ev) {
+        // Only handles touch events beginning in navigation bar system gesture zone
+        if (mPositioner.getNavBarGestureZone().contains((int) ev.getX(), (int) ev.getY())) {
+            if (DEBUG_BUBBLE_GESTURE) {
+                Log.d(TAG, "handling touch y=" + ev.getY()
+                        + " navBarGestureZone=" + mPositioner.getNavBarGestureZone());
+            }
+            return true;
+        }
+        return false;
+    }
+
+    private VelocityTracker getVelocityTracker() {
+        if (mVelocityTracker == null) {
+            mVelocityTracker = VelocityTracker.obtain();
+        }
+        return mVelocityTracker;
+    }
+
+    private void finishTracking() {
+        mTouchDown.set(0, 0);
+        mTrackingTouches = false;
+        mInterceptingTouches = false;
+        if (mVelocityTracker != null) {
+            mVelocityTracker.recycle();
+            mVelocityTracker = null;
+        }
+    }
+
+    /**
+     * Callback for receiving {@link MotionEvent} updates
+     */
+    interface MotionEventListener {
+        /**
+         * Touch down action.
+         *
+         * @param x x coordinate
+         * @param y y coordinate
+         */
+        void onDown(float x, float y);
+
+        /**
+         * Move action.
+         * Reports distance from point reported in {@link #onDown(float, float)}
+         *
+         * @param dx distance moved on x-axis from starting point, in pixels
+         * @param dy distance moved on y-axis from starting point, in pixels
+         */
+        void onMove(float dx, float dy);
+
+        /**
+         * Touch up action.
+         *
+         * @param velX velocity of the move action on x axis
+         * @param velY velocity of the move actin on y axis
+         */
+        void onUp(float velX, float velY);
+
+        /**
+         * Motion action was cancelled.
+         */
+        void onCancel();
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/RelativeTouchListener.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/RelativeTouchListener.kt
index cf0cefe..ea9d065 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/RelativeTouchListener.kt
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/RelativeTouchListener.kt
@@ -17,8 +17,6 @@
 package com.android.wm.shell.bubbles
 
 import android.graphics.PointF
-import android.os.Handler
-import android.os.Looper
 import android.view.MotionEvent
 import android.view.VelocityTracker
 import android.view.View
@@ -146,6 +144,12 @@
                 velocityTracker.clear()
                 movedEnough = false
             }
+
+            MotionEvent.ACTION_CANCEL -> {
+                v.handler.removeCallbacksAndMessages(null)
+                velocityTracker.clear()
+                movedEnough = false
+            }
         }
 
         return true
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationController.java
index 573f424..c24efb9 100644
--- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationController.java
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/ExpandedAnimationController.java
@@ -17,11 +17,13 @@
 package com.android.wm.shell.bubbles.animation;
 
 import static com.android.wm.shell.bubbles.BubblePositioner.NUM_VISIBLE_WHEN_RESTING;
+import static com.android.wm.shell.bubbles.BubbleStackView.HOME_GESTURE_ENABLED;
 
 import android.content.res.Resources;
 import android.graphics.Path;
 import android.graphics.PointF;
 import android.view.View;
+import android.view.animation.Interpolator;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
@@ -61,7 +63,10 @@
     private static final float DAMPING_RATIO_MEDIUM_LOW_BOUNCY = 0.65f;
 
     /** Stiffness for the expand/collapse path-following animation. */
-    private static final int EXPAND_COLLAPSE_ANIM_STIFFNESS = 1000;
+    private static final int EXPAND_COLLAPSE_ANIM_STIFFNESS = 400;
+
+    /** Stiffness for the expand/collapse animation when home gesture handling is off */
+    private static final int EXPAND_COLLAPSE_ANIM_STIFFNESS_WITHOUT_HOME_GESTURE = 1000;
 
     /**
      * Velocity required to dismiss an individual bubble without dragging it into the dismiss
@@ -73,6 +78,11 @@
             new PhysicsAnimator.SpringConfig(
                     EXPAND_COLLAPSE_ANIM_STIFFNESS, SpringForce.DAMPING_RATIO_NO_BOUNCY);
 
+    private final PhysicsAnimator.SpringConfig mAnimateOutSpringConfigWithoutHomeGesture =
+            new PhysicsAnimator.SpringConfig(
+                    EXPAND_COLLAPSE_ANIM_STIFFNESS_WITHOUT_HOME_GESTURE,
+                    SpringForce.DAMPING_RATIO_NO_BOUNCY);
+
     /** Horizontal offset between bubbles, which we need to know to re-stack them. */
     private float mStackOffsetPx;
     /** Size of each bubble. */
@@ -278,11 +288,20 @@
                     (firstBubbleLeads && index == 0)
                             || (!firstBubbleLeads && index == mLayout.getChildCount() - 1);
 
+            Interpolator interpolator;
+            if (HOME_GESTURE_ENABLED) {
+                // When home gesture is enabled, we use a different animation timing for collapse
+                interpolator = expanding
+                        ? Interpolators.EMPHASIZED_ACCELERATE : Interpolators.EMPHASIZED_DECELERATE;
+            } else {
+                interpolator = Interpolators.LINEAR;
+            }
+
             animation
                     .followAnimatedTargetAlongPath(
                             path,
                             EXPAND_COLLAPSE_TARGET_ANIM_DURATION /* targetAnimDuration */,
-                            Interpolators.LINEAR /* targetAnimInterpolator */,
+                            interpolator /* targetAnimInterpolator */,
                             isLeadBubble ? mLeadBubbleEndAction : null /* endAction */,
                             () -> mLeadBubbleEndAction = null /* endAction */)
                     .withStartDelay(startDelay)
@@ -525,10 +544,16 @@
             finishRemoval.run();
             mOnBubbleAnimatedOutAction.run();
         } else {
+            PhysicsAnimator.SpringConfig springConfig;
+            if (HOME_GESTURE_ENABLED) {
+                springConfig = mAnimateOutSpringConfig;
+            } else {
+                springConfig = mAnimateOutSpringConfigWithoutHomeGesture;
+            }
             PhysicsAnimator.getInstance(child)
                     .spring(DynamicAnimation.ALPHA, 0f)
-                    .spring(DynamicAnimation.SCALE_X, 0f, mAnimateOutSpringConfig)
-                    .spring(DynamicAnimation.SCALE_Y, 0f, mAnimateOutSpringConfig)
+                    .spring(DynamicAnimation.SCALE_X, 0f, springConfig)
+                    .spring(DynamicAnimation.SCALE_Y, 0f, springConfig)
                     .withEndActions(finishRemoval, mOnBubbleAnimatedOutAction)
                     .start();
         }
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/ExpandedViewAnimationController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/ExpandedViewAnimationController.java
new file mode 100644
index 0000000..8a33780
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/ExpandedViewAnimationController.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.wm.shell.bubbles.animation;
+
+import com.android.wm.shell.bubbles.BubbleExpandedView;
+
+/**
+ * Animation controller for bubble expanded view collapsing
+ */
+public interface ExpandedViewAnimationController {
+    /**
+     * Set expanded view that this controller is working with.
+     */
+    void setExpandedView(BubbleExpandedView expandedView);
+
+    /**
+     * Set current collapse value, in pixels.
+     *
+     * @param distance pixels that user dragged the view by
+     */
+    void updateDrag(float distance);
+
+    /**
+     * Set current swipe velocity.
+     * Velocity is directional:
+     * <ul>
+     *     <li>velocity < 0 means swipe direction is up</li>
+     *     <li>velocity > 0 means swipe direction is down</li>
+     * </ul>
+     */
+    void setSwipeVelocity(float velocity);
+
+    /**
+     * Check if view is dragged past collapse threshold or swipe up velocity exceeds min velocity
+     * required to collapse the view
+     */
+    boolean shouldCollapse();
+
+    /**
+     * Animate view to collapsed state
+     *
+     * @param startStackCollapse runnable that is triggered when bubbles can start moving back to
+     *                           their collapsed location
+     * @param after              runnable to run after animation is complete
+     */
+    void animateCollapse(Runnable startStackCollapse, Runnable after);
+
+    /**
+     * Animate the view back to fully expanded state.
+     */
+    void animateBackToExpanded();
+
+    /**
+     * Animate view for IME visibility change
+     */
+    void animateForImeVisibilityChange(boolean visible);
+
+    /**
+     * Reset the view to fully expanded state
+     */
+    void reset();
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/ExpandedViewAnimationControllerImpl.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/ExpandedViewAnimationControllerImpl.java
new file mode 100644
index 0000000..ca54232
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/ExpandedViewAnimationControllerImpl.java
@@ -0,0 +1,432 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.wm.shell.bubbles.animation;
+
+import static android.view.View.ALPHA;
+
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.DEBUG_COLLAPSE_ANIMATOR;
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.DEBUG_EXPANDED_VIEW_DRAGGING;
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_BUBBLES;
+import static com.android.wm.shell.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME;
+import static com.android.wm.shell.bubbles.BubbleExpandedView.BOTTOM_CLIP_PROPERTY;
+import static com.android.wm.shell.bubbles.BubbleExpandedView.CONTENT_ALPHA;
+import static com.android.wm.shell.bubbles.BubbleExpandedView.MANAGE_BUTTON_ALPHA;
+
+import android.animation.Animator;
+import android.animation.AnimatorListenerAdapter;
+import android.animation.AnimatorSet;
+import android.animation.ObjectAnimator;
+import android.animation.ValueAnimator;
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.util.Log;
+import android.view.HapticFeedbackConstants;
+import android.view.ViewConfiguration;
+
+import androidx.annotation.Nullable;
+import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.dynamicanimation.animation.FloatPropertyCompat;
+import androidx.dynamicanimation.animation.SpringAnimation;
+import androidx.dynamicanimation.animation.SpringForce;
+
+import com.android.wm.shell.animation.FlingAnimationUtils;
+import com.android.wm.shell.animation.Interpolators;
+import com.android.wm.shell.bubbles.BubbleExpandedView;
+import com.android.wm.shell.bubbles.BubblePositioner;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Implementation of {@link ExpandedViewAnimationController} that uses a collapse animation to
+ * hide the {@link BubbleExpandedView}
+ */
+public class ExpandedViewAnimationControllerImpl implements ExpandedViewAnimationController {
+
+    private static final String TAG = TAG_WITH_CLASS_NAME ? "ExpandedViewAnimCtrl" : TAG_BUBBLES;
+
+    private static final float COLLAPSE_THRESHOLD = 0.02f;
+
+    private static final int COLLAPSE_DURATION_MS = 250;
+
+    private static final int MANAGE_BUTTON_ANIM_DURATION_MS = 78;
+
+    private static final int CONTENT_OPACITY_ANIM_DELAY_MS = 93;
+    private static final int CONTENT_OPACITY_ANIM_DURATION_MS = 78;
+
+    private static final int BACKGROUND_OPACITY_ANIM_DELAY_MS = 172;
+    private static final int BACKGROUND_OPACITY_ANIM_DURATION_MS = 78;
+
+    /** Animation fraction threshold for content alpha animation when stack collapse should begin */
+    private static final float STACK_COLLAPSE_THRESHOLD = 0.5f;
+
+    private static final FloatPropertyCompat<ExpandedViewAnimationControllerImpl>
+            COLLAPSE_HEIGHT_PROPERTY =
+            new FloatPropertyCompat<ExpandedViewAnimationControllerImpl>("CollapseSpring") {
+                @Override
+                public float getValue(ExpandedViewAnimationControllerImpl controller) {
+                    return controller.getCollapsedAmount();
+                }
+
+                @Override
+                public void setValue(ExpandedViewAnimationControllerImpl controller,
+                        float value) {
+                    controller.setCollapsedAmount(value);
+                }
+            };
+
+    private final int mMinFlingVelocity;
+    private float mSwipeUpVelocity;
+    private float mSwipeDownVelocity;
+    private final BubblePositioner mPositioner;
+    private final FlingAnimationUtils mFlingAnimationUtils;
+    private int mDraggedAmount;
+    private float mCollapsedAmount;
+    @Nullable
+    private BubbleExpandedView mExpandedView;
+    @Nullable
+    private AnimatorSet mCollapseAnimation;
+    private boolean mNotifiedAboutThreshold;
+    private SpringAnimation mBackToExpandedAnimation;
+    @Nullable
+    private ObjectAnimator mBottomClipAnim;
+
+    public ExpandedViewAnimationControllerImpl(Context context, BubblePositioner positioner) {
+        mFlingAnimationUtils = new FlingAnimationUtils(context.getResources().getDisplayMetrics(),
+                COLLAPSE_DURATION_MS / 1000f);
+        mMinFlingVelocity = ViewConfiguration.get(context).getScaledMinimumFlingVelocity();
+        mPositioner = positioner;
+    }
+
+    private static void adjustAnimatorSetDuration(AnimatorSet animatorSet,
+            float durationAdjustment) {
+        for (Animator animator : animatorSet.getChildAnimations()) {
+            animator.setStartDelay((long) (animator.getStartDelay() * durationAdjustment));
+            animator.setDuration((long) (animator.getDuration() * durationAdjustment));
+        }
+    }
+
+    @Override
+    public void setExpandedView(BubbleExpandedView expandedView) {
+        if (mExpandedView != null) {
+            if (DEBUG_COLLAPSE_ANIMATOR) {
+                Log.d(TAG, "updating expandedView, resetting previous");
+            }
+            if (mCollapseAnimation != null) {
+                mCollapseAnimation.cancel();
+            }
+            if (mBackToExpandedAnimation != null) {
+                mBackToExpandedAnimation.cancel();
+            }
+            reset();
+        }
+        mExpandedView = expandedView;
+    }
+
+    @Override
+    public void updateDrag(float distance) {
+        if (mExpandedView != null) {
+            mDraggedAmount = OverScroll.dampedScroll(distance, mExpandedView.getContentHeight());
+
+            if (DEBUG_COLLAPSE_ANIMATOR && DEBUG_EXPANDED_VIEW_DRAGGING) {
+                Log.d(TAG, "updateDrag: distance=" + distance + " dragged=" + mDraggedAmount);
+            }
+
+            setCollapsedAmount(mDraggedAmount);
+
+            if (!mNotifiedAboutThreshold && isPastCollapseThreshold()) {
+                mNotifiedAboutThreshold = true;
+                if (DEBUG_COLLAPSE_ANIMATOR) {
+                    Log.d(TAG, "notifying over collapse threshold");
+                }
+                vibrateIfEnabled();
+            }
+        }
+    }
+
+    @Override
+    public void setSwipeVelocity(float velocity) {
+        if (velocity < 0) {
+            mSwipeUpVelocity = Math.abs(velocity);
+            mSwipeDownVelocity = 0;
+        } else {
+            mSwipeUpVelocity = 0;
+            mSwipeDownVelocity = velocity;
+        }
+    }
+
+    @Override
+    public boolean shouldCollapse() {
+        if (mSwipeDownVelocity > mMinFlingVelocity) {
+            // Swipe velocity is positive and over fling velocity.
+            // This is a swipe down, always reset to expanded state, regardless of dragged amount.
+            if (DEBUG_COLLAPSE_ANIMATOR) {
+                Log.d(TAG,
+                        "not collapsing expanded view, swipe down velocity: " + mSwipeDownVelocity
+                                + " minV: " + mMinFlingVelocity);
+            }
+            return false;
+        }
+
+        if (mSwipeUpVelocity > mMinFlingVelocity) {
+            // Swiping up and over fling velocity, collapse the view.
+            if (DEBUG_COLLAPSE_ANIMATOR) {
+                Log.d(TAG,
+                        "collapse expanded view, swipe up velocity: " + mSwipeUpVelocity + " minV: "
+                                + mMinFlingVelocity);
+            }
+            return true;
+        }
+
+        if (isPastCollapseThreshold()) {
+            if (DEBUG_COLLAPSE_ANIMATOR) {
+                Log.d(TAG, "collapse expanded view, past threshold, dragged: " + mDraggedAmount);
+            }
+            return true;
+        }
+
+        if (DEBUG_COLLAPSE_ANIMATOR) {
+            Log.d(TAG, "not collapsing expanded view");
+        }
+
+        return false;
+    }
+
+    @Override
+    public void animateCollapse(Runnable startStackCollapse, Runnable after) {
+        if (DEBUG_COLLAPSE_ANIMATOR) {
+            Log.d(TAG,
+                    "expandedView animate collapse swipeVel=" + mSwipeUpVelocity + " minFlingVel="
+                            + mMinFlingVelocity);
+        }
+        if (mExpandedView != null) {
+            // Mark it as animating immediately to avoid updates to the view before animation starts
+            mExpandedView.setAnimating(true);
+
+            if (mCollapseAnimation != null) {
+                mCollapseAnimation.cancel();
+            }
+            mCollapseAnimation = createCollapseAnimation(mExpandedView, startStackCollapse, after);
+
+            if (mSwipeUpVelocity >= mMinFlingVelocity) {
+                int contentHeight = mExpandedView.getContentHeight();
+
+                // Use a temp animator to get adjusted duration value for swipe.
+                // This new value will be used to adjust animation times proportionally in the
+                // animator set. If we adjust animator set duration directly, all child animations
+                // will get the same animation time.
+                ValueAnimator tempAnimator = new ValueAnimator();
+                mFlingAnimationUtils.applyDismissing(tempAnimator, mCollapsedAmount, contentHeight,
+                        mSwipeUpVelocity, (contentHeight - mCollapsedAmount));
+
+                float durationAdjustment =
+                        (float) tempAnimator.getDuration() / COLLAPSE_DURATION_MS;
+
+                adjustAnimatorSetDuration(mCollapseAnimation, durationAdjustment);
+                mCollapseAnimation.setInterpolator(tempAnimator.getInterpolator());
+            }
+            mCollapseAnimation.start();
+        }
+    }
+
+    @Override
+    public void animateBackToExpanded() {
+        if (DEBUG_COLLAPSE_ANIMATOR) {
+            Log.d(TAG, "expandedView animate back to expanded");
+        }
+        BubbleExpandedView expandedView = mExpandedView;
+        if (expandedView == null) {
+            return;
+        }
+
+        expandedView.setAnimating(true);
+
+        mBackToExpandedAnimation = new SpringAnimation(this, COLLAPSE_HEIGHT_PROPERTY);
+        mBackToExpandedAnimation.setSpring(new SpringForce()
+                .setStiffness(SpringForce.STIFFNESS_LOW)
+                .setDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY)
+        );
+        mBackToExpandedAnimation.addEndListener(new OneTimeEndListener() {
+            @Override
+            public void onAnimationEnd(DynamicAnimation animation, boolean canceled, float value,
+                    float velocity) {
+                super.onAnimationEnd(animation, canceled, value, velocity);
+                mNotifiedAboutThreshold = false;
+                mBackToExpandedAnimation = null;
+                expandedView.setAnimating(false);
+            }
+        });
+        mBackToExpandedAnimation.setStartValue(mCollapsedAmount);
+        mBackToExpandedAnimation.animateToFinalPosition(0);
+    }
+
+    @Override
+    public void animateForImeVisibilityChange(boolean visible) {
+        if (mExpandedView != null) {
+            if (mBottomClipAnim != null) {
+                mBottomClipAnim.cancel();
+            }
+            int clip = 0;
+            if (visible) {
+                // Clip the expanded view at the top of the IME view
+                clip = mExpandedView.getContentBottomOnScreen() - mPositioner.getImeTop();
+                // Don't allow negative clip value
+                clip = Math.max(clip, 0);
+            }
+            mBottomClipAnim = ObjectAnimator.ofInt(mExpandedView, BOTTOM_CLIP_PROPERTY, clip);
+            mBottomClipAnim.addListener(new AnimatorListenerAdapter() {
+                @Override
+                public void onAnimationEnd(Animator animation) {
+                    mBottomClipAnim = null;
+                }
+            });
+            mBottomClipAnim.start();
+        }
+    }
+
+    @Override
+    public void reset() {
+        if (DEBUG_COLLAPSE_ANIMATOR) {
+            Log.d(TAG, "reset expandedView collapsed state");
+        }
+        if (mExpandedView == null) {
+            return;
+        }
+        mExpandedView.setAnimating(false);
+
+        if (mCollapseAnimation != null) {
+            mCollapseAnimation.cancel();
+        }
+        if (mBackToExpandedAnimation != null) {
+            mBackToExpandedAnimation.cancel();
+        }
+        mExpandedView.setContentAlpha(1);
+        mExpandedView.setAlpha(1);
+        mExpandedView.setManageButtonAlpha(1);
+        setCollapsedAmount(0);
+        mExpandedView.setBottomClip(0);
+        mExpandedView.movePointerBy(0, 0);
+        mCollapsedAmount = 0;
+        mDraggedAmount = 0;
+        mSwipeUpVelocity = 0;
+        mSwipeDownVelocity = 0;
+        mNotifiedAboutThreshold = false;
+    }
+
+    private float getCollapsedAmount() {
+        return mCollapsedAmount;
+    }
+
+    private void setCollapsedAmount(float collapsed) {
+        if (mCollapsedAmount != collapsed) {
+            float previous = mCollapsedAmount;
+            mCollapsedAmount = collapsed;
+
+            if (mExpandedView != null) {
+                if (previous == 0) {
+                    // View was not collapsed before. Apply z order change
+                    mExpandedView.setSurfaceZOrderedOnTop(true);
+                    mExpandedView.setAnimating(true);
+                }
+
+                mExpandedView.setTopClip((int) mCollapsedAmount);
+                // Move up with translationY. Use negative collapsed value
+                mExpandedView.setContentTranslationY(-mCollapsedAmount);
+                mExpandedView.setManageButtonTranslationY(-mCollapsedAmount);
+
+                if (mCollapsedAmount == 0) {
+                    // View is no longer collapsed. Revert z order change
+                    mExpandedView.setSurfaceZOrderedOnTop(false);
+                    mExpandedView.setAnimating(false);
+                }
+            }
+        }
+    }
+
+    private boolean isPastCollapseThreshold() {
+        if (mExpandedView != null) {
+            return mDraggedAmount > mExpandedView.getContentHeight() * COLLAPSE_THRESHOLD;
+        }
+        return false;
+    }
+
+    private AnimatorSet createCollapseAnimation(BubbleExpandedView expandedView,
+            Runnable startStackCollapse, Runnable after) {
+        List<Animator> animatorList = new ArrayList<>();
+        animatorList.add(createHeightAnimation(expandedView));
+        animatorList.add(createManageButtonAnimation());
+        ObjectAnimator contentAlphaAnimation = createContentAlphaAnimation();
+        final boolean[] notified = {false};
+        contentAlphaAnimation.addUpdateListener(animation -> {
+            if (!notified[0] && animation.getAnimatedFraction() > STACK_COLLAPSE_THRESHOLD) {
+                notified[0] = true;
+                // Notify bubbles that they can start moving back to the collapsed position
+                startStackCollapse.run();
+            }
+        });
+        animatorList.add(contentAlphaAnimation);
+        animatorList.add(createBackgroundAlphaAnimation());
+
+        AnimatorSet animatorSet = new AnimatorSet();
+        animatorSet.addListener(new AnimatorListenerAdapter() {
+            @Override
+            public void onAnimationEnd(Animator animation) {
+                after.run();
+            }
+        });
+        animatorSet.playTogether(animatorList);
+        return animatorSet;
+    }
+
+    private ValueAnimator createHeightAnimation(BubbleExpandedView expandedView) {
+        ValueAnimator animator = ValueAnimator.ofInt((int) mCollapsedAmount,
+                expandedView.getContentHeight());
+        animator.setInterpolator(Interpolators.EMPHASIZED_ACCELERATE);
+        animator.setDuration(COLLAPSE_DURATION_MS);
+        animator.addUpdateListener(anim -> setCollapsedAmount((int) anim.getAnimatedValue()));
+        return animator;
+    }
+
+    private ObjectAnimator createManageButtonAnimation() {
+        ObjectAnimator animator = ObjectAnimator.ofFloat(mExpandedView, MANAGE_BUTTON_ALPHA, 0f);
+        animator.setDuration(MANAGE_BUTTON_ANIM_DURATION_MS);
+        animator.setInterpolator(Interpolators.LINEAR);
+        return animator;
+    }
+
+    private ObjectAnimator createContentAlphaAnimation() {
+        ObjectAnimator animator = ObjectAnimator.ofFloat(mExpandedView, CONTENT_ALPHA, 0f);
+        animator.setDuration(CONTENT_OPACITY_ANIM_DURATION_MS);
+        animator.setInterpolator(Interpolators.LINEAR);
+        animator.setStartDelay(CONTENT_OPACITY_ANIM_DELAY_MS);
+        return animator;
+    }
+
+    private ObjectAnimator createBackgroundAlphaAnimation() {
+        ObjectAnimator animator = ObjectAnimator.ofFloat(mExpandedView, ALPHA, 0f);
+        animator.setDuration(BACKGROUND_OPACITY_ANIM_DURATION_MS);
+        animator.setInterpolator(Interpolators.LINEAR);
+        animator.setStartDelay(BACKGROUND_OPACITY_ANIM_DELAY_MS);
+        return animator;
+    }
+
+    @SuppressLint("MissingPermission")
+    private void vibrateIfEnabled() {
+        if (mExpandedView != null) {
+            mExpandedView.performHapticFeedback(HapticFeedbackConstants.DRAG_CROSSING);
+        }
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/ExpandedViewAnimationControllerStub.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/ExpandedViewAnimationControllerStub.java
new file mode 100644
index 0000000..bb8a3aa
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/ExpandedViewAnimationControllerStub.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.wm.shell.bubbles.animation;
+
+import com.android.wm.shell.bubbles.BubbleExpandedView;
+
+/**
+ * Stub implementation {@link ExpandedViewAnimationController} that does not animate the
+ * {@link BubbleExpandedView}
+ */
+public class ExpandedViewAnimationControllerStub implements ExpandedViewAnimationController {
+    @Override
+    public void setExpandedView(BubbleExpandedView expandedView) {
+    }
+
+    @Override
+    public void updateDrag(float distance) {
+    }
+
+    @Override
+    public void setSwipeVelocity(float velocity) {
+    }
+
+    @Override
+    public boolean shouldCollapse() {
+        return false;
+    }
+
+    @Override
+    public void animateCollapse(Runnable startStackCollapse, Runnable after) {
+    }
+
+    @Override
+    public void animateBackToExpanded() {
+    }
+
+    @Override
+    public void animateForImeVisibilityChange(boolean visible) {
+    }
+
+    @Override
+    public void reset() {
+    }
+}
diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/OverScroll.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/OverScroll.java
new file mode 100644
index 0000000..d4e76ed
--- /dev/null
+++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/animation/OverScroll.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.wm.shell.bubbles.animation;
+
+/**
+ * Utility methods for overscroll damping and related effect.
+ *
+ * Copied from packages/apps/Launcher3/src/com/android/launcher3/touch/OverScroll.java
+ */
+public class OverScroll {
+
+    private static final float OVERSCROLL_DAMP_FACTOR = 0.07f;
+
+    /**
+     * This curve determines how the effect of scrolling over the limits of the page diminishes
+     * as the user pulls further and further from the bounds
+     *
+     * @param f The percentage of how much the user has overscrolled.
+     * @return A transformed percentage based on the influence curve.
+     */
+    private static float overScrollInfluenceCurve(float f) {
+        f -= 1.0f;
+        return f * f * f + 1.0f;
+    }
+
+    /**
+     * @param amount The original amount overscrolled.
+     * @param max The maximum amount that the View can overscroll.
+     * @return The dampened overscroll amount.
+     */
+    public static int dampedScroll(float amount, int max) {
+        if (Float.compare(amount, 0) == 0) return 0;
+
+        float f = amount / max;
+        f = f / (Math.abs(f)) * (overScrollInfluenceCurve(Math.abs(f)));
+
+        // Clamp this factor, f, to -1 < f < 1
+        if (Math.abs(f) >= 1) {
+            f /= Math.abs(f);
+        }
+
+        return Math.round(OVERSCROLL_DAMP_FACTOR * f * max);
+    }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/BubblesNavBarMotionEventHandlerTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/BubblesNavBarMotionEventHandlerTest.java
new file mode 100644
index 0000000..44ff354
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/BubblesNavBarMotionEventHandlerTest.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.wm.shell.bubbles;
+
+import static android.view.MotionEvent.ACTION_CANCEL;
+import static android.view.MotionEvent.ACTION_DOWN;
+import static android.view.MotionEvent.ACTION_MOVE;
+import static android.view.MotionEvent.ACTION_UP;
+
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.floatThat;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.verifyZeroInteractions;
+
+import android.os.SystemClock;
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableLooper;
+import android.view.MotionEvent;
+import android.view.WindowManager;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.wm.shell.ShellTestCase;
+import com.android.wm.shell.bubbles.BubblesNavBarMotionEventHandler.MotionEventListener;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+/**
+ * Test {@link MotionEvent} handling in {@link BubblesNavBarMotionEventHandler}.
+ * Verifies that swipe events
+ */
+@SmallTest
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+@RunWith(AndroidTestingRunner.class)
+public class BubblesNavBarMotionEventHandlerTest extends ShellTestCase {
+
+    private BubblesNavBarMotionEventHandler mMotionEventHandler;
+    @Mock
+    private WindowManager mWindowManager;
+    @Mock
+    private Runnable mInterceptTouchRunnable;
+    @Mock
+    private MotionEventListener mMotionEventListener;
+    private long mMotionEventTime;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+        TestableBubblePositioner positioner = new TestableBubblePositioner(getContext(),
+                mWindowManager);
+        mMotionEventHandler = new BubblesNavBarMotionEventHandler(getContext(), positioner,
+                mInterceptTouchRunnable, mMotionEventListener);
+        mMotionEventTime = SystemClock.uptimeMillis();
+    }
+
+    @Test
+    public void testMotionEvent_swipeUpInGestureZone_handled() {
+        mMotionEventHandler.onMotionEvent(newEvent(ACTION_DOWN, 0, 990));
+        mMotionEventHandler.onMotionEvent(newEvent(ACTION_MOVE, 0, 690));
+        mMotionEventHandler.onMotionEvent(newEvent(ACTION_MOVE, 0, 490));
+        mMotionEventHandler.onMotionEvent(newEvent(ACTION_MOVE, 0, 390));
+        mMotionEventHandler.onMotionEvent(newEvent(ACTION_UP, 0, 390));
+
+        verify(mMotionEventListener).onDown(0, 990);
+        verify(mMotionEventListener).onMove(0, -300);
+        verify(mMotionEventListener).onMove(0, -500);
+        verify(mMotionEventListener).onMove(0, -600);
+        // Check that velocity up is about 5000
+        verify(mMotionEventListener).onUp(eq(0f), floatThat(f -> Math.round(f) == -5000));
+        verifyZeroInteractions(mMotionEventListener);
+        verify(mInterceptTouchRunnable).run();
+    }
+
+    @Test
+    public void testMotionEvent_swipeUpOutsideGestureZone_ignored() {
+        mMotionEventHandler.onMotionEvent(newEvent(ACTION_DOWN, 0, 500));
+        mMotionEventHandler.onMotionEvent(newEvent(ACTION_MOVE, 0, 100));
+        mMotionEventHandler.onMotionEvent(newEvent(ACTION_UP, 0, 100));
+
+        verifyZeroInteractions(mMotionEventListener);
+        verifyZeroInteractions(mInterceptTouchRunnable);
+    }
+
+    @Test
+    public void testMotionEvent_horizontalMoveMoreThanTouchSlop_handled() {
+        mMotionEventHandler.onMotionEvent(newEvent(ACTION_DOWN, 0, 990));
+        mMotionEventHandler.onMotionEvent(newEvent(ACTION_MOVE, 100, 990));
+        mMotionEventHandler.onMotionEvent(newEvent(ACTION_UP, 100, 990));
+
+        verify(mMotionEventListener).onDown(0, 990);
+        verify(mMotionEventListener).onMove(100, 0);
+        verify(mMotionEventListener).onUp(0, 0);
+        verifyZeroInteractions(mMotionEventListener);
+        verify(mInterceptTouchRunnable).run();
+    }
+
+    @Test
+    public void testMotionEvent_moveLessThanTouchSlop_ignored() {
+        mMotionEventHandler.onMotionEvent(newEvent(ACTION_DOWN, 0, 990));
+        mMotionEventHandler.onMotionEvent(newEvent(ACTION_MOVE, 0, 989));
+        mMotionEventHandler.onMotionEvent(newEvent(ACTION_UP, 0, 989));
+
+        verify(mMotionEventListener).onDown(0, 990);
+        verifyNoMoreInteractions(mMotionEventListener);
+        verifyZeroInteractions(mInterceptTouchRunnable);
+    }
+
+    @Test
+    public void testMotionEvent_actionCancel_listenerNotified() {
+        mMotionEventHandler.onMotionEvent(newEvent(ACTION_DOWN, 0, 990));
+        mMotionEventHandler.onMotionEvent(newEvent(ACTION_CANCEL, 0, 990));
+        verify(mMotionEventListener).onDown(0, 990);
+        verify(mMotionEventListener).onCancel();
+        verifyNoMoreInteractions(mMotionEventListener);
+        verifyZeroInteractions(mInterceptTouchRunnable);
+    }
+
+    private MotionEvent newEvent(int actionDown, float x, float y) {
+        MotionEvent event = MotionEvent.obtain(0L, mMotionEventTime, actionDown, x, y, 0);
+        mMotionEventTime += 10;
+        return event;
+    }
+}
diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/animation/ExpandedViewAnimationControllerTest.java b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/animation/ExpandedViewAnimationControllerTest.java
new file mode 100644
index 0000000..21887c0
--- /dev/null
+++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/animation/ExpandedViewAnimationControllerTest.java
@@ -0,0 +1,181 @@
+/*
+ * Copyright (C) 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.wm.shell.bubbles.animation;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assume.assumeTrue;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableLooper;
+import android.view.ViewConfiguration;
+import android.view.WindowManager;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.wm.shell.ShellTestCase;
+import com.android.wm.shell.bubbles.BubbleExpandedView;
+import com.android.wm.shell.bubbles.TestableBubblePositioner;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+public class ExpandedViewAnimationControllerTest extends ShellTestCase {
+
+    private ExpandedViewAnimationController mController;
+
+    @Mock
+    private WindowManager mWindowManager;
+
+    @Mock
+    private BubbleExpandedView mMockExpandedView;
+
+    @Before
+    public void setUp() {
+        MockitoAnnotations.initMocks(this);
+
+        TestableBubblePositioner positioner = new TestableBubblePositioner(getContext(),
+                mWindowManager);
+        mController = new ExpandedViewAnimationControllerImpl(getContext(), positioner);
+
+        mController.setExpandedView(mMockExpandedView);
+        when(mMockExpandedView.getContentHeight()).thenReturn(1000);
+    }
+
+    @Test
+    public void testUpdateDrag_expandedViewMovesUpAndClipped() {
+        // Drag by 50 pixels which corresponds to 10 pixels with overscroll
+        int dragDistance = 50;
+        int dampenedDistance = 10;
+
+        mController.updateDrag(dragDistance);
+
+        verify(mMockExpandedView).setTopClip(dampenedDistance);
+        verify(mMockExpandedView).setContentTranslationY(-dampenedDistance);
+        verify(mMockExpandedView).setManageButtonTranslationY(-dampenedDistance);
+    }
+
+    @Test
+    public void testUpdateDrag_zOrderUpdates() {
+        mController.updateDrag(10);
+        mController.updateDrag(20);
+
+        verify(mMockExpandedView, times(1)).setSurfaceZOrderedOnTop(true);
+        verify(mMockExpandedView, times(1)).setAnimating(true);
+    }
+
+    @Test
+    public void testUpdateDrag_moveBackToZero_zOrderRestored() {
+        mController.updateDrag(50);
+        reset(mMockExpandedView);
+        mController.updateDrag(0);
+        mController.updateDrag(0);
+
+        verify(mMockExpandedView, times(1)).setSurfaceZOrderedOnTop(false);
+        verify(mMockExpandedView, times(1)).setAnimating(false);
+    }
+
+    @Test
+    public void testUpdateDrag_hapticFeedbackOnlyOnce() {
+        // Drag by 10 which is below the collapse threshold - no feedback
+        mController.updateDrag(10);
+        verify(mMockExpandedView, times(0)).performHapticFeedback(anyInt());
+        // 150 takes it over the threshold - perform feedback
+        mController.updateDrag(150);
+        verify(mMockExpandedView, times(1)).performHapticFeedback(anyInt());
+        // Continue dragging, no more feedback
+        mController.updateDrag(200);
+        verify(mMockExpandedView, times(1)).performHapticFeedback(anyInt());
+        // Drag below threshold and over again - no more feedback
+        mController.updateDrag(10);
+        mController.updateDrag(150);
+        verify(mMockExpandedView, times(1)).performHapticFeedback(anyInt());
+    }
+
+    @Test
+    public void testShouldCollapse_doNotCollapseIfNotDragged() {
+        assertThat(mController.shouldCollapse()).isFalse();
+    }
+
+    @Test
+    public void testShouldCollapse_doNotCollapseIfVelocityDown() {
+        assumeTrue("Min fling velocity should be > 1 for this test", getMinFlingVelocity() > 1);
+        mController.setSwipeVelocity(getVelocityAboveMinFling());
+        assertThat(mController.shouldCollapse()).isFalse();
+    }
+
+    @Test
+    public void tesShouldCollapse_doNotCollapseIfVelocityUpIsSmall() {
+        assumeTrue("Min fling velocity should be > 1 for this test", getMinFlingVelocity() > 1);
+        mController.setSwipeVelocity(-getVelocityBelowMinFling());
+        assertThat(mController.shouldCollapse()).isFalse();
+    }
+
+    @Test
+    public void testShouldCollapse_collapseIfVelocityUpIsLarge() {
+        assumeTrue("Min fling velocity should be > 1 for this test", getMinFlingVelocity() > 1);
+        mController.setSwipeVelocity(-getVelocityAboveMinFling());
+        assertThat(mController.shouldCollapse()).isTrue();
+    }
+
+    @Test
+    public void testShouldCollapse_collapseIfPastThreshold() {
+        mController.updateDrag(500);
+        assertThat(mController.shouldCollapse()).isTrue();
+    }
+
+    @Test
+    public void testReset() {
+        mController.updateDrag(100);
+        reset(mMockExpandedView);
+        mController.reset();
+        verify(mMockExpandedView, atLeastOnce()).setAnimating(false);
+        verify(mMockExpandedView).setContentAlpha(1);
+        verify(mMockExpandedView).setAlpha(1);
+        verify(mMockExpandedView).setManageButtonAlpha(1);
+        verify(mMockExpandedView).setManageButtonAlpha(1);
+        verify(mMockExpandedView).setTopClip(0);
+        verify(mMockExpandedView).setContentTranslationY(-0f);
+        verify(mMockExpandedView).setManageButtonTranslationY(-0f);
+        verify(mMockExpandedView).setBottomClip(0);
+        verify(mMockExpandedView).movePointerBy(0, 0);
+        assertThat(mController.shouldCollapse()).isFalse();
+    }
+
+    private int getVelocityBelowMinFling() {
+        return getMinFlingVelocity() - 1;
+    }
+
+    private int getVelocityAboveMinFling() {
+        return getMinFlingVelocity() + 1;
+    }
+
+    private int getMinFlingVelocity() {
+        return ViewConfiguration.get(getContext()).getScaledMinimumFlingVelocity();
+    }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/flags/Flags.java b/packages/SystemUI/src/com/android/systemui/flags/Flags.java
index 94418f4..55bbcb6 100644
--- a/packages/SystemUI/src/com/android/systemui/flags/Flags.java
+++ b/packages/SystemUI/src/com/android/systemui/flags/Flags.java
@@ -166,6 +166,13 @@
     public static final SysPropBooleanFlag WM_ENABLE_SHELL_TRANSITIONS =
             new SysPropBooleanFlag(1100, "persist.wm.debug.shell_transit", false);
 
+    /**
+     * b/170163464: animate bubbles expanded view collapse with home gesture
+     */
+    @Keep
+    public static final SysPropBooleanFlag BUBBLES_HOME_GESTURE =
+            new SysPropBooleanFlag(1101, "persist.wm.debug.bubbles_home_gesture", false);
+
     // 1200 - predictive back
     @Keep
     public static final SysPropBooleanFlag WM_ENABLE_PREDICTIVE_BACK = new SysPropBooleanFlag(