Adds PhysicsPropertyAnimator, which simplifies animation controllers.

See the updated docs for an explanation and example usages. The number of overloaded animateValueForChild methods, and end listener/property setting boilerplate in the controllers were getting annoying, and this is a familiar pattern similar to ViewPropertyAnimator.

Test: atest SystemUITests, also manually tested to ensure no regressions.
Change-Id: Ibca870efda447d79b795c846408f1f6864ce3aa9
diff --git a/packages/SystemUI/docs/physics-animation-layout-control-methods.png b/packages/SystemUI/docs/physics-animation-layout-control-methods.png
deleted file mode 100644
index e77c676..0000000
--- a/packages/SystemUI/docs/physics-animation-layout-control-methods.png
+++ /dev/null
Binary files differ
diff --git a/packages/SystemUI/docs/physics-animation-layout.md b/packages/SystemUI/docs/physics-animation-layout.md
index 300f63a..488c465 100644
--- a/packages/SystemUI/docs/physics-animation-layout.md
+++ b/packages/SystemUI/docs/physics-animation-layout.md
@@ -25,22 +25,74 @@
 Returns a SpringForce instance to use for animations of the given property. This allows the controller to configure stiffness and bounciness values. Since the physics animations internally use SpringForce instances to hold inflight animation values, this method needs to return a new SpringForce instance each time - no constants allowed.
 
 ### Animation Control Methods
-![Diagram of how calls to animateValueForChildAtIndex dispatch to DynamicAnimations.](physics-animation-layout-control-methods.png)
 Once the layout has used the controller’s configuration properties to build the animations, the controller can use them to actually run animations. This is done for two reasons - reacting to a view being added or removed, or responding to another class (such as a touch handler or broadcast receiver) requesting an animation. ```onChildAdded```, ```onChildRemoved```, and ```setChildVisibility``` are called automatically by the layout, giving the controller the opportunity to animate the child in/out/visible/gone. Custom methods are called by anyone with access to the controller instance to do things like expand, collapse, or move the child views.
 
-In either case, the controller has access to the layout’s protected ```animateValueForChildAtIndex(ViewProperty property, int index, float value)``` method. This method is used to actually run an animation.
+In either case, the controller can use `super.animationForChild` to retrieve a `PhysicsPropertyAnimator` instance. This object behaves similarly to the `ViewPropertyAnimator` object you would receive from `View.animate()`. 
 
-For example, moving the first child view to *(100, 200)*:
+#### PhysicsPropertyAnimator
+
+Like `ViewPropertyAnimator`, `PhysicsPropertyAnimator` provides the following methods for animating properties:
+- `alpha(float)`
+- `translationX/Y/Z(float)`
+- `scaleX/Y(float)`
+
+It also provides the following configuration methods:
+- `withStartDelay(int)`, for starting the animation after a given delay.
+- `withStartVelocity(float)`, for starting the animation with the given start velocity.
+- `withPositionStartVelocities(float, float)`, for setting specific start velocities for TRANSLATION_X and TRANSLATION_Y, since these typically differ.
+- `start(Runnable)`, to start the animation, with an optional end action to call when the animations for every property (including chained animations) have completed.
+
+For example, moving the first child view:
 
 ```
-animateValueForChildAtIndex(TRANSLATION_X, 0, 100);
-animateValueForChildAtIndex(TRANSLATION_Y, 0, 200);
+animationForChild(getChildAt(0))
+    .translationX(100)
+    .translationY(200)
+    .setStartDelay(500)
+    .start();
 ```
 
-This would use the physics animations constructed by the layout to spring the view to *(100, 200)*.
+This would use the physics animations constructed by the layout to spring the view to *(100, 200)* after 500ms.
 
 If the controller’s ```getNextAnimationInChain``` method set up the first child’s TRANSLATION_X/Y animations to be chained to the second child’s, this would result in the second child also springing towards (100, 200), plus any offset returned by ```getOffsetForChainedPropertyAnimation```.
 
+##### Advanced Usage
+The animator has additional functionality to reduce the amount of boilerplate required for typical physics animation use cases.
+
+- Often, animations will set starting values for properties before the animation begins. Property methods like `translationX` have an overloaded variant: `translationX(from, to)`. When `start()` is called, the animation will set the view's translationX property to `from` before beginning the animation to `to`.
+- We may want to use different end actions for each property. For example, if we're animating a view to the bottom of the screen, and also fading it out, we might want to perform an action as soon as the fade out is complete. We can use `alpha(to, endAction)`, which will call endAction as soon as the alpha animation is finished. A special case is `position(x, y, endAction)`, where the endAction is called when both translationX and translationY animations have completed.
+
+`PhysicsAnimationController` also provides `animationsForChildrenFromIndex(int, ChildAnimationConfigurator)`. This is a convenience method for starting animations on multiple child views, starting at the given index. The `ChildAnimationConfigurator` is called with a `PhysicsPropertyAnimator` for each child, where calls to methods like `translationX` and `withStartVelocity` can be made. `animationsForChildrenFromIndex` returns a `MultiAnimationStarter` with a single method, `startAll(endAction)`, which starts all of the animations and calls the end action when they have all completed.
+
+##### Examples
+Spring the stack of bubbles (whose animations are chained) to the bottom of the screen, shrinking them to 50% size. Once the first bubble is done shrinking, begin fading them out, and then remove them all from the parent once all bubbles have faded out:
+
+```
+animationForChild(leadBubble)
+    .position(screenCenter, screenBottom)
+    .scaleX(0.5f)
+    .scaleY(0.5f, () -> animationForChild(leadBubble).alpha(0).start(removeAllFromParent))
+    .start();
+```
+
+'Drop in' a child view that was just added to the layout:
+
+```
+animationForChild(newView)
+    .scaleX(1.15f /* from */, 1f /* to */)
+    .scaleY(1.15f /* from */, 1f /* to */)
+    .alpha(0f /* from */, 1f /* to */)
+    .position(posX, posY)
+    .start();
+```
+
+Move every view except for the first to x = (index - 1) * 50, then remove the first view.
+
+```
+animationsForChildrenFromIndex(1, (index, anim) -> anim.translationX((index - 1) * 50))
+    .startAll(removeFirstView);
+```
+
 ## PhysicsAnimationLayout
 The layout itself is a FrameLayout descendant with a few extra methods:
 
diff --git a/packages/SystemUI/res/values/ids.xml b/packages/SystemUI/res/values/ids.xml
index 05cf040..501b1b5 100644
--- a/packages/SystemUI/res/values/ids.xml
+++ b/packages/SystemUI/res/values/ids.xml
@@ -111,13 +111,14 @@
     <!-- Optional cancel button on Keyguard -->
     <item type="id" name="cancel_button"/>
 
-    <!-- For saving DynamicAnimation physics animations as view tags. -->
+    <!-- For saving PhysicsAnimationLayout animations/animators as view tags. -->
     <item type="id" name="translation_x_dynamicanimation_tag"/>
     <item type="id" name="translation_y_dynamicanimation_tag"/>
     <item type="id" name="translation_z_dynamicanimation_tag"/>
     <item type="id" name="alpha_dynamicanimation_tag"/>
     <item type="id" name="scale_x_dynamicanimation_tag"/>
     <item type="id" name="scale_y_dynamicanimation_tag"/>
+    <item type="id" name="physics_animator_tag"/>
 
     <!-- Global Actions Menu -->
     <item type="id" name="global_actions_view" />
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/animation/ExpandedAnimationController.java b/packages/SystemUI/src/com/android/systemui/bubbles/animation/ExpandedAnimationController.java
index 40e08be..d601e63 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/animation/ExpandedAnimationController.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/animation/ExpandedAnimationController.java
@@ -104,16 +104,23 @@
      * @return The y-value to which the bubbles were expanded, in case that's useful.
      */
     public float expandFromStack(PointF collapseTo, Runnable after) {
+        animationsForChildrenFromIndex(
+                0, /* startIndex */
+                new ChildAnimationConfigurator() {
+                    // How much to translate the next bubble, so that it is not overlapping the
+                    // previous one.
+                    float mTranslateNextBubbleXBy = mBubblePaddingPx;
+
+                    @Override
+                    public void configureAnimationForChildAtIndex(
+                            int index, PhysicsAnimationLayout.PhysicsPropertyAnimator animation) {
+                        animation.position(mTranslateNextBubbleXBy, getExpandedY());
+                        mTranslateNextBubbleXBy += mBubbleSizePx + mBubblePaddingPx;
+                    }
+            })
+            .startAll(after);
+
         mCollapseToPoint = collapseTo;
-
-        // How much to translate the next bubble, so that it is not overlapping the previous one.
-        float translateNextBubbleXBy = mBubblePaddingPx;
-        for (int i = 0; i < mLayout.getChildCount(); i++) {
-            mLayout.animatePositionForChildAtIndex(i, translateNextBubbleXBy, getExpandedY());
-            translateNextBubbleXBy += mBubbleSizePx + mBubblePaddingPx;
-        }
-
-        runAfterTranslationsEnd(after);
         return getExpandedY();
     }
 
@@ -121,13 +128,14 @@
     public void collapseBackToStack(Runnable after) {
         // Stack to the left if we're going to the left, or right if not.
         final float sideMultiplier = mLayout.isFirstChildXLeftOfCenter(mCollapseToPoint.x) ? -1 : 1;
-        for (int i = 0; i < mLayout.getChildCount(); i++) {
-            mLayout.animatePositionForChildAtIndex(
-                    i,
-                    mCollapseToPoint.x + (sideMultiplier * i * mStackOffsetPx), mCollapseToPoint.y);
-        }
 
-        runAfterTranslationsEnd(after);
+        animationsForChildrenFromIndex(
+                0, /* startIndex */
+                (index, animation) ->
+                    animation.position(
+                            mCollapseToPoint.x + (sideMultiplier * index * mStackOffsetPx),
+                            mCollapseToPoint.y))
+            .startAll(after /* endAction */);
     }
 
     /** Prepares the given bubble to be dragged out. */
@@ -164,20 +172,10 @@
     public void snapBubbleBack(View bubbleView, float velX, float velY) {
         final int index = mLayout.indexOfChild(bubbleView);
 
-        // Snap the bubble back, respecting its current velocity.
-        mLayout.animateValueForChildAtIndex(
-                DynamicAnimation.TRANSLATION_X, index, getXForChildAtIndex(index), velX);
-        mLayout.animateValueForChildAtIndex(
-                DynamicAnimation.TRANSLATION_Y, index, getExpandedY(), velY);
-        mLayout.setEndListenerForProperties(
-                mLayout.new OneTimeMultiplePropertyEndListener() {
-                    @Override
-                    void onAllAnimationsForPropertiesEnd() {
-                        // Reset Z translation once the bubble is done snapping back.
-                        bubbleView.setTranslationZ(0f);
-                    }
-                },
-                DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
+        animationForChildAtIndex(index)
+                .position(getXForChildAtIndex(index), getExpandedY())
+                .withPositionStartVelocities(velX, velY)
+                .start(() -> bubbleView.setTranslationZ(0f) /* after */);
 
         animateStackByBubbleWidthsStartingFrom(
                 /* numBubbleWidths */ 0, /* startIndex */ index + 1);
@@ -202,12 +200,8 @@
      */
     public void updateYPosition(Runnable after) {
         if (mLayout == null) return;
-
-        for (int i = 0; i < mLayout.getChildCount(); i++) {
-            boolean isLast = i == mLayout.getChildCount() - 1;
-            mLayout.animateValueForChildAtIndex(DynamicAnimation.TRANSLATION_Y, i,
-                    getExpandedY(), isLast ? after : null);
-        }
+        animationsForChildrenFromIndex(
+                0, (i, anim) -> anim.translationY(getExpandedY())).startAll(after);
     }
 
     /**
@@ -216,12 +210,11 @@
      * positions.
      */
     private void animateStackByBubbleWidthsStartingFrom(int numBubbleWidths, int startIndex) {
-        for (int i = startIndex; i < mLayout.getChildCount(); i++) {
-            mLayout.animateValueForChildAtIndex(
-                    DynamicAnimation.TRANSLATION_X,
-                    i,
-                    getXForChildAtIndex(i + numBubbleWidths));
-        }
+        animationsForChildrenFromIndex(
+                startIndex,
+                (index, animation) ->
+                        animation.translationX(getXForChildAtIndex(index + numBubbleWidths)))
+            .startAll();
     }
 
     /** The Y value of the row of expanded bubbles. */
@@ -248,21 +241,6 @@
         }
     }
 
-    /** Runs the given Runnable after all translation-related animations have ended. */
-    private void runAfterTranslationsEnd(Runnable after) {
-        DynamicAnimation.OnAnimationEndListener allEndedListener =
-                (animation, canceled, value, velocity) -> {
-                    if (!mLayout.arePropertiesAnimating(
-                            DynamicAnimation.TRANSLATION_X,
-                            DynamicAnimation.TRANSLATION_Y)) {
-                        after.run();
-                    }
-                };
-
-        mLayout.setEndListenerForProperty(allEndedListener, DynamicAnimation.TRANSLATION_X);
-        mLayout.setEndListenerForProperty(allEndedListener, DynamicAnimation.TRANSLATION_Y);
-    }
-
     @Override
     Set<DynamicAnimation.ViewProperty> getAnimatedProperties() {
         return Sets.newHashSet(
@@ -295,8 +273,12 @@
         // Pop in from the top.
         // TODO: Reverse this when bubbles are at the bottom.
         child.setTranslationX(getXForChildAtIndex(index));
-        child.setTranslationY(getExpandedY() - mBubbleSizePx * ANIMATE_TRANSLATION_FACTOR);
-        mLayout.animateValueForChild(DynamicAnimation.TRANSLATION_Y, child, getExpandedY());
+
+        animationForChild(child)
+                .translationY(
+                        getExpandedY() - mBubbleSizePx * ANIMATE_TRANSLATION_FACTOR, /* from */
+                        getExpandedY() /* to */)
+                .start();
         animateBubblesAfterIndexToCorrectX(index);
     }
 
@@ -304,36 +286,26 @@
     void onChildRemoved(View child, int index, Runnable finishRemoval) {
         // Bubble pops out to the top.
         // TODO: Reverse this when bubbles are at the bottom.
-        mLayout.animateValueForChild(
-                DynamicAnimation.ALPHA, child, 0f, finishRemoval);
+
+        final PhysicsAnimationLayout.PhysicsPropertyAnimator animator = animationForChild(child);
+        animator.alpha(0f, finishRemoval /* endAction */);
 
         // If we're removing the dragged-out bubble, that means it got dismissed.
         if (child.equals(mBubbleDraggingOut)) {
-            // Throw it to the bottom of the screen, towards the center horizontally.
-            mLayout.animateValueForChild(
-                    DynamicAnimation.TRANSLATION_X,
-                    child,
-                    mLayout.getWidth() / 2f - mBubbleSizePx / 2f,
-                    mBubbleDraggingOutVelX);
-            mLayout.animateValueForChild(
-                    DynamicAnimation.TRANSLATION_Y,
-                    child,
-                    mLayout.getHeight() + mBubbleSizePx,
-                    mBubbleDraggingOutVelY);
-
-            // Scale it down a bit so it looks like it's disappearing.
-            mLayout.animateValueForChild(DynamicAnimation.SCALE_X, child, ANIMATE_SCALE_PERCENT);
-            mLayout.animateValueForChild(DynamicAnimation.SCALE_Y, child, ANIMATE_SCALE_PERCENT);
+            animator.position(
+                            mLayout.getWidth() / 2f - mBubbleSizePx / 2f,
+                            mLayout.getHeight() + mBubbleSizePx)
+                    .withPositionStartVelocities(mBubbleDraggingOutVelX, mBubbleDraggingOutVelY)
+                    .scaleX(ANIMATE_SCALE_PERCENT)
+                    .scaleY(ANIMATE_SCALE_PERCENT);
 
             mBubbleDraggingOut = null;
         } else {
-            // If we're removing some random bubble just throw it off the top.
-            mLayout.animateValueForChild(
-                    DynamicAnimation.TRANSLATION_Y,
-                    child,
-                    getExpandedY() - mBubbleSizePx * ANIMATE_TRANSLATION_FACTOR);
+            animator.translationY(getExpandedY() - mBubbleSizePx * ANIMATE_TRANSLATION_FACTOR);
         }
 
+        animator.start();
+
         // Animate all the other bubbles to their new positions sans this bubble.
         animateBubblesAfterIndexToCorrectX(index);
     }
@@ -346,12 +318,9 @@
             child.setVisibility(View.VISIBLE);
         }
 
-        // Fade in.
-        mLayout.animateValueForChild(
-                DynamicAnimation.ALPHA,
-                child,
-                /* value */ visibility == View.GONE ? 0f : 1f,
-                () -> super.setChildVisibility(child, index, visibility));
+        animationForChild(child)
+                .alpha(visibility == View.GONE ? 0f : 1f)
+                .start(() -> super.setChildVisibility(child, index, visibility) /* after */);
     }
 
     /**
@@ -365,8 +334,9 @@
             // Don't animate the dragging out bubble, or it'll jump around while being dragged. It
             // will be snapped to the correct X value after the drag (if it's not dismissed).
             if (!bubble.equals(mBubbleDraggingOut)) {
-                mLayout.animateValueForChild(
-                        DynamicAnimation.TRANSLATION_X, bubble, getXForChildAtIndex(i));
+                animationForChild(bubble)
+                        .translationX(getXForChildAtIndex(i))
+                        .start();
             }
         }
     }
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayout.java b/packages/SystemUI/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayout.java
index dfdcfc9..2fa87d8 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayout.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayout.java
@@ -27,8 +27,11 @@
 
 import com.android.systemui.R;
 
+import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
 /**
@@ -46,6 +49,35 @@
      */
     abstract static class PhysicsAnimationController {
 
+        /** Configures a given {@link PhysicsPropertyAnimator} for a view at the given index. */
+        interface ChildAnimationConfigurator {
+
+            /**
+             * Called to configure the animator for the view at the given index.
+             *
+             * This method should make use of methods such as
+             * {@link PhysicsPropertyAnimator#translationX} and
+             * {@link PhysicsPropertyAnimator#withStartDelay} to configure the animation.
+             *
+             * Implementations should not call {@link PhysicsPropertyAnimator#start}, this will
+             * happen elsewhere after configuration is complete.
+             */
+            void configureAnimationForChildAtIndex(int index, PhysicsPropertyAnimator animation);
+        }
+
+        /**
+         * Returned by {@link #animationsForChildrenFromIndex} to allow starting multiple animations
+         * on multiple child views at the same time.
+         */
+        interface MultiAnimationStarter {
+
+            /**
+             * Start all animations and call the given end actions once all animations have
+             * completed.
+             */
+            void startAll(Runnable... endActions);
+        }
+
         /**
          * Constant to return from {@link #getNextAnimationInChain} if the animation should not be
          * chained at all.
@@ -98,7 +130,7 @@
          * by getChildAt() and getChildCount().
          *
          * The controller can perform animations on the child (either manually, or by using
-         * {@link #animateValueForChild}), and then call finishRemoval when complete.
+         * {@link #animationForChild(View)}), and then call finishRemoval when complete.
          *
          * finishRemoval must be called by implementations of this method, or transient views will
          * never be removed.
@@ -125,14 +157,74 @@
         protected void setChildVisibility(View child, int index, int visibility) {
             child.setVisibility(visibility);
         }
+
+        /**
+         * Returns a {@link PhysicsPropertyAnimator} instance for the given child view.
+         */
+        protected PhysicsPropertyAnimator animationForChild(View child) {
+            PhysicsPropertyAnimator animator =
+                    (PhysicsPropertyAnimator) child.getTag(R.id.physics_animator_tag);
+
+            if (animator == null) {
+                animator = mLayout.new PhysicsPropertyAnimator(child);
+                child.setTag(R.id.physics_animator_tag, animator);
+            }
+
+            return animator;
+        }
+
+        /** Returns a {@link PhysicsPropertyAnimator} instance for child at the given index. */
+        protected PhysicsPropertyAnimator animationForChildAtIndex(int index) {
+            return animationForChild(mLayout.getChildAt(index));
+        }
+
+        /**
+         * Returns a {@link MultiAnimationStarter} whose startAll method will start the physics
+         * animations for all children from startIndex onward. The provided configurator will be
+         * called with each child's {@link PhysicsPropertyAnimator}, where it can set up each
+         * animation appropriately.
+         */
+        protected MultiAnimationStarter animationsForChildrenFromIndex(
+                int startIndex, ChildAnimationConfigurator configurator) {
+            final Set<DynamicAnimation.ViewProperty> allAnimatedProperties = new HashSet<>();
+            final List<PhysicsPropertyAnimator> allChildAnims = new ArrayList<>();
+
+            // Retrieve the animator for each child, ask the configurator to configure it, then save
+            // it and the properties it chose to animate.
+            for (int i = startIndex; i < mLayout.getChildCount(); i++) {
+                final PhysicsPropertyAnimator anim = animationForChildAtIndex(i);
+                configurator.configureAnimationForChildAtIndex(i, anim);
+                allAnimatedProperties.addAll(anim.getAnimatedProperties());
+                allChildAnims.add(anim);
+            }
+
+            // Return a MultiAnimationStarter that will start all of the child animations, and also
+            // add a multiple property end listener to the layout that will call the end action
+            // provided to startAll() once all animations on the animated properties complete.
+            return (endActions) -> {
+                if (endActions != null) {
+                    mLayout.setEndActionForMultipleProperties(
+                            () -> {
+                                for (Runnable action : endActions) {
+                                    action.run();
+                                }
+                            },
+                            allAnimatedProperties.toArray(
+                                    new DynamicAnimation.ViewProperty[0]));
+                }
+
+                for (PhysicsPropertyAnimator childAnim : allChildAnims) {
+                    childAnim.start();
+                }
+            };
+        }
     }
 
     /**
-     * End listeners that are called when every child's animation of the given property has
-     * finished.
+     * End actions that are called when every child's animation of the given property has finished.
      */
-    protected final HashMap<DynamicAnimation.ViewProperty, DynamicAnimation.OnAnimationEndListener>
-            mEndListenerForProperty = new HashMap<>();
+    protected final HashMap<DynamicAnimation.ViewProperty, Runnable> mEndActionForProperty =
+            new HashMap<>();
 
     /** Set of currently rendered transient views. */
     private final Set<View> mTransientViews = new HashSet<>();
@@ -165,7 +257,7 @@
      */
     public void setController(PhysicsAnimationController controller) {
         cancelAllAnimations();
-        mEndListenerForProperty.clear();
+        mEndActionForProperty.clear();
 
         this.mController = controller;
         mController.setLayout(this);
@@ -177,26 +269,27 @@
     }
 
     /**
-     * Sets an end listener that will be called when all child animations for a given property have
+     * Sets an end action that will be run when all child animations for a given property have
      * stopped running.
      */
-    public void setEndListenerForProperty(
-            DynamicAnimation.OnAnimationEndListener listener,
-            DynamicAnimation.ViewProperty property) {
-        mEndListenerForProperty.put(property, listener);
+    public void setEndActionForProperty(Runnable action, DynamicAnimation.ViewProperty property) {
+        mEndActionForProperty.put(property, action);
     }
 
     /**
-     * Sets an end listener that will be called whenever any of the given properties' animations
-     * end. For example, setting a listener for TRANSLATION_X and TRANSLATION_Y will result in that
-     * listener being called twice - once when all TRANSLATION_X animations end, and again when all
-     * TRANSLATION_Y animations end.
+     * Sets an end action that will be run when all child animations for all of the given properties
+     * have stopped running.
      */
-    public void setEndListenerForProperties(
-            DynamicAnimation.OnAnimationEndListener endListener,
-            DynamicAnimation.ViewProperty... properties) {
+    public void setEndActionForMultipleProperties(
+            Runnable action, DynamicAnimation.ViewProperty... properties) {
+        final Runnable checkIfAllFinished = () -> {
+            if (!arePropertiesAnimating(properties)) {
+                action.run();
+            }
+        };
+
         for (DynamicAnimation.ViewProperty property : properties) {
-            setEndListenerForProperty(endListener, property);
+            setEndActionForProperty(checkIfAllFinished, property);
         }
     }
 
@@ -204,8 +297,8 @@
      * Removes the end listener that would have been called when all child animations for a given
      * property stopped running.
      */
-    public void removeEndListenerForProperty(DynamicAnimation.ViewProperty property) {
-        mEndListenerForProperty.remove(property);
+    public void removeEndActionForProperty(DynamicAnimation.ViewProperty property) {
+        mEndActionForProperty.remove(property);
     }
 
     @Override
@@ -231,6 +324,11 @@
     }
 
     @Override
+    public void removeViewAt(int index) {
+        removeView(getChildAt(index));
+    }
+
+    @Override
     public void addTransientView(View view, int index) {
         super.addTransientView(view, index);
         mTransientViews.add(view);
@@ -304,7 +402,10 @@
 
         for (int i = 0; i < getChildCount(); i++) {
             for (DynamicAnimation.ViewProperty property : mController.getAnimatedProperties()) {
-                getAnimationAtIndex(property, i).cancel();
+                final DynamicAnimation anim = getAnimationAtIndex(property, i);
+                if (anim != null) {
+                    anim.cancel();
+                }
             }
         }
     }
@@ -316,107 +417,6 @@
         }
     }
 
-    /**
-     * Animates the property of the given child view, then runs the callback provided when the
-     * animation ends.
-     */
-    protected void animateValueForChild(
-            DynamicAnimation.ViewProperty property,
-            View view,
-            float value,
-            float startVel,
-            Runnable after) {
-        if (view != null) {
-            final SpringAnimation animation =
-                    (SpringAnimation) view.getTag(getTagIdForProperty(property));
-            if (after != null) {
-                animation.addEndListener(new OneTimeEndListener() {
-                    @Override
-                    public void onAnimationEnd(DynamicAnimation animation, boolean canceled,
-                            float value, float velocity) {
-                        super.onAnimationEnd(animation, canceled, value, velocity);
-                        after.run();
-                    }
-                });
-            }
-
-            // Set the start velocity if it's something other than the not-set value.
-            if (startVel != Float.MAX_VALUE) {
-                animation.setStartVelocity(startVel);
-            }
-
-            animation.animateToFinalPosition(value);
-        }
-    }
-
-    protected void animateValueForChild(
-            DynamicAnimation.ViewProperty property,
-            View view,
-            float value,
-            Runnable after) {
-        animateValueForChild(property, view, value, Float.MAX_VALUE, after);
-    }
-
-    protected void animateValueForChild(
-            DynamicAnimation.ViewProperty property,
-            View view,
-            float value) {
-        animateValueForChild(property, view, value, Float.MAX_VALUE, /* after */ null);
-    }
-
-    protected void animateValueForChild(
-            DynamicAnimation.ViewProperty property,
-            View view,
-            float value,
-            float startVel) {
-        animateValueForChild(property, view, value, startVel, /* after */ null);
-    }
-
-    /**
-     * Animates the property of the child at the given index to the given value, then runs the
-     * callback provided when the animation ends.
-     */
-    protected void animateValueForChildAtIndex(
-            DynamicAnimation.ViewProperty property,
-            int index,
-            float value,
-            float startVel,
-            Runnable after) {
-        animateValueForChild(property, getChildAt(index), value, startVel, after);
-    }
-
-    /** Shortcut to animate a value with a callback, but no start velocity. */
-    protected void animateValueForChildAtIndex(
-            DynamicAnimation.ViewProperty property,
-            int index,
-            float value,
-            Runnable after) {
-        animateValueForChildAtIndex(property, index, value, Float.MAX_VALUE, after);
-    }
-
-    /** Shortcut to animate a value with a start velocity, but no callback. */
-    protected void animateValueForChildAtIndex(
-            DynamicAnimation.ViewProperty property,
-            int index,
-            float value,
-            float startVel) {
-        animateValueForChildAtIndex(property, index, value, startVel, /* callback */ null);
-    }
-
-    /** Shortcut to animate a value without changing the velocity or providing a callback. */
-    protected void animateValueForChildAtIndex(
-            DynamicAnimation.ViewProperty property,
-            int index,
-            float value) {
-        animateValueForChildAtIndex(property, index, value, Float.MAX_VALUE, /* callback */ null);
-    }
-
-    /** Shortcut to animate a child view's TRANSLATION_X and TRANSLATION_Y values. */
-    protected void animatePositionForChildAtIndex(int index, float x, float y) {
-        animateValueForChildAtIndex(DynamicAnimation.TRANSLATION_X, index, x);
-        animateValueForChildAtIndex(DynamicAnimation.TRANSLATION_Y, index, y);
-    }
-
     /** Whether the first child would be left of center if translated to the given x value. */
     protected boolean isFirstChildXLeftOfCenter(float x) {
         if (getChildCount() > 0) {
@@ -562,40 +562,256 @@
         public void onAnimationEnd(
                 DynamicAnimation anim, boolean canceled, float value, float velocity) {
             if (!arePropertiesAnimating(mProperty)) {
-                if (mEndListenerForProperty.containsKey(mProperty)) {
-                    mEndListenerForProperty.get(mProperty).onAnimationEnd(anim, canceled, value,
-                            velocity);
+                if (mEndActionForProperty.containsKey(mProperty)) {
+                    mEndActionForProperty.get(mProperty).run();
                 }
             }
         }
     }
 
     /**
-     * One time end listener that waits for every animation on every given property to finish. At
-     * that point, it calls {@link #onAllAnimationsForPropertiesEnd} and then removes itself as an
-     * end listener from each property.
+     * Animator class returned by {@link PhysicsAnimationController#animationForChild}, to allow
+     * controllers to animate child views using physics animations.
+     *
+     * See docs/physics-animation-layout.md for documentation and examples.
      */
-    public abstract class OneTimeMultiplePropertyEndListener
-            implements DynamicAnimation.OnAnimationEndListener {
-        final DynamicAnimation.ViewProperty[] mViewProperties;
+    protected class PhysicsPropertyAnimator {
+        /** The view whose properties this animator animates. */
+        private View mView;
 
-        OneTimeMultiplePropertyEndListener(DynamicAnimation.ViewProperty... properties) {
-            mViewProperties = properties;
+        /** Start velocity to use for all property animations. */
+        private float mDefaultStartVelocity = 0f;
+
+        /** Start delay to use when start is called. */
+        private long mStartDelay = 0;
+
+        /** End actions to call when animations for the given property complete. */
+        private Map<DynamicAnimation.ViewProperty, Runnable[]> mEndActionsForProperty =
+                new HashMap<>();
+
+        /**
+         * Start velocities to use for TRANSLATION_X and TRANSLATION_Y, since these are often
+         * provided by VelocityTrackers and differ from each other.
+         */
+        private Map<DynamicAnimation.ViewProperty, Float> mPositionStartVelocities =
+                new HashMap<>();
+
+        /**
+         * End actions to call when both TRANSLATION_X and TRANSLATION_Y animations have completed,
+         * if {@link #position} was used to animate TRANSLATION_X and TRANSLATION_Y simultaneously.
+         */
+        private Runnable[] mPositionEndActions;
+
+        /**
+         * All of the properties that have been set and will animate when {@link #start} is called.
+         */
+        private Map<DynamicAnimation.ViewProperty, Float> mAnimatedProperties = new HashMap<>();
+
+        protected PhysicsPropertyAnimator(View view) {
+            this.mView = view;
         }
 
-        @Override
-        public void onAnimationEnd(DynamicAnimation animation, boolean canceled, float value,
-                float velocity) {
-            if (!arePropertiesAnimating(mViewProperties)) {
-                onAllAnimationsForPropertiesEnd();
+        /** Animate a property to the given value, then call the optional end actions. */
+        public PhysicsPropertyAnimator property(
+                DynamicAnimation.ViewProperty property, float value, Runnable... endActions) {
+            mAnimatedProperties.put(property, value);
+            mEndActionsForProperty.put(property, endActions);
+            return this;
+        }
 
-                for (DynamicAnimation.ViewProperty property : mViewProperties) {
-                    removeEndListenerForProperty(property);
+        /** Animate the view's alpha value to the provided value. */
+        public PhysicsPropertyAnimator alpha(float alpha, Runnable... endActions) {
+            return property(DynamicAnimation.ALPHA, alpha, endActions);
+        }
+
+        /** Set the view's alpha value to 'from', then animate it to the given value. */
+        public PhysicsPropertyAnimator alpha(float from, float to, Runnable... endActions) {
+            mView.setAlpha(from);
+            return alpha(to, endActions);
+        }
+
+        /** Animate the view's translationX value to the provided value. */
+        public PhysicsPropertyAnimator translationX(float translationX, Runnable... endActions) {
+            return property(DynamicAnimation.TRANSLATION_X, translationX, endActions);
+        }
+
+        /** Set the view's translationX value to 'from', then animate it to the given value. */
+        public PhysicsPropertyAnimator translationX(
+                float from, float to, Runnable... endActions) {
+            mView.setTranslationX(from);
+            return translationX(to, endActions);
+        }
+
+        /** Animate the view's translationY value to the provided value. */
+        public PhysicsPropertyAnimator translationY(float translationY, Runnable... endActions) {
+            return property(DynamicAnimation.TRANSLATION_Y, translationY, endActions);
+        }
+
+        /** Set the view's translationY value to 'from', then animate it to the given value. */
+        public PhysicsPropertyAnimator translationY(
+                float from, float to, Runnable... endActions) {
+            mView.setTranslationY(from);
+            return translationY(to, endActions);
+        }
+
+        /**
+         * Animate the view's translationX and translationY values, and call the end actions only
+         * once both TRANSLATION_X and TRANSLATION_Y animations have completed.
+         */
+        public PhysicsPropertyAnimator position(
+                float translationX, float translationY, Runnable... endActions) {
+            mPositionEndActions = endActions;
+            translationX(translationX);
+            return translationY(translationY);
+        }
+
+        /** Animate the view's scaleX value to the provided value. */
+        public PhysicsPropertyAnimator scaleX(float scaleX, Runnable... endActions) {
+            return property(DynamicAnimation.SCALE_X, scaleX, endActions);
+        }
+
+        /** Set the view's scaleX value to 'from', then animate it to the given value. */
+        public PhysicsPropertyAnimator scaleX(float from, float to, Runnable... endActions) {
+            mView.setScaleX(from);
+            return scaleX(to, endActions);
+        }
+
+        /** Animate the view's scaleY value to the provided value. */
+        public PhysicsPropertyAnimator scaleY(float scaleY, Runnable... endActions) {
+            return property(DynamicAnimation.SCALE_Y, scaleY, endActions);
+        }
+
+        /** Set the view's scaleY value to 'from', then animate it to the given value. */
+        public PhysicsPropertyAnimator scaleY(float from, float to, Runnable... endActions) {
+            mView.setScaleY(from);
+            return scaleY(to, endActions);
+        }
+
+        /** Set the start velocity to use for all property animations. */
+        public PhysicsPropertyAnimator withStartVelocity(float startVel) {
+            mDefaultStartVelocity = startVel;
+            return this;
+        }
+
+        /**
+         * Set the start velocities to use for TRANSLATION_X and TRANSLATION_Y animations. This
+         * overrides any value set via {@link #withStartVelocity(float)} for those properties.
+         */
+        public PhysicsPropertyAnimator withPositionStartVelocities(float velX, float velY) {
+            mPositionStartVelocities.put(DynamicAnimation.TRANSLATION_X, velX);
+            mPositionStartVelocities.put(DynamicAnimation.TRANSLATION_Y, velY);
+            return this;
+        }
+
+        /** Set a delay, in milliseconds, before kicking off the animations. */
+        public PhysicsPropertyAnimator withStartDelay(long startDelay) {
+            mStartDelay = startDelay;
+            return this;
+        }
+
+        /**
+         * Start the animations, and call the optional end actions once all animations for every
+         * animated property on every child (including chained animations) have ended.
+         */
+        public void start(Runnable... after) {
+            final Set<DynamicAnimation.ViewProperty> properties = getAnimatedProperties();
+
+            // If there are end actions, set an end listener on the layout for all the properties
+            // we're about to animate.
+            if (after != null) {
+                final DynamicAnimation.ViewProperty[] propertiesArray =
+                        properties.toArray(new DynamicAnimation.ViewProperty[0]);
+                for (Runnable callback : after) {
+                    setEndActionForMultipleProperties(callback, propertiesArray);
+                }
+            }
+
+            // If we used position-specific end actions, we'll need to listen for both TRANSLATION_X
+            // and TRANSLATION_Y animations ending, and call them once both have finished.
+            if (mPositionEndActions != null) {
+                final SpringAnimation translationXAnim =
+                        getAnimationFromView(DynamicAnimation.TRANSLATION_X, mView);
+                final SpringAnimation translationYAnim =
+                        getAnimationFromView(DynamicAnimation.TRANSLATION_Y, mView);
+                final Runnable waitForBothXAndY = () -> {
+                    if (!translationXAnim.isRunning() && !translationYAnim.isRunning()) {
+                        if (mPositionEndActions != null) {
+                            for (Runnable callback : mPositionEndActions) {
+                                callback.run();
+                            }
+                        }
+
+                        mPositionEndActions = null;
+                    }
+                };
+
+                mEndActionsForProperty.put(DynamicAnimation.TRANSLATION_X,
+                        new Runnable[]{waitForBothXAndY});
+                mEndActionsForProperty.put(DynamicAnimation.TRANSLATION_Y,
+                        new Runnable[]{waitForBothXAndY});
+            }
+
+            // Actually start the animations.
+            for (DynamicAnimation.ViewProperty property : properties) {
+                animateValueForChild(
+                        property,
+                        mView,
+                        mAnimatedProperties.get(property),
+                        mPositionStartVelocities.getOrDefault(property, mDefaultStartVelocity),
+                        mStartDelay,
+                        mEndActionsForProperty.get(property));
+            }
+
+            // Clear out the animator.
+            mAnimatedProperties.clear();
+            mPositionStartVelocities.clear();
+            mDefaultStartVelocity = 0;
+            mStartDelay = 0;
+            mEndActionsForProperty.clear();
+        }
+
+        /** Returns the set of properties that will animate once {@link #start} is called. */
+        protected Set<DynamicAnimation.ViewProperty> getAnimatedProperties() {
+            return mAnimatedProperties.keySet();
+        }
+
+        /**
+         * Animates the property of the given child view, then runs the callback provided when the
+         * animation ends.
+         */
+        protected void animateValueForChild(
+                DynamicAnimation.ViewProperty property,
+                View view,
+                float value,
+                float startVel,
+                long startDelay,
+                Runnable[] afterCallbacks) {
+            if (view != null) {
+                final SpringAnimation animation =
+                        (SpringAnimation) view.getTag(getTagIdForProperty(property));
+                if (afterCallbacks != null) {
+                    animation.addEndListener(new OneTimeEndListener() {
+                        @Override
+                        public void onAnimationEnd(DynamicAnimation animation, boolean canceled,
+                                float value, float velocity) {
+                            super.onAnimationEnd(animation, canceled, value, velocity);
+                            for (Runnable runnable : afterCallbacks) {
+                                runnable.run();
+                            }
+                        }
+                    });
+                }
+
+                if (startVel > 0) {
+                    animation.setStartVelocity(startVel);
+                }
+
+                if (startDelay > 0) {
+                    postDelayed(() -> animation.animateToFinalPosition(value), startDelay);
+                } else {
+                    animation.animateToFinalPosition(value);
                 }
             }
         }
-
-        /** Called when every animation for every property has finished. */
-        abstract void onAllAnimationsForPropertiesEnd();
     }
 }
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/animation/StackAnimationController.java b/packages/SystemUI/src/com/android/systemui/bubbles/animation/StackAnimationController.java
index 3c4bc72..c395031 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/animation/StackAnimationController.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/animation/StackAnimationController.java
@@ -206,12 +206,12 @@
                         .setDampingRatio(SPRING_DAMPING_RATIO),
                 /* destination */ null);
 
-        mLayout.setEndListenerForProperties(
-                (animation, canceled, value, velocity) -> {
+        mLayout.setEndActionForMultipleProperties(
+                () -> {
                     mRestingStackPosition = new PointF();
                     mRestingStackPosition.set(mStackPosition);
-                    mLayout.removeEndListenerForProperty(DynamicAnimation.TRANSLATION_X);
-                    mLayout.removeEndListenerForProperty(DynamicAnimation.TRANSLATION_Y);
+                    mLayout.removeEndActionForProperty(DynamicAnimation.TRANSLATION_X);
+                    mLayout.removeEndActionForProperty(DynamicAnimation.TRANSLATION_Y);
                 },
                 DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
     }
@@ -292,8 +292,8 @@
         cancelStackPositionAnimation(DynamicAnimation.TRANSLATION_X);
         cancelStackPositionAnimation(DynamicAnimation.TRANSLATION_Y);
 
-        mLayout.removeEndListenerForProperty(DynamicAnimation.TRANSLATION_X);
-        mLayout.removeEndListenerForProperty(DynamicAnimation.TRANSLATION_Y);
+        mLayout.removeEndActionForProperty(DynamicAnimation.TRANSLATION_X);
+        mLayout.removeEndActionForProperty(DynamicAnimation.TRANSLATION_Y);
     }
 
     /**
@@ -441,19 +441,18 @@
 
     @Override
     void onChildRemoved(View child, int index, Runnable finishRemoval) {
-        // Animate the child out, actually removing it once its alpha is zero.
-        mLayout.animateValueForChild(DynamicAnimation.ALPHA, child, 0f, finishRemoval);
-        mLayout.animateValueForChild(DynamicAnimation.SCALE_X, child, ANIMATE_IN_STARTING_SCALE);
-        mLayout.animateValueForChild(DynamicAnimation.SCALE_Y, child, ANIMATE_IN_STARTING_SCALE);
-
         // Animate the removing view in the opposite direction of the stack.
         final float xOffset = getOffsetForChainedPropertyAnimation(DynamicAnimation.TRANSLATION_X);
-        mLayout.animateValueForChild(DynamicAnimation.TRANSLATION_X, child,
-                mStackPosition.x - (-xOffset * ANIMATE_TRANSLATION_FACTOR));
+        animationForChild(child)
+                .alpha(0f, finishRemoval /* after */)
+                .scaleX(ANIMATE_IN_STARTING_SCALE)
+                .scaleY(ANIMATE_IN_STARTING_SCALE)
+                .translationX(mStackPosition.x - (-xOffset * ANIMATE_TRANSLATION_FACTOR))
+                .start();
 
-        // Pull the top of the stack to the correct position, the chained animations will instruct
-        // any children that are out of place to animate to the correct position.
-        mLayout.animateValueForChildAtIndex(DynamicAnimation.TRANSLATION_X, 0, mStackPosition.x);
+        if (mLayout.getChildCount() > 0) {
+            animationForChildAtIndex(0).translationX(mStackPosition.x).start();
+        }
     }
 
     /** Moves the stack, without any animation, to the starting position. */
@@ -486,10 +485,12 @@
 
         if (mLayout.getChildCount() > 0) {
             property.setValue(mLayout.getChildAt(0), value);
-            mLayout.animateValueForChildAtIndex(
-                    property,
-                    /* index */ 1,
-                    value + getOffsetForChainedPropertyAnimation(property));
+
+            if (mLayout.getChildCount() > 1) {
+                animationForChildAtIndex(1)
+                        .property(property, value + getOffsetForChainedPropertyAnimation(property))
+                        .start();
+            }
         }
     }
 
@@ -520,23 +521,15 @@
     private void animateInBubble(View child) {
         child.setTranslationY(mStackPosition.y);
 
-        // Pop in the new bubble.
-        child.setScaleX(ANIMATE_IN_STARTING_SCALE);
-        child.setScaleY(ANIMATE_IN_STARTING_SCALE);
-        mLayout.animateValueForChildAtIndex(DynamicAnimation.SCALE_X, 0, 1f);
-        mLayout.animateValueForChildAtIndex(DynamicAnimation.SCALE_Y, 0, 1f);
-
-        // Fade in the new bubble.
-        child.setAlpha(0);
-        mLayout.animateValueForChildAtIndex(DynamicAnimation.ALPHA, 0, 1f);
-
-        // Start the new bubble 4x the normal offset distance in the opposite direction. We'll
-        // animate in from this position. Since the animations are chained, when the new bubble
-        // flies in from the side, it will push the other ones out of the way.
         float xOffset = getOffsetForChainedPropertyAnimation(DynamicAnimation.TRANSLATION_X);
-        child.setTranslationX(mStackPosition.x - ANIMATE_TRANSLATION_FACTOR * xOffset);
-        mLayout.animateValueForChildAtIndex(
-                DynamicAnimation.TRANSLATION_X, 0, mStackPosition.x);
+        animationForChild(child)
+                .scaleX(ANIMATE_IN_STARTING_SCALE /* from */, 1f /* to */)
+                .scaleY(ANIMATE_IN_STARTING_SCALE /* from */, 1f /* to */)
+                .alpha(0f /* from */, 1f /* to */)
+                .translationX(
+                        mStackPosition.x - ANIMATE_TRANSLATION_FACTOR * xOffset /* from */,
+                        mStackPosition.x /* to */)
+                .start();
     }
 
     /**
diff --git a/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/ExpandedAnimationControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/ExpandedAnimationControllerTest.java
index b8add89..cd84805 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/ExpandedAnimationControllerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/ExpandedAnimationControllerTest.java
@@ -43,7 +43,6 @@
     @Spy
     private ExpandedAnimationController mExpandedController =
             new ExpandedAnimationController(new Point(500, 1000) /* displaySize */);
-
     private int mStackOffset;
     private float mBubblePadding;
     private float mBubbleSize;
diff --git a/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayoutTest.java b/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayoutTest.java
index a50919b..38a90f7 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayoutTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayoutTest.java
@@ -20,12 +20,11 @@
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyBoolean;
-import static org.mockito.ArgumentMatchers.anyFloat;
 import static org.mockito.ArgumentMatchers.anyInt;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.inOrder;
 import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
 
 import android.os.SystemClock;
 import android.testing.AndroidTestingRunner;
@@ -68,7 +67,8 @@
         // offset, and don't actually remove views immediately (since most implementations will wait
         // to animate child views out before actually removing them).
         mTestableController.setAnimatedProperties(Sets.newHashSet(
-                DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y));
+                DynamicAnimation.TRANSLATION_X,
+                DynamicAnimation.TRANSLATION_Y));
         mTestableController.setChainedProperties(Sets.newHashSet(DynamicAnimation.TRANSLATION_X));
         mTestableController.setOffsetForProperty(
                 DynamicAnimation.TRANSLATION_X, TEST_TRANSLATION_X_OFFSET);
@@ -126,11 +126,11 @@
 
         // Animate the first child's translation X.
         final CountDownLatch animLatch = new CountDownLatch(1);
-        mLayout.animateValueForChildAtIndex(
-                DynamicAnimation.TRANSLATION_X,
-                0,
-                100,
-                animLatch::countDown);
+
+        mTestableController
+                .animationForChildAtIndex(0)
+                .translationX(100)
+                .start(animLatch::countDown);
         animLatch.await(1, TimeUnit.SECONDS);
 
         // Ensure that the first view has been translated, but not the second one.
@@ -140,60 +140,50 @@
 
     @Test
     public void testUpdateValueXChained() throws InterruptedException {
-        mLayout.setController(mTestableController);
-        addOneMoreThanRenderLimitBubbles();
         testChainedTranslationAnimations();
     }
 
     @Test
-    public void testSetEndListeners() throws InterruptedException {
+    public void testSetEndActions() throws InterruptedException {
         mLayout.setController(mTestableController);
         addOneMoreThanRenderLimitBubbles();
         mTestableController.setChainedProperties(Sets.newHashSet());
 
         final CountDownLatch xLatch = new CountDownLatch(1);
-        OneTimeEndListener xEndListener = Mockito.spy(new OneTimeEndListener() {
+        Runnable xEndAction = Mockito.spy(new Runnable() {
+
             @Override
-            public void onAnimationEnd(DynamicAnimation animation, boolean canceled, float value,
-                    float velocity) {
-                super.onAnimationEnd(animation, canceled, value, velocity);
+            public void run() {
                 xLatch.countDown();
             }
         });
 
         final CountDownLatch yLatch = new CountDownLatch(1);
-        final OneTimeEndListener yEndListener = Mockito.spy(new OneTimeEndListener() {
+        Runnable yEndAction = Mockito.spy(new Runnable() {
+
             @Override
-            public void onAnimationEnd(DynamicAnimation animation, boolean canceled, float value,
-                    float velocity) {
-                super.onAnimationEnd(animation, canceled, value, velocity);
+            public void run() {
                 yLatch.countDown();
             }
         });
 
         // Set end listeners for both x and y.
-        mLayout.setEndListenerForProperty(xEndListener, DynamicAnimation.TRANSLATION_X);
-        mLayout.setEndListenerForProperty(yEndListener, DynamicAnimation.TRANSLATION_Y);
+        mLayout.setEndActionForProperty(xEndAction, DynamicAnimation.TRANSLATION_X);
+        mLayout.setEndActionForProperty(yEndAction, DynamicAnimation.TRANSLATION_Y);
 
         // Animate x, and wait for it to finish.
-        mLayout.animateValueForChildAtIndex(
-                DynamicAnimation.TRANSLATION_X,
-                0,
-                100);
+        mTestableController.animationForChildAtIndex(0)
+                .translationX(100)
+                .start();
+
         xLatch.await();
         yLatch.await(1, TimeUnit.SECONDS);
 
         // Make sure the x end listener was called only one time, and the y listener was never
         // called since we didn't animate y. Wait 1 second after the original animation end trigger
         // to make sure it doesn't get called again.
-        Mockito.verify(xEndListener, Mockito.after(1000).times(1))
-                .onAnimationEnd(
-                        any(),
-                        eq(false),
-                        eq(100f),
-                        anyFloat());
-        Mockito.verify(yEndListener, Mockito.after(1000).never())
-                .onAnimationEnd(any(), anyBoolean(), anyFloat(), anyFloat());
+        Mockito.verify(xEndAction, Mockito.after(1000).times(1)).run();
+        Mockito.verify(yEndAction, Mockito.after(1000).never()).run();
     }
 
     @Test
@@ -203,39 +193,31 @@
         mTestableController.setChainedProperties(Sets.newHashSet());
 
         final CountDownLatch xLatch = new CountDownLatch(1);
-        OneTimeEndListener xEndListener = Mockito.spy(new OneTimeEndListener() {
+        Runnable xEndListener = Mockito.spy(new Runnable() {
+
             @Override
-            public void onAnimationEnd(DynamicAnimation animation, boolean canceled, float value,
-                    float velocity) {
-                super.onAnimationEnd(animation, canceled, value, velocity);
+            public void run() {
                 xLatch.countDown();
             }
         });
 
         // Set the end listener.
-        mLayout.setEndListenerForProperty(xEndListener, DynamicAnimation.TRANSLATION_X);
+        mLayout.setEndActionForProperty(xEndListener, DynamicAnimation.TRANSLATION_X);
 
         // Animate x, and wait for it to finish.
-        mLayout.animateValueForChildAtIndex(
-                DynamicAnimation.TRANSLATION_X,
-                0,
-                100);
+        mTestableController.animationForChildAtIndex(0)
+                .translationX(100)
+                .start();
         xLatch.await();
 
         InOrder endListenerCalls = inOrder(xEndListener);
-        endListenerCalls.verify(xEndListener, Mockito.times(1))
-                .onAnimationEnd(
-                        any(),
-                        eq(false),
-                        eq(100f),
-                        anyFloat());
+        endListenerCalls.verify(xEndListener, Mockito.times(1)).run();
 
         // Animate X again, remove the end listener.
-        mLayout.animateValueForChildAtIndex(
-                DynamicAnimation.TRANSLATION_X,
-                0,
-                1000);
-        mLayout.removeEndListenerForProperty(DynamicAnimation.TRANSLATION_X);
+        mTestableController.animationForChildAtIndex(0)
+                .translationX(1000)
+                .start();
+        mLayout.removeEndActionForProperty(DynamicAnimation.TRANSLATION_X);
         xLatch.await(1, TimeUnit.SECONDS);
 
         // Make sure the end listener was not called.
@@ -261,10 +243,9 @@
         secondController.setRemoveImmediately(true);
 
         mLayout.setController(secondController);
-        mLayout.animateValueForChildAtIndex(
-                DynamicAnimation.SCALE_X,
-                0,
-                1.5f);
+        mTestableController.animationForChildAtIndex(0)
+                .scaleX(1.5f)
+                .start();
 
         waitForPropertyAnimations(DynamicAnimation.SCALE_X);
 
@@ -285,10 +266,9 @@
                 .getOffsetForChainedPropertyAnimation(eq(DynamicAnimation.SCALE_X));
 
         mLayout.setController(mTestableController);
-        mLayout.animateValueForChildAtIndex(
-                DynamicAnimation.TRANSLATION_X,
-                0,
-                100f);
+        mTestableController.animationForChildAtIndex(0)
+                .translationX(100f)
+                .start();
 
         waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X);
 
@@ -308,10 +288,9 @@
         assertFalse(mLayout.arePropertiesAnimating(
                 DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y));
 
-        mLayout.animateValueForChildAtIndex(
-                DynamicAnimation.TRANSLATION_X,
-                0,
-                100);
+        mTestableController.animationForChildAtIndex(0)
+                .translationX(100f)
+                .start();
 
         // Wait for the animations to get underway.
         SystemClock.sleep(50);
@@ -330,14 +309,9 @@
         mLayout.setController(mTestableController);
         addOneMoreThanRenderLimitBubbles();
 
-        mLayout.animateValueForChildAtIndex(
-                DynamicAnimation.TRANSLATION_X,
-                0,
-                1000);
-        mLayout.animateValueForChildAtIndex(
-                DynamicAnimation.TRANSLATION_Y,
-                0,
-                1000);
+        mTestableController.animationForChildAtIndex(0)
+                .position(1000, 1000)
+                .start();
 
         mLayout.cancelAllAnimations();
 
@@ -367,13 +341,15 @@
 
     /** Standard test of chained translation animations. */
     private void testChainedTranslationAnimations() throws InterruptedException {
+        mLayout.setController(mTestableController);
+        addOneMoreThanRenderLimitBubbles();
+
         assertEquals(0, mLayout.getChildAt(0).getTranslationX(), .1f);
         assertEquals(0, mLayout.getChildAt(1).getTranslationX(), .1f);
 
-        mLayout.animateValueForChildAtIndex(
-                DynamicAnimation.TRANSLATION_X,
-                0,
-                100);
+        mTestableController.animationForChildAtIndex(0)
+                .translationX(100f)
+                .start();
 
         waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X);
 
@@ -392,10 +368,9 @@
         assertEquals(0, mLayout.getChildAt(1).getTranslationY(), .1f);
 
         // Animate the first child's Y translation.
-        mLayout.animateValueForChildAtIndex(
-                DynamicAnimation.TRANSLATION_Y,
-                0,
-                100);
+        mTestableController.animationForChildAtIndex(0)
+                .translationY(100f)
+                .start();
 
         waitForPropertyAnimations(DynamicAnimation.TRANSLATION_Y);
 
@@ -405,6 +380,75 @@
         assertEquals(0, mLayout.getChildAt(1).getTranslationY(), .1f);
     }
 
+    @Test
+    public void testPhysicsAnimator() throws InterruptedException {
+        mLayout.setController(mTestableController);
+        addOneMoreThanRenderLimitBubbles();
+
+        Runnable afterAll = Mockito.mock(Runnable.class);
+        Runnable after = Mockito.spy(new Runnable() {
+            int mCallCount = 0;
+
+            @Override
+            public void run() {
+                // Make sure that if only one of the animations has finished, we didn't already call
+                // afterAll.
+                if (mCallCount == 1) {
+                    Mockito.verifyNoMoreInteractions(afterAll);
+                }
+            }
+        });
+
+        // Animate from x = 7 to x = 100, and from y = 100 to 7 = 200, calling 'after' after each
+        // property's animation completes, then call afterAll when they're all complete.
+        mTestableController.animationForChildAtIndex(0)
+                .translationX(7, 100, after)
+                .translationY(100, 200, after)
+                .start(afterAll);
+
+        // We should have immediately set the 'from' values.
+        assertEquals(7, mViews.get(0).getTranslationX(), .01f);
+        assertEquals(100, mViews.get(0).getTranslationY(), .01f);
+
+        waitForPropertyAnimations(
+                DynamicAnimation.TRANSLATION_X,
+                DynamicAnimation.TRANSLATION_Y);
+
+        // We should have called the after callback twice, and afterAll once. We verify in the
+        // mocked callback that afterAll isn't called before both finish.
+        Mockito.verify(after, times(2)).run();
+        Mockito.verify(afterAll).run();
+
+        // Make sure we actually animated the views.
+        assertEquals(100, mViews.get(0).getTranslationX(), .01f);
+        assertEquals(200, mViews.get(0).getTranslationY(), .01f);
+    }
+
+    @Test
+    public void testAnimationsForChildrenFromIndex() throws InterruptedException {
+        // Don't chain since we're going to invoke each animation independently.
+        mTestableController.setChainedProperties(new HashSet<>());
+
+        mLayout.setController(mTestableController);
+
+        addOneMoreThanRenderLimitBubbles();
+
+        Runnable allEnd = Mockito.mock(Runnable.class);
+
+        mTestableController.animationsForChildrenFromIndex(
+                1, (index, animation) -> animation.translationX((index - 1) * 50))
+            .startAll(allEnd);
+
+        waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X);
+
+        assertEquals(0, mViews.get(0).getTranslationX(), .1f);
+        assertEquals(0, mViews.get(1).getTranslationX(), .1f);
+        assertEquals(50, mViews.get(2).getTranslationX(), .1f);
+        assertEquals(100, mViews.get(3).getTranslationX(), .1f);
+
+        Mockito.verify(allEnd, times(1)).run();
+    }
+
     /**
      * Animation controller with configuration methods whose return values can be set by individual
      * tests.
diff --git a/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayoutTestCase.java b/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayoutTestCase.java
index d94b669..9fce092 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayoutTestCase.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayoutTestCase.java
@@ -28,6 +28,7 @@
 import android.widget.FrameLayout;
 
 import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.dynamicanimation.animation.SpringForce;
 
 import com.android.systemui.R;
 import com.android.systemui.SysuiTestCase;
@@ -38,6 +39,7 @@
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Set;
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 
@@ -113,25 +115,17 @@
             throws InterruptedException {
         final CountDownLatch animLatch = new CountDownLatch(properties.length);
         for (DynamicAnimation.ViewProperty property : properties) {
-            mLayout.setTestEndListenerForProperty(new OneTimeEndListener() {
-                @Override
-                public void onAnimationEnd(DynamicAnimation animation, boolean canceled,
-                        float value,
-                        float velocity) {
-                    super.onAnimationEnd(animation, canceled, value, velocity);
-                    animLatch.countDown();
-                }
-            }, property);
+            mLayout.setTestEndActionForProperty(animLatch::countDown, property);
         }
-        animLatch.await(1, TimeUnit.SECONDS);
+
+        animLatch.await(2, TimeUnit.SECONDS);
     }
 
-    /** Uses a latch to wait for the message queue to finish. */
+    /** Uses a latch to wait for the main thread message queue to finish. */
     void waitForLayoutMessageQueue() throws InterruptedException {
-        // Wait for layout, then the view should be actually removed.
         CountDownLatch layoutLatch = new CountDownLatch(1);
         mMainThreadHandler.post(layoutLatch::countDown);
-        layoutLatch.await(1, TimeUnit.SECONDS);
+        layoutLatch.await(2, TimeUnit.SECONDS);
     }
 
     /**
@@ -145,8 +139,9 @@
 
         @Override
         public void setController(PhysicsAnimationController controller) {
-            mMainThreadHandler.post(() -> super.setController(controller));
-            waitForMessageQueueAndIgnoreIfInterrupted();
+            runOnMainThreadAndBlock(
+                    () -> super.setController(
+                            new MainThreadAnimationControllerWrapper(controller)));
         }
 
         @Override
@@ -160,59 +155,139 @@
         }
 
         @Override
-        protected void animateValueForChildAtIndex(DynamicAnimation.ViewProperty property,
-                int index, float value, float startVel, Runnable after) {
-            mMainThreadHandler.post(() ->
-                    super.animateValueForChildAtIndex(property, index, value, startVel, after));
-        }
-
-        @Override
         public WindowInsets getRootWindowInsets() {
             return mWindowInsets;
         }
 
         @Override
-        public void removeView(View view) {
-            mMainThreadHandler.post(() ->
-                    super.removeView(view));
-            waitForMessageQueueAndIgnoreIfInterrupted();
+        public void addView(View child, int index) {
+            child.setTag(R.id.physics_animator_tag, new TestablePhysicsPropertyAnimator(child));
+            super.addView(child, index);
         }
 
         @Override
         public void addView(View child, int index, ViewGroup.LayoutParams params) {
-            mMainThreadHandler.post(() ->
-                    super.addView(child, index, params));
-            waitForMessageQueueAndIgnoreIfInterrupted();
+            child.setTag(R.id.physics_animator_tag, new TestablePhysicsPropertyAnimator(child));
+            super.addView(child, index, params);
         }
 
         /**
-         * Wait for the queue but just catch and print the exception if interrupted, since we can't
-         * just add the exception to the overridden methods' signatures.
+         * Sets an end action that will be called after the 'real' end action that was already set.
          */
-        private void waitForMessageQueueAndIgnoreIfInterrupted() {
-            try {
-                waitForLayoutMessageQueue();
-            } catch (InterruptedException e) {
-                e.printStackTrace();
+        private void setTestEndActionForProperty(
+                Runnable action, DynamicAnimation.ViewProperty property) {
+            final Runnable realEndAction = mEndActionForProperty.get(property);
+
+            setEndActionForProperty(() -> {
+                if (realEndAction != null) {
+                    realEndAction.run();
+                }
+
+                action.run();
+            }, property);
+        }
+
+        /** PhysicsPropertyAnimator that posts its animations to the main thread. */
+        protected class TestablePhysicsPropertyAnimator extends PhysicsPropertyAnimator {
+            public TestablePhysicsPropertyAnimator(View view) {
+                super(view);
+            }
+
+            @Override
+            protected void animateValueForChild(DynamicAnimation.ViewProperty property, View view,
+                    float value, float startVel, long startDelay, Runnable[] afterCallbacks) {
+                mMainThreadHandler.post(() -> super.animateValueForChild(
+                        property, view, value, startVel, startDelay, afterCallbacks));
             }
         }
 
         /**
-         * Sets an end listener that will be called after the 'real' end listener that was already
-         * set.
+         * Wrapper around an animation controller that dispatches methods that could start
+         * animations to the main thread.
          */
-        private void setTestEndListenerForProperty(DynamicAnimation.OnAnimationEndListener listener,
-                DynamicAnimation.ViewProperty property) {
-            final DynamicAnimation.OnAnimationEndListener realEndListener =
-                    mEndListenerForProperty.get(property);
+        protected class MainThreadAnimationControllerWrapper extends PhysicsAnimationController {
 
-            setEndListenerForProperty((animation, canceled, value, velocity) -> {
-                if (realEndListener != null) {
-                    realEndListener.onAnimationEnd(animation, canceled, value, velocity);
+            private final PhysicsAnimationController mWrappedController;
+
+            protected MainThreadAnimationControllerWrapper(PhysicsAnimationController controller) {
+                mWrappedController = controller;
+            }
+
+            @Override
+            protected void setLayout(PhysicsAnimationLayout layout) {
+                mWrappedController.setLayout(layout);
+            }
+
+            @Override
+            protected PhysicsAnimationLayout getLayout() {
+                return mWrappedController.getLayout();
+            }
+
+            @Override
+            Set<DynamicAnimation.ViewProperty> getAnimatedProperties() {
+                return mWrappedController.getAnimatedProperties();
+            }
+
+            @Override
+            int getNextAnimationInChain(DynamicAnimation.ViewProperty property, int index) {
+                return mWrappedController.getNextAnimationInChain(property, index);
+            }
+
+            @Override
+            float getOffsetForChainedPropertyAnimation(DynamicAnimation.ViewProperty property) {
+                return mWrappedController.getOffsetForChainedPropertyAnimation(property);
+            }
+
+            @Override
+            SpringForce getSpringForce(DynamicAnimation.ViewProperty property, View view) {
+                return mWrappedController.getSpringForce(property, view);
+            }
+
+            @Override
+            void onChildAdded(View child, int index) {
+                runOnMainThreadAndBlock(() -> mWrappedController.onChildAdded(child, index));
+            }
+
+            @Override
+            void onChildRemoved(View child, int index, Runnable finishRemoval) {
+                runOnMainThreadAndBlock(
+                        () -> mWrappedController.onChildRemoved(child, index, finishRemoval));
+            }
+
+            @Override
+            protected void setChildVisibility(View child, int index, int visibility) {
+                mWrappedController.setChildVisibility(child, index, visibility);
+            }
+
+            @Override
+            protected PhysicsPropertyAnimator animationForChild(View child) {
+                PhysicsPropertyAnimator animator =
+                        (PhysicsPropertyAnimator) child.getTag(R.id.physics_animator_tag);
+
+                if (!(animator instanceof TestablePhysicsPropertyAnimator)) {
+                    animator = new TestablePhysicsPropertyAnimator(child);
+                    child.setTag(R.id.physics_animator_tag, animator);
                 }
 
-                listener.onAnimationEnd(animation, canceled, value, velocity);
-            }, property);
+                return animator;
+            }
+        }
+    }
+
+    /**
+     * Posts the given Runnable on the main thread, and blocks the calling thread until it's run.
+     */
+    private void runOnMainThreadAndBlock(Runnable action) {
+        final CountDownLatch latch = new CountDownLatch(1);
+        mMainThreadHandler.post(() -> {
+            action.run();
+            latch.countDown();
+        });
+
+        try {
+            latch.await(5, TimeUnit.SECONDS);
+        } catch (InterruptedException e) {
+            e.printStackTrace();
         }
     }
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/StackAnimationControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/StackAnimationControllerTest.java
index 2ee73f3..096f205 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/StackAnimationControllerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/StackAnimationControllerTest.java
@@ -209,6 +209,9 @@
         final PointF prevStackPos = mStackController.getStackPosition();
 
         mLayout.removeAllViews();
+
+        waitForLayoutMessageQueue();
+
         mLayout.addView(new FrameLayout(getContext()));
 
         waitForLayoutMessageQueue();