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.wm.shell.bubbles.animation;
18 
19 import static com.android.wm.shell.bubbles.BubblePositioner.NUM_VISIBLE_WHEN_RESTING;
20 
21 import android.content.res.Resources;
22 import android.graphics.Path;
23 import android.graphics.PointF;
24 import android.view.View;
25 
26 import androidx.annotation.NonNull;
27 import androidx.annotation.Nullable;
28 import androidx.dynamicanimation.animation.DynamicAnimation;
29 import androidx.dynamicanimation.animation.SpringForce;
30 
31 import com.android.wm.shell.R;
32 import com.android.wm.shell.animation.Interpolators;
33 import com.android.wm.shell.animation.PhysicsAnimator;
34 import com.android.wm.shell.bubbles.BubblePositioner;
35 import com.android.wm.shell.bubbles.BubbleStackView;
36 import com.android.wm.shell.common.magnetictarget.MagnetizedObject;
37 
38 import com.google.android.collect.Sets;
39 
40 import java.io.FileDescriptor;
41 import java.io.PrintWriter;
42 import java.util.Set;
43 
44 /**
45  * Animation controller for bubbles when they're in their expanded state, or animating to/from the
46  * expanded state. This controls the expansion animation as well as bubbles 'dragging out' to be
47  * dismissed.
48  */
49 public class ExpandedAnimationController
50         extends PhysicsAnimationLayout.PhysicsAnimationController {
51 
52     /**
53      * How much to translate the bubbles when they're animating in/out. This value is multiplied by
54      * the bubble size.
55      */
56     private static final int ANIMATE_TRANSLATION_FACTOR = 4;
57 
58     /** Duration of the expand/collapse target path animation. */
59     public static final int EXPAND_COLLAPSE_TARGET_ANIM_DURATION = 175;
60 
61     /** Damping ratio for expand/collapse spring. */
62     private static final float DAMPING_RATIO_MEDIUM_LOW_BOUNCY = 0.65f;
63 
64     /** Stiffness for the expand/collapse path-following animation. */
65     private static final int EXPAND_COLLAPSE_ANIM_STIFFNESS = 1000;
66 
67     /**
68      * Velocity required to dismiss an individual bubble without dragging it into the dismiss
69      * target.
70      */
71     private static final float FLING_TO_DISMISS_MIN_VELOCITY = 6000f;
72 
73     private final PhysicsAnimator.SpringConfig mAnimateOutSpringConfig =
74             new PhysicsAnimator.SpringConfig(
75                     EXPAND_COLLAPSE_ANIM_STIFFNESS, SpringForce.DAMPING_RATIO_NO_BOUNCY);
76 
77     /** Horizontal offset between bubbles, which we need to know to re-stack them. */
78     private float mStackOffsetPx;
79     /** Size of each bubble. */
80     private float mBubbleSizePx;
81     /** Whether the expand / collapse animation is running. */
82     private boolean mAnimatingExpand = false;
83 
84     /**
85      * Whether we are animating other Bubbles UI elements out in preparation for a call to
86      * {@link #collapseBackToStack}. If true, we won't animate bubbles in response to adds or
87      * reorders.
88      */
89     private boolean mPreparingToCollapse = false;
90 
91     private boolean mAnimatingCollapse = false;
92     @Nullable
93     private Runnable mAfterExpand;
94     private Runnable mAfterCollapse;
95     private PointF mCollapsePoint;
96 
97     /**
98      * Whether the dragged out bubble is springing towards the touch point, rather than using the
99      * default behavior of moving directly to the touch point.
100      *
101      * This happens when the user's finger exits the dismiss area while the bubble is magnetized to
102      * the center. Since the touch point differs from the bubble location, we need to animate the
103      * bubble back to the touch point to avoid a jarring instant location change from the center of
104      * the target to the touch point just outside the target bounds.
105      */
106     private boolean mSpringingBubbleToTouch = false;
107 
108     /**
109      * Whether to spring the bubble to the next touch event coordinates. This is used to animate the
110      * bubble out of the magnetic dismiss target to the touch location.
111      *
112      * Once it 'catches up' and the animation ends, we'll revert to moving it directly.
113      */
114     private boolean mSpringToTouchOnNextMotionEvent = false;
115 
116     /** The bubble currently being dragged out of the row (to potentially be dismissed). */
117     private MagnetizedObject<View> mMagnetizedBubbleDraggingOut;
118 
119     /**
120      * Callback to run whenever any bubble is animated out. The BubbleStackView will check if the
121      * end of this animation means we have no bubbles left, and notify the BubbleController.
122      */
123     private Runnable mOnBubbleAnimatedOutAction;
124 
125     private BubblePositioner mPositioner;
126 
127     private BubbleStackView mBubbleStackView;
128 
ExpandedAnimationController(BubblePositioner positioner, Runnable onBubbleAnimatedOutAction, BubbleStackView stackView)129     public ExpandedAnimationController(BubblePositioner positioner,
130             Runnable onBubbleAnimatedOutAction, BubbleStackView stackView) {
131         mPositioner = positioner;
132         updateResources();
133         mOnBubbleAnimatedOutAction = onBubbleAnimatedOutAction;
134         mCollapsePoint = mPositioner.getDefaultStartPosition();
135         mBubbleStackView = stackView;
136     }
137 
138     /**
139      * Whether the individual bubble has been dragged out of the row of bubbles far enough to cause
140      * the rest of the bubbles to animate to fill the gap.
141      */
142     private boolean mBubbleDraggedOutEnough = false;
143 
144     /** End action to run when the lead bubble's expansion animation completes. */
145     @Nullable
146     private Runnable mLeadBubbleEndAction;
147 
148     /**
149      * Animates expanding the bubbles into a row along the top of the screen, optionally running an
150      * end action when the entire animation completes, and an end action when the lead bubble's
151      * animation ends.
152      */
expandFromStack( @ullable Runnable after, @Nullable Runnable leadBubbleEndAction)153     public void expandFromStack(
154             @Nullable Runnable after, @Nullable Runnable leadBubbleEndAction) {
155         mPreparingToCollapse = false;
156         mAnimatingCollapse = false;
157         mAnimatingExpand = true;
158         mAfterExpand = after;
159         mLeadBubbleEndAction = leadBubbleEndAction;
160 
161         startOrUpdatePathAnimation(true /* expanding */);
162     }
163 
164     /**
165      * Animates expanding the bubbles into a row along the top of the screen.
166      */
expandFromStack(@ullable Runnable after)167     public void expandFromStack(@Nullable Runnable after) {
168         expandFromStack(after, null /* leadBubbleEndAction */);
169     }
170 
171     /**
172      * Sets that we're animating the stack collapsed, but haven't yet called
173      * {@link #collapseBackToStack}. This will temporarily suspend animations for bubbles that are
174      * added or re-ordered, since the upcoming collapse animation will handle positioning those
175      * bubbles in the collapsed stack.
176      */
notifyPreparingToCollapse()177     public void notifyPreparingToCollapse() {
178         mPreparingToCollapse = true;
179     }
180 
181     /** Animate collapsing the bubbles back to their stacked position. */
collapseBackToStack(PointF collapsePoint, Runnable after)182     public void collapseBackToStack(PointF collapsePoint, Runnable after) {
183         mAnimatingExpand = false;
184         mPreparingToCollapse = false;
185         mAnimatingCollapse = true;
186         mAfterCollapse = after;
187         mCollapsePoint = collapsePoint;
188 
189         startOrUpdatePathAnimation(false /* expanding */);
190     }
191 
192     /**
193      * Update effective screen width based on current orientation.
194      */
updateResources()195     public void updateResources() {
196         if (mLayout == null) {
197             return;
198         }
199         Resources res = mLayout.getContext().getResources();
200         mStackOffsetPx = res.getDimensionPixelSize(R.dimen.bubble_stack_offset);
201         mBubbleSizePx = mPositioner.getBubbleSize();
202     }
203 
204     /**
205      * Animates the bubbles along a curved path, either to expand them along the top or collapse
206      * them back into a stack.
207      */
startOrUpdatePathAnimation(boolean expanding)208     private void startOrUpdatePathAnimation(boolean expanding) {
209         Runnable after;
210 
211         if (expanding) {
212             after = () -> {
213                 mAnimatingExpand = false;
214 
215                 if (mAfterExpand != null) {
216                     mAfterExpand.run();
217                 }
218 
219                 mAfterExpand = null;
220 
221                 // Update bubble positions in case any bubbles were added or removed during the
222                 // expansion animation.
223                 updateBubblePositions();
224             };
225         } else {
226             after = () -> {
227                 mAnimatingCollapse = false;
228 
229                 if (mAfterCollapse != null) {
230                     mAfterCollapse.run();
231                 }
232 
233                 mAfterCollapse = null;
234             };
235         }
236 
237         // Animate each bubble individually, since each path will end in a different spot.
238         animationsForChildrenFromIndex(0, (index, animation) -> {
239             final View bubble = mLayout.getChildAt(index);
240 
241             // Start a path at the bubble's current position.
242             final Path path = new Path();
243             path.moveTo(bubble.getTranslationX(), bubble.getTranslationY());
244 
245             final PointF p = mPositioner.getExpandedBubbleXY(index, mBubbleStackView.getState());
246             if (expanding) {
247                 // If we're expanding, first draw a line from the bubble's current position to where
248                 // it'll end up
249                 path.lineTo(bubble.getTranslationX(), p.y);
250                 // Then, draw a line across the screen to the bubble's resting position.
251                 path.lineTo(p.x, p.y);
252             } else {
253                 final float stackedX = mCollapsePoint.x;
254 
255                 // If we're collapsing, draw a line from the bubble's current position to the side
256                 // of the screen where the bubble will be stacked.
257                 path.lineTo(stackedX, p.y);
258 
259                 // Then, draw a line down to the stack position.
260                 path.lineTo(stackedX, mCollapsePoint.y
261                         + Math.min(index, NUM_VISIBLE_WHEN_RESTING - 1) * mStackOffsetPx);
262             }
263 
264             // The lead bubble should be the bubble with the longest distance to travel when we're
265             // expanding, and the bubble with the shortest distance to travel when we're collapsing.
266             // During expansion from the left side, the last bubble has to travel to the far right
267             // side, so we have it lead and 'pull' the rest of the bubbles into place. From the
268             // right side, the first bubble is traveling to the top left, so it leads. During
269             // collapse to the left, the first bubble has the shortest travel time back to the stack
270             // position, so it leads (and vice versa).
271             final boolean firstBubbleLeads =
272                     (expanding && !mLayout.isFirstChildXLeftOfCenter(bubble.getTranslationX()))
273                             || (!expanding && mLayout.isFirstChildXLeftOfCenter(mCollapsePoint.x));
274             final int startDelay = firstBubbleLeads
275                     ? (index * 10)
276                     : ((mLayout.getChildCount() - index) * 10);
277 
278             final boolean isLeadBubble =
279                     (firstBubbleLeads && index == 0)
280                             || (!firstBubbleLeads && index == mLayout.getChildCount() - 1);
281 
282             animation
283                     .followAnimatedTargetAlongPath(
284                             path,
285                             EXPAND_COLLAPSE_TARGET_ANIM_DURATION /* targetAnimDuration */,
286                             Interpolators.LINEAR /* targetAnimInterpolator */,
287                             isLeadBubble ? mLeadBubbleEndAction : null /* endAction */,
288                             () -> mLeadBubbleEndAction = null /* endAction */)
289                     .withStartDelay(startDelay)
290                     .withStiffness(EXPAND_COLLAPSE_ANIM_STIFFNESS);
291         }).startAll(after);
292     }
293 
294     /** Notifies the controller that the dragged-out bubble was unstuck from the magnetic target. */
onUnstuckFromTarget()295     public void onUnstuckFromTarget() {
296         mSpringToTouchOnNextMotionEvent = true;
297     }
298 
299     /**
300      * Prepares the given bubble view to be dragged out, using the provided magnetic target and
301      * listener.
302      */
prepareForBubbleDrag( View bubble, MagnetizedObject.MagneticTarget target, MagnetizedObject.MagnetListener listener)303     public void prepareForBubbleDrag(
304             View bubble,
305             MagnetizedObject.MagneticTarget target,
306             MagnetizedObject.MagnetListener listener) {
307         mLayout.cancelAnimationsOnView(bubble);
308 
309         bubble.setTranslationZ(Short.MAX_VALUE);
310         mMagnetizedBubbleDraggingOut = new MagnetizedObject<View>(
311                 mLayout.getContext(), bubble,
312                 DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y) {
313             @Override
314             public float getWidth(@NonNull View underlyingObject) {
315                 return mBubbleSizePx;
316             }
317 
318             @Override
319             public float getHeight(@NonNull View underlyingObject) {
320                 return mBubbleSizePx;
321             }
322 
323             @Override
324             public void getLocationOnScreen(@NonNull View underlyingObject, @NonNull int[] loc) {
325                 loc[0] = (int) bubble.getTranslationX();
326                 loc[1] = (int) bubble.getTranslationY();
327             }
328         };
329         mMagnetizedBubbleDraggingOut.addTarget(target);
330         mMagnetizedBubbleDraggingOut.setMagnetListener(listener);
331         mMagnetizedBubbleDraggingOut.setHapticsEnabled(true);
332         mMagnetizedBubbleDraggingOut.setFlingToTargetMinVelocity(FLING_TO_DISMISS_MIN_VELOCITY);
333     }
334 
springBubbleTo(View bubble, float x, float y)335     private void springBubbleTo(View bubble, float x, float y) {
336         animationForChild(bubble)
337                 .translationX(x)
338                 .translationY(y)
339                 .withStiffness(SpringForce.STIFFNESS_HIGH)
340                 .start();
341     }
342 
343     /**
344      * Drags an individual bubble to the given coordinates. Bubbles to the right will animate to
345      * take its place once it's dragged out of the row of bubbles, and animate out of the way if the
346      * bubble is dragged back into the row.
347      */
dragBubbleOut(View bubbleView, float x, float y)348     public void dragBubbleOut(View bubbleView, float x, float y) {
349         if (mMagnetizedBubbleDraggingOut == null) {
350             return;
351         }
352         if (mSpringToTouchOnNextMotionEvent) {
353             springBubbleTo(mMagnetizedBubbleDraggingOut.getUnderlyingObject(), x, y);
354             mSpringToTouchOnNextMotionEvent = false;
355             mSpringingBubbleToTouch = true;
356         } else if (mSpringingBubbleToTouch) {
357             if (mLayout.arePropertiesAnimatingOnView(
358                     bubbleView, DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y)) {
359                 springBubbleTo(mMagnetizedBubbleDraggingOut.getUnderlyingObject(), x, y);
360             } else {
361                 mSpringingBubbleToTouch = false;
362             }
363         }
364 
365         if (!mSpringingBubbleToTouch && !mMagnetizedBubbleDraggingOut.getObjectStuckToTarget()) {
366             bubbleView.setTranslationX(x);
367             bubbleView.setTranslationY(y);
368         }
369 
370         final float expandedY = mPositioner.getExpandedViewYTopAligned();
371         final boolean draggedOutEnough =
372                 y > expandedY + mBubbleSizePx || y < expandedY - mBubbleSizePx;
373         if (draggedOutEnough != mBubbleDraggedOutEnough) {
374             updateBubblePositions();
375             mBubbleDraggedOutEnough = draggedOutEnough;
376         }
377     }
378 
379     /** Plays a dismiss animation on the dragged out bubble. */
380     public void dismissDraggedOutBubble(View bubble, float translationYBy, Runnable after) {
381         if (bubble == null) {
382             return;
383         }
384         animationForChild(bubble)
385                 .withStiffness(SpringForce.STIFFNESS_HIGH)
386                 .scaleX(0f)
387                 .scaleY(0f)
388                 .translationY(bubble.getTranslationY() + translationYBy)
389                 .alpha(0f, after)
390                 .start();
391 
392         updateBubblePositions();
393     }
394 
395     @Nullable
396     public View getDraggedOutBubble() {
397         return mMagnetizedBubbleDraggingOut == null
398                 ? null
399                 : mMagnetizedBubbleDraggingOut.getUnderlyingObject();
400     }
401 
402     /** Returns the MagnetizedObject instance for the dragging-out bubble. */
403     public MagnetizedObject<View> getMagnetizedBubbleDraggingOut() {
404         return mMagnetizedBubbleDraggingOut;
405     }
406 
407     /**
408      * Snaps a bubble back to its position within the bubble row, and animates the rest of the
409      * bubbles to accommodate it if it was previously dragged out past the threshold.
410      */
411     public void snapBubbleBack(View bubbleView, float velX, float velY) {
412         if (mLayout == null) {
413             return;
414         }
415         final int index = mLayout.indexOfChild(bubbleView);
416         final PointF p = mPositioner.getExpandedBubbleXY(index, mBubbleStackView.getState());
417         animationForChildAtIndex(index)
418                 .position(p.x, p.y)
419                 .withPositionStartVelocities(velX, velY)
420                 .start(() -> bubbleView.setTranslationZ(0f) /* after */);
421 
422         mMagnetizedBubbleDraggingOut = null;
423 
424         updateBubblePositions();
425     }
426 
427     /** Resets bubble drag out gesture flags. */
onGestureFinished()428     public void onGestureFinished() {
429         mBubbleDraggedOutEnough = false;
430         mMagnetizedBubbleDraggingOut = null;
431         updateBubblePositions();
432     }
433 
434     /** Description of current animation controller state. */
dump(FileDescriptor fd, PrintWriter pw, String[] args)435     public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
436         pw.println("ExpandedAnimationController state:");
437         pw.print("  isActive:          "); pw.println(isActiveController());
438         pw.print("  animatingExpand:   "); pw.println(mAnimatingExpand);
439         pw.print("  animatingCollapse: "); pw.println(mAnimatingCollapse);
440         pw.print("  springingBubble:   "); pw.println(mSpringingBubbleToTouch);
441     }
442 
443     @Override
onActiveControllerForLayout(PhysicsAnimationLayout layout)444     void onActiveControllerForLayout(PhysicsAnimationLayout layout) {
445         updateResources();
446 
447         // Ensure that all child views are at 1x scale, and visible, in case they were animating
448         // in.
449         mLayout.setVisibility(View.VISIBLE);
450         animationsForChildrenFromIndex(0 /* startIndex */, (index, animation) ->
451                 animation.scaleX(1f).scaleY(1f).alpha(1f)).startAll();
452     }
453 
454     @Override
getAnimatedProperties()455     Set<DynamicAnimation.ViewProperty> getAnimatedProperties() {
456         return Sets.newHashSet(
457                 DynamicAnimation.TRANSLATION_X,
458                 DynamicAnimation.TRANSLATION_Y,
459                 DynamicAnimation.SCALE_X,
460                 DynamicAnimation.SCALE_Y,
461                 DynamicAnimation.ALPHA);
462     }
463 
464     @Override
getNextAnimationInChain(DynamicAnimation.ViewProperty property, int index)465     int getNextAnimationInChain(DynamicAnimation.ViewProperty property, int index) {
466         return NONE;
467     }
468 
469     @Override
getOffsetForChainedPropertyAnimation(DynamicAnimation.ViewProperty property, int index)470     float getOffsetForChainedPropertyAnimation(DynamicAnimation.ViewProperty property, int index) {
471         return 0;
472     }
473 
474     @Override
getSpringForce(DynamicAnimation.ViewProperty property, View view)475     SpringForce getSpringForce(DynamicAnimation.ViewProperty property, View view) {
476         return new SpringForce()
477                 .setDampingRatio(DAMPING_RATIO_MEDIUM_LOW_BOUNCY)
478                 .setStiffness(SpringForce.STIFFNESS_LOW);
479     }
480 
481     @Override
onChildAdded(View child, int index)482     void onChildAdded(View child, int index) {
483         // If a bubble is added while the expand/collapse animations are playing, update the
484         // animation to include the new bubble.
485         if (mAnimatingExpand) {
486             startOrUpdatePathAnimation(true /* expanding */);
487         } else if (mAnimatingCollapse) {
488             startOrUpdatePathAnimation(false /* expanding */);
489         } else {
490             boolean onLeft = mPositioner.isStackOnLeft(mCollapsePoint);
491             final PointF p = mPositioner.getExpandedBubbleXY(index, mBubbleStackView.getState());
492             if (mPositioner.showBubblesVertically()) {
493                 child.setTranslationY(p.y);
494             } else {
495                 child.setTranslationX(p.x);
496             }
497 
498             if (mPreparingToCollapse) {
499                 // Don't animate if we're collapsing, as that animation will handle placing the
500                 // new bubble in the stacked position.
501                 return;
502             }
503 
504             if (mPositioner.showBubblesVertically()) {
505                 float fromX = onLeft
506                         ? p.x - mBubbleSizePx * ANIMATE_TRANSLATION_FACTOR
507                         : p.x + mBubbleSizePx * ANIMATE_TRANSLATION_FACTOR;
508                 animationForChild(child)
509                         .translationX(fromX, p.y)
510                         .start();
511             } else {
512                 float fromY = p.y - mBubbleSizePx * ANIMATE_TRANSLATION_FACTOR;
513                 animationForChild(child)
514                         .translationY(fromY, p.y)
515                         .start();
516             }
517             updateBubblePositions();
518         }
519     }
520 
521     @Override
onChildRemoved(View child, int index, Runnable finishRemoval)522     void onChildRemoved(View child, int index, Runnable finishRemoval) {
523         // If we're removing the dragged-out bubble, that means it got dismissed.
524         if (child.equals(getDraggedOutBubble())) {
525             mMagnetizedBubbleDraggingOut = null;
526             finishRemoval.run();
527             mOnBubbleAnimatedOutAction.run();
528         } else {
529             PhysicsAnimator.getInstance(child)
530                     .spring(DynamicAnimation.ALPHA, 0f)
531                     .spring(DynamicAnimation.SCALE_X, 0f, mAnimateOutSpringConfig)
532                     .spring(DynamicAnimation.SCALE_Y, 0f, mAnimateOutSpringConfig)
533                     .withEndActions(finishRemoval, mOnBubbleAnimatedOutAction)
534                     .start();
535         }
536 
537         // Animate all the other bubbles to their new positions sans this bubble.
538         updateBubblePositions();
539     }
540 
541     @Override
onChildReordered(View child, int oldIndex, int newIndex)542     void onChildReordered(View child, int oldIndex, int newIndex) {
543         if (mPreparingToCollapse) {
544             // If a re-order is received while we're preparing to collapse, ignore it. Once started,
545             // the collapse animation will animate all of the bubbles to their correct (stacked)
546             // position.
547             return;
548         }
549 
550         if (mAnimatingCollapse) {
551             // If a re-order is received during collapse, update the animation so that the bubbles
552             // end up in the correct (stacked) position.
553             startOrUpdatePathAnimation(false /* expanding */);
554         } else {
555             // Otherwise, animate the bubbles around to reflect their new order.
556             updateBubblePositions();
557         }
558     }
559 
updateBubblePositions()560     private void updateBubblePositions() {
561         if (mAnimatingExpand || mAnimatingCollapse) {
562             return;
563         }
564         for (int i = 0; i < mLayout.getChildCount(); i++) {
565             final View bubble = mLayout.getChildAt(i);
566 
567             // Don't animate the dragging out bubble, or it'll jump around while being dragged. It
568             // will be snapped to the correct X value after the drag (if it's not dismissed).
569             if (bubble.equals(getDraggedOutBubble())) {
570                 return;
571             }
572 
573             final PointF p = mPositioner.getExpandedBubbleXY(i, mBubbleStackView.getState());
574             animationForChild(bubble)
575                     .translationX(p.x)
576                     .translationY(p.y)
577                     .start();
578         }
579     }
580 }
581