1 /*
2  * Copyright (C) 2019 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.bubbles.animation;
18 
19 import android.content.res.Resources;
20 import android.graphics.PointF;
21 import android.graphics.RectF;
22 import android.util.Log;
23 import android.view.View;
24 import android.view.WindowInsets;
25 
26 import androidx.annotation.Nullable;
27 import androidx.dynamicanimation.animation.DynamicAnimation;
28 import androidx.dynamicanimation.animation.FlingAnimation;
29 import androidx.dynamicanimation.animation.FloatPropertyCompat;
30 import androidx.dynamicanimation.animation.SpringAnimation;
31 import androidx.dynamicanimation.animation.SpringForce;
32 
33 import com.android.systemui.R;
34 
35 import com.google.android.collect.Sets;
36 
37 import java.io.FileDescriptor;
38 import java.io.PrintWriter;
39 import java.util.HashMap;
40 import java.util.Set;
41 
42 /**
43  * Animation controller for bubbles when they're in their stacked state. Stacked bubbles sit atop
44  * each other with a slight offset to the left or right (depending on which side of the screen they
45  * are on). Bubbles 'follow' each other when dragged, and can be flung to the left or right sides of
46  * the screen.
47  */
48 public class StackAnimationController extends
49         PhysicsAnimationLayout.PhysicsAnimationController {
50 
51     private static final String TAG = "Bubbs.StackCtrl";
52 
53     /** Scale factor to use initially for new bubbles being animated in. */
54     private static final float ANIMATE_IN_STARTING_SCALE = 1.15f;
55 
56     /** Translation factor (multiplied by stack offset) to use for bubbles being animated in/out. */
57     private static final int ANIMATE_TRANSLATION_FACTOR = 4;
58 
59     /** Values to use for animating bubbles in. */
60     private static final float ANIMATE_IN_STIFFNESS = 1000f;
61     private static final int ANIMATE_IN_START_DELAY = 25;
62 
63     /**
64      * Values to use for the default {@link SpringForce} provided to the physics animation layout.
65      */
66     private static final int DEFAULT_STIFFNESS = 12000;
67     private static final int FLING_FOLLOW_STIFFNESS = 20000;
68     private static final float DEFAULT_BOUNCINESS = 0.9f;
69 
70     /**
71      * Friction applied to fling animations. Since the stack must land on one of the sides of the
72      * screen, we want less friction horizontally so that the stack has a better chance of making it
73      * to the side without needing a spring.
74      */
75     private static final float FLING_FRICTION_X = 2.2f;
76     private static final float FLING_FRICTION_Y = 2.2f;
77 
78     /**
79      * Values to use for the stack spring animation used to spring the stack to its final position
80      * after a fling.
81      */
82     private static final int SPRING_AFTER_FLING_STIFFNESS = 750;
83     private static final float SPRING_AFTER_FLING_DAMPING_RATIO = 0.85f;
84 
85     /**
86      * Minimum fling velocity required to trigger moving the stack from one side of the screen to
87      * the other.
88      */
89     private static final float ESCAPE_VELOCITY = 750f;
90 
91     /**
92      * The canonical position of the stack. This is typically the position of the first bubble, but
93      * we need to keep track of it separately from the first bubble's translation in case there are
94      * no bubbles, or the first bubble was just added and being animated to its new position.
95      */
96     private PointF mStackPosition = new PointF(-1, -1);
97 
98     /** Whether or not the stack's start position has been set. */
99     private boolean mStackMovedToStartPosition = false;
100 
101     /** The most recent position in which the stack was resting on the edge of the screen. */
102     @Nullable private PointF mRestingStackPosition;
103 
104     /** The height of the most recently visible IME. */
105     private float mImeHeight = 0f;
106 
107     /**
108      * The Y position of the stack before the IME became visible, or {@link Float#MIN_VALUE} if the
109      * IME is not visible or the user moved the stack since the IME became visible.
110      */
111     private float mPreImeY = Float.MIN_VALUE;
112 
113     /**
114      * Animations on the stack position itself, which would have been started in
115      * {@link #flingThenSpringFirstBubbleWithStackFollowing}. These animations dispatch to
116      * {@link #moveFirstBubbleWithStackFollowing} to move the entire stack (with 'following' effect)
117      * to a legal position on the side of the screen.
118      */
119     private HashMap<DynamicAnimation.ViewProperty, DynamicAnimation> mStackPositionAnimations =
120             new HashMap<>();
121 
122     /**
123      * Whether the current motion of the stack is due to a fling animation (vs. being dragged
124      * manually).
125      */
126     private boolean mIsMovingFromFlinging = false;
127 
128     /**
129      * Whether the stack is within the dismiss target (either by being dragged, magnet'd, or flung).
130      */
131     private boolean mWithinDismissTarget = false;
132 
133     /**
134      * Whether the first bubble is springing towards the touch point, rather than using the default
135      * behavior of moving directly to the touch point with the rest of the stack following it.
136      *
137      * This happens when the user's finger exits the dismiss area while the stack is magnetized to
138      * the center. Since the touch point differs from the stack location, we need to animate the
139      * stack back to the touch point to avoid a jarring instant location change from the center of
140      * the target to the touch point just outside the target bounds.
141      *
142      * This is reset once the spring animations end, since that means the first bubble has
143      * successfully 'caught up' to the touch.
144      */
145     private boolean mFirstBubbleSpringingToTouch = false;
146 
147     /** Horizontal offset of bubbles in the stack. */
148     private float mStackOffset;
149     /** Diameter of the bubble icon. */
150     private int mBubbleIconBitmapSize;
151     /** Width of the bubble (icon and padding). */
152     private int mBubbleSize;
153     /**
154      * The amount of space to add between the bubbles and certain UI elements, such as the top of
155      * the screen or the IME. This does not apply to the left/right sides of the screen since the
156      * stack goes offscreen intentionally.
157      */
158     private int mBubblePaddingTop;
159     /** How far offscreen the stack rests. */
160     private int mBubbleOffscreen;
161     /** How far down the screen the stack starts, when there is no pre-existing location. */
162     private int mStackStartingVerticalOffset;
163     /** Height of the status bar. */
164     private float mStatusBarHeight;
165 
166     /**
167      * Instantly move the first bubble to the given point, and animate the rest of the stack behind
168      * it with the 'following' effect.
169      */
moveFirstBubbleWithStackFollowing(float x, float y)170     public void moveFirstBubbleWithStackFollowing(float x, float y) {
171         // If we manually move the bubbles with the IME open, clear the return point since we don't
172         // want the stack to snap away from the new position.
173         mPreImeY = Float.MIN_VALUE;
174 
175         moveFirstBubbleWithStackFollowing(DynamicAnimation.TRANSLATION_X, x);
176         moveFirstBubbleWithStackFollowing(DynamicAnimation.TRANSLATION_Y, y);
177 
178         // This method is called when the stack is being dragged manually, so we're clearly no
179         // longer flinging.
180         mIsMovingFromFlinging = false;
181     }
182 
183     /**
184      * The position of the stack - typically the position of the first bubble; if no bubbles have
185      * been added yet, it will be where the first bubble will go when added.
186      */
getStackPosition()187     public PointF getStackPosition() {
188         return mStackPosition;
189     }
190 
191     /** Whether the stack is on the left side of the screen. */
isStackOnLeftSide()192     public boolean isStackOnLeftSide() {
193         if (mLayout == null || !isStackPositionSet()) {
194             return false;
195         }
196 
197         float stackCenter = mStackPosition.x + mBubbleIconBitmapSize / 2;
198         float screenCenter = mLayout.getWidth() / 2;
199         return stackCenter < screenCenter;
200     }
201 
202     /**
203      * Fling stack to given corner, within allowable screen bounds.
204      * Note that we need new SpringForce instances per animation despite identical configs because
205      * SpringAnimation uses SpringForce's internal (changing) velocity while the animation runs.
206      */
springStack(float destinationX, float destinationY)207     public void springStack(float destinationX, float destinationY) {
208         springFirstBubbleWithStackFollowing(DynamicAnimation.TRANSLATION_X,
209                 new SpringForce()
210                         .setStiffness(SPRING_AFTER_FLING_STIFFNESS)
211                         .setDampingRatio(SPRING_AFTER_FLING_DAMPING_RATIO),
212                 0 /* startXVelocity */,
213                 destinationX);
214 
215         springFirstBubbleWithStackFollowing(DynamicAnimation.TRANSLATION_Y,
216                 new SpringForce()
217                         .setStiffness(SPRING_AFTER_FLING_STIFFNESS)
218                         .setDampingRatio(SPRING_AFTER_FLING_DAMPING_RATIO),
219                 0 /* startYVelocity */,
220                 destinationY);
221     }
222 
223     /**
224      * Flings the stack starting with the given velocities, springing it to the nearest edge
225      * afterward.
226      *
227      * @return The X value that the stack will end up at after the fling/spring.
228      */
flingStackThenSpringToEdge(float x, float velX, float velY)229     public float flingStackThenSpringToEdge(float x, float velX, float velY) {
230         final boolean stackOnLeftSide = x - mBubbleIconBitmapSize / 2 < mLayout.getWidth() / 2;
231 
232         final boolean stackShouldFlingLeft = stackOnLeftSide
233                 ? velX < ESCAPE_VELOCITY
234                 : velX < -ESCAPE_VELOCITY;
235 
236         final RectF stackBounds = getAllowableStackPositionRegion();
237 
238         // Target X translation (either the left or right side of the screen).
239         final float destinationRelativeX = stackShouldFlingLeft
240                 ? stackBounds.left : stackBounds.right;
241 
242         // If all bubbles were removed during a drag event, just return the X we would have animated
243         // to if there were still bubbles.
244         if (mLayout == null || mLayout.getChildCount() == 0) {
245             return destinationRelativeX;
246         }
247 
248         // Minimum velocity required for the stack to make it to the targeted side of the screen,
249         // taking friction into account (4.2f is the number that friction scalars are multiplied by
250         // in DynamicAnimation.DragForce). This is an estimate - it could possibly be slightly off,
251         // but the SpringAnimation at the end will ensure that it reaches the destination X
252         // regardless.
253         final float minimumVelocityToReachEdge =
254                 (destinationRelativeX - x) * (FLING_FRICTION_X * 4.2f);
255 
256         // Use the touch event's velocity if it's sufficient, otherwise use the minimum velocity so
257         // that it'll make it all the way to the side of the screen.
258         final float startXVelocity = stackShouldFlingLeft
259                 ? Math.min(minimumVelocityToReachEdge, velX)
260                 : Math.max(minimumVelocityToReachEdge, velX);
261 
262         flingThenSpringFirstBubbleWithStackFollowing(
263                 DynamicAnimation.TRANSLATION_X,
264                 startXVelocity,
265                 FLING_FRICTION_X,
266                 new SpringForce()
267                         .setStiffness(SPRING_AFTER_FLING_STIFFNESS)
268                         .setDampingRatio(SPRING_AFTER_FLING_DAMPING_RATIO),
269                 destinationRelativeX);
270 
271         flingThenSpringFirstBubbleWithStackFollowing(
272                 DynamicAnimation.TRANSLATION_Y,
273                 velY,
274                 FLING_FRICTION_Y,
275                 new SpringForce()
276                         .setStiffness(SPRING_AFTER_FLING_STIFFNESS)
277                         .setDampingRatio(SPRING_AFTER_FLING_DAMPING_RATIO),
278                 /* destination */ null);
279 
280         // If we're flinging now, there's no more touch event to catch up to.
281         mFirstBubbleSpringingToTouch = false;
282         mIsMovingFromFlinging = true;
283         return destinationRelativeX;
284     }
285 
286     /**
287      * Where the stack would be if it were snapped to the nearest horizontal edge (left or right).
288      */
289     public PointF getStackPositionAlongNearestHorizontalEdge() {
290         final PointF stackPos = getStackPosition();
291         final boolean onLeft = mLayout.isFirstChildXLeftOfCenter(stackPos.x);
292         final RectF bounds = getAllowableStackPositionRegion();
293 
294         stackPos.x = onLeft ? bounds.left : bounds.right;
295         return stackPos;
296     }
297 
298     /**
299      * Moves the stack in response to rotation. We keep it in the most similar position by keeping
300      * it on the same side, and positioning it the same percentage of the way down the screen
301      * (taking status bar/nav bar into account by using the allowable region's height).
302      */
303     public void moveStackToSimilarPositionAfterRotation(boolean wasOnLeft, float verticalPercent) {
304         final RectF allowablePos = getAllowableStackPositionRegion();
305         final float allowableRegionHeight = allowablePos.bottom - allowablePos.top;
306 
307         final float x = wasOnLeft ? allowablePos.left : allowablePos.right;
308         final float y = (allowableRegionHeight * verticalPercent) + allowablePos.top;
309 
310         setStackPosition(new PointF(x, y));
311     }
312 
313     /** Description of current animation controller state. */
314     public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
315         pw.println("StackAnimationController state:");
316         pw.print("  isActive:             "); pw.println(isActiveController());
317         pw.print("  restingStackPos:      ");
318         pw.println(mRestingStackPosition != null ? mRestingStackPosition.toString() : "null");
319         pw.print("  currentStackPos:      "); pw.println(mStackPosition.toString());
320         pw.print("  isMovingFromFlinging: "); pw.println(mIsMovingFromFlinging);
321         pw.print("  withinDismiss:        "); pw.println(mWithinDismissTarget);
322         pw.print("  firstBubbleSpringing: "); pw.println(mFirstBubbleSpringingToTouch);
323     }
324 
325     /**
326      * Flings the first bubble along the given property's axis, using the provided configuration
327      * values. When the animation ends - either by hitting the min/max, or by friction sufficiently
328      * reducing momentum - a SpringAnimation takes over to snap the bubble to the given final
329      * position.
330      */
331     protected void flingThenSpringFirstBubbleWithStackFollowing(
332             DynamicAnimation.ViewProperty property,
333             float vel,
334             float friction,
335             SpringForce spring,
336             Float finalPosition) {
337         Log.d(TAG, String.format("Flinging %s.",
338                 PhysicsAnimationLayout.getReadablePropertyName(property)));
339 
340         StackPositionProperty firstBubbleProperty = new StackPositionProperty(property);
341         final float currentValue = firstBubbleProperty.getValue(this);
342         final RectF bounds = getAllowableStackPositionRegion();
343         final float min =
344                 property.equals(DynamicAnimation.TRANSLATION_X)
345                         ? bounds.left
346                         : bounds.top;
347         final float max =
348                 property.equals(DynamicAnimation.TRANSLATION_X)
349                         ? bounds.right
350                         : bounds.bottom;
351 
352         FlingAnimation flingAnimation = new FlingAnimation(this, firstBubbleProperty);
353         flingAnimation.setFriction(friction)
354                 .setStartVelocity(vel)
355 
356                 // If the bubble's property value starts beyond the desired min/max, use that value
357                 // instead so that the animation won't immediately end. If, for example, the user
358                 // drags the bubbles into the navigation bar, but then flings them upward, we want
359                 // the fling to occur despite temporarily having a value outside of the min/max. If
360                 // the bubbles are out of bounds and flung even farther out of bounds, the fling
361                 // animation will halt immediately and the SpringAnimation will take over, springing
362                 // it in reverse to the (legal) final position.
363                 .setMinValue(Math.min(currentValue, min))
364                 .setMaxValue(Math.max(currentValue, max))
365 
366                 .addEndListener((animation, canceled, endValue, endVelocity) -> {
367                     if (!canceled) {
368                         mRestingStackPosition = new PointF();
369                         mRestingStackPosition.set(mStackPosition);
370 
371                         springFirstBubbleWithStackFollowing(property, spring, endVelocity,
372                                 finalPosition != null
373                                         ? finalPosition
374                                         : Math.max(min, Math.min(max, endValue)));
375                     }
376                 });
377 
378         cancelStackPositionAnimation(property);
379         mStackPositionAnimations.put(property, flingAnimation);
380         flingAnimation.start();
381     }
382 
383     /**
384      * Cancel any stack position animations that were started by calling
385      * @link #flingThenSpringFirstBubbleWithStackFollowing}, and remove any corresponding end
386      * listeners.
387      */
388     public void cancelStackPositionAnimations() {
389         cancelStackPositionAnimation(DynamicAnimation.TRANSLATION_X);
390         cancelStackPositionAnimation(DynamicAnimation.TRANSLATION_Y);
391 
392         removeEndActionForProperty(DynamicAnimation.TRANSLATION_X);
393         removeEndActionForProperty(DynamicAnimation.TRANSLATION_Y);
394     }
395 
396     /** Save the current IME height so that we know where the stack bounds should be. */
397     public void setImeHeight(int imeHeight) {
398         mImeHeight = imeHeight;
399     }
400 
401     /**
402      * Animates the stack either away from the newly visible IME, or back to its original position
403      * due to the IME going away.
404      */
405     public void animateForImeVisibility(boolean imeVisible) {
406         final float maxBubbleY = getAllowableStackPositionRegion().bottom;
407         float destinationY = Float.MIN_VALUE;
408 
409         if (imeVisible) {
410             // Stack is lower than it should be and overlaps the now-visible IME.
411             if (mStackPosition.y > maxBubbleY && mPreImeY == Float.MIN_VALUE) {
412                 mPreImeY = mStackPosition.y;
413                 destinationY = maxBubbleY;
414             }
415         } else {
416             if (mPreImeY > Float.MIN_VALUE) {
417                 destinationY = mPreImeY;
418                 mPreImeY = Float.MIN_VALUE;
419             }
420         }
421 
422         if (destinationY > Float.MIN_VALUE) {
423             springFirstBubbleWithStackFollowing(
424                     DynamicAnimation.TRANSLATION_Y,
425                     getSpringForce(DynamicAnimation.TRANSLATION_Y, /* view */ null)
426                             .setStiffness(SpringForce.STIFFNESS_LOW),
427                     /* startVel */ 0f,
428                     destinationY);
429         }
430     }
431 
432     /**
433      * Returns the region within which the stack is allowed to rest. This goes slightly off the left
434      * and right sides of the screen, below the status bar/cutout and above the navigation bar.
435      * While the stack is not allowed to rest outside of these bounds, it can temporarily be
436      * animated or dragged beyond them.
437      */
438     public RectF getAllowableStackPositionRegion() {
439         final WindowInsets insets = mLayout.getRootWindowInsets();
440         final RectF allowableRegion = new RectF();
441         if (insets != null) {
442             allowableRegion.left =
443                     -mBubbleOffscreen
444                             + Math.max(
445                             insets.getSystemWindowInsetLeft(),
446                             insets.getDisplayCutout() != null
447                                     ? insets.getDisplayCutout().getSafeInsetLeft()
448                                     : 0);
449             allowableRegion.right =
450                     mLayout.getWidth()
451                             - mBubbleSize
452                             + mBubbleOffscreen
453                             - Math.max(
454                             insets.getSystemWindowInsetRight(),
455                             insets.getDisplayCutout() != null
456                                     ? insets.getDisplayCutout().getSafeInsetRight()
457                                     : 0);
458 
459             allowableRegion.top =
460                     mBubblePaddingTop
461                             + Math.max(
462                             mStatusBarHeight,
463                             insets.getDisplayCutout() != null
464                                     ? insets.getDisplayCutout().getSafeInsetTop()
465                                     : 0);
466             allowableRegion.bottom =
467                     mLayout.getHeight()
468                             - mBubbleSize
469                             - mBubblePaddingTop
470                             - (mImeHeight > Float.MIN_VALUE ? mImeHeight + mBubblePaddingTop : 0f)
471                             - Math.max(
472                             insets.getSystemWindowInsetBottom(),
473                             insets.getDisplayCutout() != null
474                                     ? insets.getDisplayCutout().getSafeInsetBottom()
475                                     : 0);
476         }
477 
478         return allowableRegion;
479     }
480 
481     /** Moves the stack in response to a touch event. */
482     public void moveStackFromTouch(float x, float y) {
483 
484         // If we're springing to the touch point to 'catch up' after dragging out of the dismiss
485         // target, then update the stack position animations instead of moving the bubble directly.
486         if (mFirstBubbleSpringingToTouch) {
487             final SpringAnimation springToTouchX =
488                     (SpringAnimation) mStackPositionAnimations.get(DynamicAnimation.TRANSLATION_X);
489             final SpringAnimation springToTouchY =
490                     (SpringAnimation) mStackPositionAnimations.get(DynamicAnimation.TRANSLATION_Y);
491 
492             // If either animation is still running, we haven't caught up. Update the animations.
493             if (springToTouchX.isRunning() || springToTouchY.isRunning()) {
494                 springToTouchX.animateToFinalPosition(x);
495                 springToTouchY.animateToFinalPosition(y);
496             } else {
497                 // If the animations have finished, the stack is now at the touch point. We can
498                 // resume moving the bubble directly.
499                 mFirstBubbleSpringingToTouch = false;
500             }
501         }
502 
503         if (!mFirstBubbleSpringingToTouch && !mWithinDismissTarget) {
504             moveFirstBubbleWithStackFollowing(x, y);
505         }
506     }
507 
508     /**
509      * Demagnetizes the stack, springing it towards the given point. This also sets flags so that
510      * subsequent touch events will update the final position of the demagnetization spring instead
511      * of directly moving the bubbles, until demagnetization is complete.
512      */
513     public void demagnetizeFromDismissToPoint(float x, float y, float velX, float velY) {
514         mWithinDismissTarget = false;
515         mFirstBubbleSpringingToTouch = true;
516 
517         springFirstBubbleWithStackFollowing(
518                 DynamicAnimation.TRANSLATION_X,
519                 new SpringForce()
520                         .setDampingRatio(DEFAULT_BOUNCINESS)
521                         .setStiffness(DEFAULT_STIFFNESS),
522                 velX, x);
523 
524         springFirstBubbleWithStackFollowing(
525                 DynamicAnimation.TRANSLATION_Y,
526                 new SpringForce()
527                         .setDampingRatio(DEFAULT_BOUNCINESS)
528                         .setStiffness(DEFAULT_STIFFNESS),
529                 velY, y);
530     }
531 
532     /**
533      * Spring the stack towards the dismiss target, respecting existing velocity. This also sets
534      * flags so that subsequent touch events will not move the stack until it's demagnetized.
535      */
536     public void magnetToDismiss(float velX, float velY, float destY, Runnable after) {
537         mWithinDismissTarget = true;
538         mFirstBubbleSpringingToTouch = false;
539 
540         springFirstBubbleWithStackFollowing(
541                 DynamicAnimation.TRANSLATION_X,
542                 new SpringForce()
543                         .setDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY)
544                         .setStiffness(SpringForce.STIFFNESS_MEDIUM),
545                 velX, mLayout.getWidth() / 2f - mBubbleIconBitmapSize / 2f);
546 
547         springFirstBubbleWithStackFollowing(
548                 DynamicAnimation.TRANSLATION_Y,
549                 new SpringForce()
550                         .setDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY)
551                         .setStiffness(SpringForce.STIFFNESS_MEDIUM),
552                 velY, destY, after);
553     }
554 
555     /**
556      * 'Implode' the stack by shrinking the bubbles via chained animations and fading them out.
557      */
558     public void implodeStack(Runnable after) {
559         // Pop and fade the bubbles sequentially.
560         animationForChildAtIndex(0)
561                 .scaleX(0.5f)
562                 .scaleY(0.5f)
563                 .alpha(0f)
564                 .withDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY)
565                 .withStiffness(SpringForce.STIFFNESS_HIGH)
566                 .start(() -> {
567                     // Run the callback and reset flags. The child translation animations might
568                     // still be running, but that's fine. Once the alpha is at 0f they're no longer
569                     // visible anyway.
570                     after.run();
571                     mWithinDismissTarget = false;
572                 });
573     }
574 
575     /**
576      * Springs the first bubble to the given final position, with the rest of the stack 'following'.
577      */
578     protected void springFirstBubbleWithStackFollowing(
579             DynamicAnimation.ViewProperty property, SpringForce spring,
580             float vel, float finalPosition, @Nullable Runnable... after) {
581 
582         if (mLayout.getChildCount() == 0) {
583             return;
584         }
585 
586         Log.d(TAG, String.format("Springing %s to final position %f.",
587                 PhysicsAnimationLayout.getReadablePropertyName(property),
588                 finalPosition));
589 
590         StackPositionProperty firstBubbleProperty = new StackPositionProperty(property);
591         SpringAnimation springAnimation =
592                 new SpringAnimation(this, firstBubbleProperty)
593                         .setSpring(spring)
594                         .addEndListener((dynamicAnimation, b, v, v1) -> {
595                             if (after != null) {
596                                 for (Runnable callback : after) {
597                                     callback.run();
598                                 }
599                             }
600                         })
601                         .setStartVelocity(vel);
602 
603         cancelStackPositionAnimation(property);
604         mStackPositionAnimations.put(property, springAnimation);
605         springAnimation.animateToFinalPosition(finalPosition);
606     }
607 
608     @Override
609     Set<DynamicAnimation.ViewProperty> getAnimatedProperties() {
610         return Sets.newHashSet(
611                 DynamicAnimation.TRANSLATION_X, // For positioning.
612                 DynamicAnimation.TRANSLATION_Y,
613                 DynamicAnimation.ALPHA,         // For fading in new bubbles.
614                 DynamicAnimation.SCALE_X,       // For 'popping in' new bubbles.
615                 DynamicAnimation.SCALE_Y);
616     }
617 
618     @Override
619     int getNextAnimationInChain(DynamicAnimation.ViewProperty property, int index) {
620         if (property.equals(DynamicAnimation.TRANSLATION_X)
621                 || property.equals(DynamicAnimation.TRANSLATION_Y)) {
622             return index + 1;
623         } else if (mWithinDismissTarget) {
624             return index + 1; // Chain all animations in dismiss (scale, alpha, etc. are used).
625         } else {
626             return NONE;
627         }
628     }
629 
630 
631     @Override
632     float getOffsetForChainedPropertyAnimation(DynamicAnimation.ViewProperty property) {
633         if (property.equals(DynamicAnimation.TRANSLATION_X)) {
634             // If we're in the dismiss target, have the bubbles pile on top of each other with no
635             // offset.
636             if (mWithinDismissTarget) {
637                 return 0f;
638             } else {
639                 // Offset to the left if we're on the left, or the right otherwise.
640                 return mLayout.isFirstChildXLeftOfCenter(mStackPosition.x)
641                         ? -mStackOffset : mStackOffset;
642             }
643         } else {
644             return 0f;
645         }
646     }
647 
648     @Override
649     SpringForce getSpringForce(DynamicAnimation.ViewProperty property, View view) {
650         return new SpringForce()
651                 .setDampingRatio(DEFAULT_BOUNCINESS)
652                 .setStiffness(mIsMovingFromFlinging ? FLING_FOLLOW_STIFFNESS : DEFAULT_STIFFNESS);
653     }
654 
655     @Override
656     void onChildAdded(View child, int index) {
657         // Don't animate additions within the dismiss target.
658         if (mWithinDismissTarget) {
659             return;
660         }
661 
662         if (mLayout.getChildCount() == 1) {
663             // If this is the first child added, position the stack in its starting position.
664             moveStackToStartPosition();
665         } else if (isStackPositionSet() && mLayout.indexOfChild(child) == 0) {
666             // Otherwise, animate the bubble in if it's the newest bubble. If we're adding a bubble
667             // to the back of the stack, it'll be largely invisible so don't bother animating it in.
668             animateInBubble(child, index);
669         }
670     }
671 
672     @Override
673     void onChildRemoved(View child, int index, Runnable finishRemoval) {
674         // Animate the removing view in the opposite direction of the stack.
675         final float xOffset = getOffsetForChainedPropertyAnimation(DynamicAnimation.TRANSLATION_X);
676         animationForChild(child)
677                 .alpha(0f, finishRemoval /* after */)
678                 .scaleX(ANIMATE_IN_STARTING_SCALE)
679                 .scaleY(ANIMATE_IN_STARTING_SCALE)
680                 .translationX(mStackPosition.x - (-xOffset * ANIMATE_TRANSLATION_FACTOR))
681                 .start();
682 
683         // If there are other bubbles, pull them into the correct position.
684         if (mLayout.getChildCount() > 0) {
685             animationForChildAtIndex(0).translationX(mStackPosition.x).start();
686         } else {
687             // If there's no other bubbles, and we were in the dismiss target, reset the flag.
688             mWithinDismissTarget = false;
689         }
690     }
691 
692     @Override
693     void onChildReordered(View child, int oldIndex, int newIndex) {
694         if (isStackPositionSet()) {
695             setStackPosition(mStackPosition);
696         }
697     }
698 
699     @Override
700     void onActiveControllerForLayout(PhysicsAnimationLayout layout) {
701         Resources res = layout.getResources();
702         mStackOffset = res.getDimensionPixelSize(R.dimen.bubble_stack_offset);
703         mBubbleSize = res.getDimensionPixelSize(R.dimen.individual_bubble_size);
704         mBubbleIconBitmapSize = res.getDimensionPixelSize(R.dimen.bubble_icon_bitmap_size);
705         mBubblePaddingTop = res.getDimensionPixelSize(R.dimen.bubble_padding_top);
706         mBubbleOffscreen = res.getDimensionPixelSize(R.dimen.bubble_stack_offscreen);
707         mStackStartingVerticalOffset =
708                 res.getDimensionPixelSize(R.dimen.bubble_stack_starting_offset_y);
709         mStatusBarHeight =
710                 res.getDimensionPixelSize(com.android.internal.R.dimen.status_bar_height);
711     }
712 
713     /**
714      * Update effective screen width based on current orientation.
715      * @param orientation Landscape or portrait.
716      */
717     public void updateOrientation(int orientation) {
718         if (mLayout != null) {
719             Resources res = mLayout.getContext().getResources();
720             mBubblePaddingTop = res.getDimensionPixelSize(R.dimen.bubble_padding_top);
721             mStatusBarHeight = res.getDimensionPixelSize(
722                     com.android.internal.R.dimen.status_bar_height);
723         }
724     }
725 
726 
727     /** Moves the stack, without any animation, to the starting position. */
728     private void moveStackToStartPosition() {
729         // Post to ensure that the layout's width and height have been calculated.
730         mLayout.setVisibility(View.INVISIBLE);
731         mLayout.post(() -> {
732             setStackPosition(mRestingStackPosition == null
733                     ? getDefaultStartPosition()
734                     : mRestingStackPosition);
735             mStackMovedToStartPosition = true;
736             mLayout.setVisibility(View.VISIBLE);
737 
738             // Animate in the top bubble now that we're visible.
739             if (mLayout.getChildCount() > 0) {
740                 animateInBubble(mLayout.getChildAt(0), 0 /* index */);
741             }
742         });
743     }
744 
745     /**
746      * Moves the first bubble instantly to the given X or Y translation, and instructs subsequent
747      * bubbles to animate 'following' to the new location.
748      */
749     private void moveFirstBubbleWithStackFollowing(
750             DynamicAnimation.ViewProperty property, float value) {
751 
752         // Update the canonical stack position.
753         if (property.equals(DynamicAnimation.TRANSLATION_X)) {
754             mStackPosition.x = value;
755         } else if (property.equals(DynamicAnimation.TRANSLATION_Y)) {
756             mStackPosition.y = value;
757         }
758 
759         if (mLayout.getChildCount() > 0) {
760             property.setValue(mLayout.getChildAt(0), value);
761             if (mLayout.getChildCount() > 1) {
762                 animationForChildAtIndex(1)
763                         .property(property, value + getOffsetForChainedPropertyAnimation(property))
764                         .start();
765             }
766         }
767     }
768 
769     /** Moves the stack to a position instantly, with no animation. */
770     private void setStackPosition(PointF pos) {
771         Log.d(TAG, String.format("Setting position to (%f, %f).", pos.x, pos.y));
772         mStackPosition.set(pos.x, pos.y);
773 
774         // If we're not the active controller, we don't want to physically move the bubble views.
775         if (isActiveController()) {
776             // Cancel animations that could be moving the views.
777             mLayout.cancelAllAnimationsOfProperties(
778                     DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
779             cancelStackPositionAnimations();
780 
781             // Since we're not using the chained animations, apply the offsets manually.
782             final float xOffset = getOffsetForChainedPropertyAnimation(
783                     DynamicAnimation.TRANSLATION_X);
784             final float yOffset = getOffsetForChainedPropertyAnimation(
785                     DynamicAnimation.TRANSLATION_Y);
786             for (int i = 0; i < mLayout.getChildCount(); i++) {
787                 mLayout.getChildAt(i).setTranslationX(pos.x + (i * xOffset));
788                 mLayout.getChildAt(i).setTranslationY(pos.y + (i * yOffset));
789             }
790         }
791     }
792 
793     /** Returns the default stack position, which is on the top right. */
794     private PointF getDefaultStartPosition() {
795         return new PointF(
796                 getAllowableStackPositionRegion().right,
797                 getAllowableStackPositionRegion().top + mStackStartingVerticalOffset);
798     }
799 
800     private boolean isStackPositionSet() {
801         return mStackMovedToStartPosition;
802     }
803 
804     /** Animates in the given bubble. */
805     private void animateInBubble(View child, int index) {
806         if (!isActiveController()) {
807             return;
808         }
809 
810         final float xOffset =
811                 getOffsetForChainedPropertyAnimation(DynamicAnimation.TRANSLATION_X);
812 
813         // Position the new bubble in the correct position, scaled down completely.
814         child.setTranslationX(mStackPosition.x + xOffset * index);
815         child.setTranslationY(mStackPosition.y);
816         child.setScaleX(0f);
817         child.setScaleY(0f);
818 
819         // Push the subsequent views out of the way, if there are subsequent views.
820         if (index + 1 < mLayout.getChildCount()) {
821             animationForChildAtIndex(index + 1)
822                     .translationX(mStackPosition.x + xOffset * (index + 1))
823                     .withStiffness(SpringForce.STIFFNESS_LOW)
824                     .start();
825         }
826 
827         // Scale in the new bubble, slightly delayed.
828         animationForChild(child)
829                 .scaleX(1f)
830                 .scaleY(1f)
831                 .withStiffness(ANIMATE_IN_STIFFNESS)
832                 .withStartDelay(mLayout.getChildCount() > 1 ? ANIMATE_IN_START_DELAY : 0)
833                 .start();
834     }
835 
836     /**
837      * Cancels any outstanding first bubble property animations that are running. This does not
838      * affect the SpringAnimations controlling the individual bubbles' 'following' effect - it only
839      * cancels animations started from {@link #springFirstBubbleWithStackFollowing} and
840      * {@link #flingThenSpringFirstBubbleWithStackFollowing}.
841      */
842     private void cancelStackPositionAnimation(DynamicAnimation.ViewProperty property) {
843         if (mStackPositionAnimations.containsKey(property)) {
844             mStackPositionAnimations.get(property).cancel();
845         }
846     }
847 
848     /**
849      * FloatProperty that uses {@link #moveFirstBubbleWithStackFollowing} to set the first bubble's
850      * translation and animate the rest of the stack with it. A DynamicAnimation can animate this
851      * property directly to move the first bubble and cause the stack to 'follow' to the new
852      * location.
853      *
854      * This could also be achieved by simply animating the first bubble view and adding an update
855      * listener to dispatch movement to the rest of the stack. However, this would require
856      * duplication of logic in that update handler - it's simpler to keep all logic contained in the
857      * {@link #moveFirstBubbleWithStackFollowing} method.
858      */
859     private class StackPositionProperty
860             extends FloatPropertyCompat<StackAnimationController> {
861         private final DynamicAnimation.ViewProperty mProperty;
862 
863         private StackPositionProperty(DynamicAnimation.ViewProperty property) {
864             super(property.toString());
865             mProperty = property;
866         }
867 
868         @Override
869         public float getValue(StackAnimationController controller) {
870             return mLayout.getChildCount() > 0 ? mProperty.getValue(mLayout.getChildAt(0)) : 0;
871         }
872 
873         @Override
874         public void setValue(StackAnimationController controller, float value) {
875             moveFirstBubbleWithStackFollowing(mProperty, value);
876         }
877     }
878 }
879 
880