Adds DynamicAnimation-based movement to the bubbles.
See go/bubble-stack-design for a high level overview of these changes. This is a large CL, but required in order to allow continued development and team testing without breaking functionality over the course of multiple CL submissions.
To integrate the new animations, the following changes have been made to existing code:
* (BubbleStackView) The bubble container (and thus, the stack view) are MATCH_PARENT to allow the bubbles to independently translate anywhere on the screen.
* (BubbleStackView) Start position is set by the stack controller, not BubbleStackView.
* (BubbleStackView) Expand positon is set by the expansion controller, not BubbleStackView.
* (BubbleStackView/BubbleTouchHandler) Added the methods onDragStart/onDragged/onDragFinish, and onBubbleDragStart/onBubbleDragged/onBubbleDragFinish, for cleaner dispatch of touch events to the appropriate animation controller.
* (BubbleStackView/BubbleController) The stack view's getBoundsOnScreen returns the first bubble's bounds, if the stack is not expanded.
* (BubbleStackView) applyCurrentState no longer manages translation of bubbles, or the expanded view, these are controlled by animation.
* (BubbleMovementHelper) Deleted, no longer needed.
* (Everywhere) Changed uses of Point to PointF, since translation values are floats anyway.
Known issues to be fixed in subsequent, far smaller CLs:
* (b/123022862) Bubble dragging out/dismissing is not animated, and the bubbles can be deposited anywhere. Tap outside the stack to collapse them back to normal.
* (b/123023502) New bubbles added while the stack is expanded are not positioned properly.
* (b/123022982) Expanded view arrow is sometimes in the wrong position.
* (b/123023410) If the stack is expanded while animating, it collapses to its original position even if not along the edge of the screen.
* (b/123023904) The expanded view doesn't animate out, it disappears instantly.
* (b/123026584) Bounds in landscape are a bit wonky.
Bug: 111236845
Test: atest SystemUITests
Test: physics-animation-testing.md
Change-Id: Icaca09e5db89c635c9bb7ca82d7d2714362e344e
diff --git a/packages/SystemUI/Android.bp b/packages/SystemUI/Android.bp
index 8be67d9..50a069d 100644
--- a/packages/SystemUI/Android.bp
+++ b/packages/SystemUI/Android.bp
@@ -57,6 +57,7 @@
"androidx.slice_slice-builders",
"androidx.arch.core_core-runtime",
"androidx.lifecycle_lifecycle-extensions",
+ "androidx.dynamicanimation_dynamicanimation",
"SystemUI-tags",
"SystemUI-proto",
"dagger2-2.19",
@@ -108,6 +109,7 @@
"androidx.slice_slice-builders",
"androidx.arch.core_core-runtime",
"androidx.lifecycle_lifecycle-extensions",
+ "androidx.dynamicanimation_dynamicanimation",
"SystemUI-tags",
"SystemUI-proto",
"metrics-helper-lib",
diff --git a/packages/SystemUI/docs/physics-animation-layout-config-methods.png b/packages/SystemUI/docs/physics-animation-layout-config-methods.png
new file mode 100644
index 0000000..c3a45e2
--- /dev/null
+++ b/packages/SystemUI/docs/physics-animation-layout-config-methods.png
Binary files differ
diff --git a/packages/SystemUI/docs/physics-animation-layout-control-methods.png b/packages/SystemUI/docs/physics-animation-layout-control-methods.png
new file mode 100644
index 0000000..e77c676
--- /dev/null
+++ b/packages/SystemUI/docs/physics-animation-layout-control-methods.png
Binary files differ
diff --git a/packages/SystemUI/docs/physics-animation-layout.md b/packages/SystemUI/docs/physics-animation-layout.md
new file mode 100644
index 0000000..a67b5e8
--- /dev/null
+++ b/packages/SystemUI/docs/physics-animation-layout.md
@@ -0,0 +1,56 @@
+# Physics Animation Layout
+
+## Overview
+**PhysicsAnimationLayout** works with an implementation of **PhysicsAnimationController** to construct and maintain physics animations for each of its child views. During the initial construction of the animations, the layout queries the controller for configuration settings such as which properties to animate, which animations to chain together, and what stiffness or bounciness to use. Once the animations are built to the controller’s specifications, the controller can then ask the layout to start, stop and manipulate them arbitrarily to achieve any desired animation effect. The controller is notified whenever children are added or removed from the layout, so that it can animate their entrance or exit, respectively.
+
+An example usage is Bubbles, which uses a PhysicsAnimationLayout for its stack of bubbles. Bubbles has controller subclasses including StackAnimationController and ExpansionAnimationController. StackAnimationController tells the layout to configure the translation animations to be chained (for the ‘following’ drag effect), and has methods such as ```moveStack(x, y)``` to animate the stack to a given point. ExpansionAnimationController asks for no animations to be chained, and exposes methods like ```expandStack()``` and ```collapseStack()```, which animate the bubbles to positions along the bottom of the screen.
+
+## PhysicsAnimationController
+PhysicsAnimationController is a public abstract class in PhysicsAnimationLayout. Controller instances must override configuration methods, which are used by the layout while constructing the animations, and animation control methods, which are called to initiate animations in response to events.
+
+### Configuration Methods
+
+The controller must override the following methods:
+
+```Set<ViewProperty> getAnimatedProperties()```
+Returns the properties, such as TRANSLATION_X and TRANSLATION_Y, for which the layout should construct physics animations.
+
+```int getNextAnimationInChain(ViewProperty property, int index)```
+If the animation at the given index should update another animation whenever its value changes, return the index of the other animation. Otherwise, return NONE. This is used to chain animations together, so that when one animation moves, the other ‘follows’ closely behind.
+
+```float getOffsetForChainedPropertyAnimation(ViewProperty property)```
+Value to add every time chained animations update the subsequent animation in the chain. For example, returning TRANSLATION_X offset = 20px means that if the first animation in the chain is animated to 10px, the second will update to 30px, the third to 50px, etc.
+
+```SpringForce getSpringForce(ViewProperty property)```
+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
+
+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``` and ```onChildRemoved``` are called automatically by the layout, giving the controller the opportunity to animate the child in/out. 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.
+
+For example, moving the first child view to *(100, 200)*:
+
+```
+animateValueForChildAtIndex(TRANSLATION_X, 0, 100);
+animateValueForChildAtIndex(TRANSLATION_Y, 0, 200);
+```
+
+This would use the physics animations constructed by the layout to spring the view to *(100, 200)*.
+
+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```.
+
+## PhysicsAnimationLayout
+The layout itself is a FrameLayout descendant with a few extra methods:
+
+```setController(PhysicsAnimationController controller)```
+Attaches the layout to the controller, so that the controller can access the layout’s protected methods. It also constructs or reconfigures the physics animations according to the new controller’s configuration methods.
+
+```setEndListenerForProperty(ViewProperty property, AnimationEndListener endListener)```
+Sets an end listener that is called when all animations on the given property have ended.
+
+```setMaxRenderedChildren(int max)```
+Child views beyond this limit will be set to GONE, and won't be animated, for performance reasons. Defaults to **5**.
+
+It has one protected method, ```animateValueForChildAtIndex(ViewProperty property, int index, float value)```, which is visible to PhysicsAnimationController descendants. This method dispatches the given value to the appropriate animation.
\ No newline at end of file
diff --git a/packages/SystemUI/docs/physics-animation-testing.md b/packages/SystemUI/docs/physics-animation-testing.md
new file mode 100644
index 0000000..47354d4
--- /dev/null
+++ b/packages/SystemUI/docs/physics-animation-testing.md
@@ -0,0 +1,11 @@
+# Physics Animation Testing
+Physics animations are notoriously difficult to test, since they’re essentially small simulations. They have no set duration, and they’re considered ‘finished’ only when the movements imparted by the animation are too small to be user-visible. Mid-states are not deterministic.
+
+For this reason, we only test the end state of animations. Manual testing should be sufficient to reveal flaws in the en-route animation visuals. In a worst-case failure case, as long as the end state is correct, usability will not be affected - animations might just look a bit off until the UI elements settle to their proper positions.
+
+## Waiting for Animations to End
+Testing any kind of animation can be tricky, since animations need to run on the main thread, and they’re asynchronous - the test has to wait for the animation to finish before we can assert anything about its end state. For normal animations, we can invoke skipToEnd to avoid waiting. While this method is available for SpringAnimation, it’s not available for FlingAnimation since its end state is not initially known. A FlingAnimation’s ‘end’ is when the friction simulation reports that motion has slowed to an invisible level. For this reason, we have to actually run the physics animations.
+
+To accommodate this, all tests of the layout itself, as well as any animation controller subclasses, use **PhysicsAnimationLayoutTestCase**. The layout provided to controllers by the test case is a **TestablePhysicsAnimationLayout**, a subclass of PhysicsAnimationLayout whose animation-related methods have been overridden to force them to run on the main thread via a Handler. Animations will simply crash if they’re called directly from the test thread, so this is important.
+
+The test case also provides ```waitForPropertyAnimations```, which uses a **CountDownLatch** to wait for all animations on a given property to complete before continuing the test. This works since the test is not running on the same thread as the animation, so a blocking call to ```latch.await()``` does not affect the animations’ progress. The latch is initialized with a count equal to the number of properties we’re listening to. We then add end listeners to the layout for each property, which call ```latch.countDown()```. Once all of the properties’ animations have completed, the latch count reaches zero and the test’s call to ```await()``` returns, with the animations complete.
\ No newline at end of file
diff --git a/packages/SystemUI/res/layout/bubble_view.xml b/packages/SystemUI/res/layout/bubble_view.xml
index 204408cd..13186fc 100644
--- a/packages/SystemUI/res/layout/bubble_view.xml
+++ b/packages/SystemUI/res/layout/bubble_view.xml
@@ -22,8 +22,8 @@
<com.android.systemui.bubbles.BadgedImageView
android:id="@+id/bubble_image"
- android:layout_width="@dimen/bubble_size"
- android:layout_height="@dimen/bubble_size"
+ android:layout_width="@dimen/individual_bubble_size"
+ android:layout_height="@dimen/individual_bubble_size"
android:padding="@dimen/bubble_view_padding"
android:clipToPadding="false"/>
diff --git a/packages/SystemUI/res/values/dimens.xml b/packages/SystemUI/res/values/dimens.xml
index ab0bbe1..3caa968 100644
--- a/packages/SystemUI/res/values/dimens.xml
+++ b/packages/SystemUI/res/values/dimens.xml
@@ -982,8 +982,8 @@
<dimen name="bubble_view_padding">0dp</dimen>
<!-- Padding between bubbles when displayed in expanded state -->
<dimen name="bubble_padding">8dp</dimen>
- <!-- Size of the collapsed bubble -->
- <dimen name="bubble_size">56dp</dimen>
+ <!-- Size of individual bubbles. -->
+ <dimen name="individual_bubble_size">56dp</dimen>
<!-- How much to inset the icon in the circle -->
<dimen name="bubble_icon_inset">16dp</dimen>
<!-- Padding around the view displayed when the bubble is expanded -->
@@ -1000,10 +1000,20 @@
<dimen name="bubble_expanded_header_height">48dp</dimen>
<!-- Left and right padding applied to the header. -->
<dimen name="bubble_expanded_header_horizontal_padding">24dp</dimen>
+ <!-- How far, horizontally, to animate the expanded view over when animating in/out. -->
+ <dimen name="bubble_expanded_animate_x_distance">100dp</dimen>
+ <!-- How far, vertically, to animate the expanded view over when animating in/out. -->
+ <dimen name="bubble_expanded_animate_y_distance">500dp</dimen>
<!-- Max width of the message bubble-->
<dimen name="bubble_message_max_width">144dp</dimen>
<!-- Min width of the message bubble -->
<dimen name="bubble_message_min_width">32dp</dimen>
<!-- Interior padding of the message bubble -->
<dimen name="bubble_message_padding">4dp</dimen>
+ <!-- Offset between bubbles in their stacked position. -->
+ <dimen name="bubble_stack_offset">5dp</dimen>
+ <!-- How far offscreen the bubble stack rests. -->
+ <dimen name="bubble_stack_offscreen">5dp</dimen>
+ <!-- How far down the screen the stack starts. -->
+ <dimen name="bubble_stack_starting_offset_y">100dp</dimen>
</resources>
diff --git a/packages/SystemUI/res/values/ids.xml b/packages/SystemUI/res/values/ids.xml
index bd34bea..2993d5c 100644
--- a/packages/SystemUI/res/values/ids.xml
+++ b/packages/SystemUI/res/values/ids.xml
@@ -115,6 +115,14 @@
<item type="id" name="aod_mask_transition_progress_end_tag" />
<item type="id" name="aod_mask_transition_progress_start_tag" />
+ <!-- For saving DynamicAnimation physics animations 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"/>
+
<!-- Global Actions Menu -->
<item type="id" name="global_actions_view" />
</resources>
diff --git a/packages/SystemUI/res/values/integers.xml b/packages/SystemUI/res/values/integers.xml
index fd7a105..e8fabf5 100644
--- a/packages/SystemUI/res/values/integers.xml
+++ b/packages/SystemUI/res/values/integers.xml
@@ -21,4 +21,10 @@
0) as we can allow the carrier text to stretch as far as needed in the QS footer. -->
<integer name="qs_footer_actions_width">-2</integer>
<integer name="qs_footer_actions_weight">0</integer>
+
+ <!-- Maximum number of bubbles to render and animate at one time. While the animations used are
+ lightweight translation animations, this number can be reduced on lower end devices if any
+ performance issues arise. -->
+ <integer name="bubbles_max_rendered">5</integer>
+
</resources>
\ No newline at end of file
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BadgedImageView.java b/packages/SystemUI/src/com/android/systemui/bubbles/BadgedImageView.java
index 92d3cc1..36a813b 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BadgedImageView.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BadgedImageView.java
@@ -57,7 +57,7 @@
int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
setScaleType(ScaleType.CENTER_CROP);
- mIconSize = getResources().getDimensionPixelSize(R.dimen.bubble_size);
+ mIconSize = getResources().getDimensionPixelSize(R.dimen.individual_bubble_size);
mDotRenderer = new BadgeRenderer(mIconSize);
}
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
index a457dee..b7bee30 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java
@@ -18,9 +18,8 @@
import static android.view.View.INVISIBLE;
import static android.view.View.VISIBLE;
-import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
+import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
-import static com.android.systemui.bubbles.BubbleMovementHelper.EDGE_OVERLAP;
import static com.android.systemui.statusbar.StatusBarState.SHADE;
import static com.android.systemui.statusbar.notification.NotificationAlertingManager.alertAgain;
@@ -229,10 +228,6 @@
}
mStackView.stackDismissed();
- // Reset the position of the stack (TODO - or should we save / respect last user position?)
- Point startPoint = getStartPoint(mStackView.getStackWidth(), mDisplaySize);
- mStackView.setPosition(startPoint.x, startPoint.y);
-
updateVisibility();
mNotificationEntryManager.updateNotifications();
}
@@ -249,16 +244,14 @@
BubbleView bubble = mBubbles.get(notif.key);
mStackView.updateBubble(bubble, notif, updatePosition);
} else {
- boolean setPosition = mStackView != null && mStackView.getVisibility() != VISIBLE;
if (mStackView == null) {
- setPosition = true;
mStackView = new BubbleStackView(mContext);
ViewGroup sbv = mStatusBarWindowController.getStatusBarView();
// XXX: Bug when you expand the shade on top of expanded bubble, there is no scrim
// between bubble and the shade
int bubblePosition = sbv.indexOfChild(sbv.findViewById(R.id.scrim_behind)) + 1;
sbv.addView(mStackView, bubblePosition,
- new FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT));
+ new FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT));
if (mExpandListener != null) {
mStackView.setExpandListener(mExpandListener);
}
@@ -273,11 +266,6 @@
}
mBubbles.put(bubble.getKey(), bubble);
mStackView.addBubble(bubble);
- if (setPosition) {
- // Need to add the bubble to the stack before we can know the width
- Point startPoint = getStartPoint(mStackView.getStackWidth(), mDisplaySize);
- mStackView.setPosition(startPoint.x, startPoint.y);
- }
}
updateVisibility();
}
@@ -423,24 +411,6 @@
return mStackView;
}
- // TODO: factor in PIP location / maybe last place user had it
- /**
- * Gets an appropriate starting point to position the bubble stack.
- */
- private static Point getStartPoint(int size, Point displaySize) {
- final int x = displaySize.x - size + EDGE_OVERLAP;
- final int y = displaySize.y / 4;
- return new Point(x, y);
- }
-
- /**
- * Gets an appropriate position for the bubble when the stack is expanded.
- */
- static Point getExpandPoint(BubbleStackView view, int size, Point displaySize) {
- // Same place for now..
- return new Point(EDGE_OVERLAP, size);
- }
-
/**
* Whether the notification has been developer configured to bubble and is allowed by the user.
*/
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleMovementHelper.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleMovementHelper.java
deleted file mode 100644
index c1063fa..0000000
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleMovementHelper.java
+++ /dev/null
@@ -1,326 +0,0 @@
-/*
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.systemui.bubbles;
-
-import static com.android.systemui.Interpolators.FAST_OUT_SLOW_IN;
-
-import android.animation.Animator.AnimatorListener;
-import android.animation.AnimatorSet;
-import android.animation.ValueAnimator;
-import android.content.Context;
-import android.content.res.Resources;
-import android.graphics.Point;
-import android.view.View;
-import android.view.WindowManager;
-
-import com.android.systemui.bubbles.BubbleTouchHandler.FloatingView;
-
-import java.util.Arrays;
-
-/**
- * Math and animators to move bubbles around the screen.
- *
- * TODO: straight up copy paste from old prototype -- consider physics, see if bubble & pip
- * movements can be unified maybe?
- */
-public class BubbleMovementHelper {
-
- private static final int MAGNET_ANIM_TIME = 150;
- public static final int EDGE_OVERLAP = 0;
-
- private Context mContext;
- private Point mDisplaySize;
-
- public BubbleMovementHelper(Context context) {
- mContext = context;
- WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
- mDisplaySize = new Point();
- wm.getDefaultDisplay().getSize(mDisplaySize);
- }
-
- /**
- * @return the distance between the two provided points.
- */
- static double distance(Point p1, Point p2) {
- return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
- }
-
- /**
- * @return the y value of a line defined by y = mx+b
- */
- static float findY(float m, float b, float x) {
- return (m * x) + b;
- }
-
- /**
- * @return the x value of a line defined by y = mx+b
- */
- static float findX(float m, float b, float y) {
- return (y - b) / m;
- }
-
- /**
- * Determines a point on the edge of the screen based on the velocity and position.
- */
- public Point getPointOnEdge(View bv, Point p, float velX, float velY) {
- // Find the slope and the y-intercept
- velX = velX == 0 ? 1 : velX;
- final float m = velY / velX;
- final float b = p.y - m * p.x;
-
- // There are two lines it can intersect, find the two points
- Point pointHoriz = new Point();
- Point pointVert = new Point();
-
- if (velX > 0) {
- // right
- pointHoriz.x = mDisplaySize.x;
- pointHoriz.y = (int) findY(m, b, mDisplaySize.x);
- } else {
- // left
- pointHoriz.x = EDGE_OVERLAP;
- pointHoriz.y = (int) findY(m, b, 0);
- }
- if (velY > 0) {
- // bottom
- pointVert.x = (int) findX(m, b, mDisplaySize.y);
- pointVert.y = mDisplaySize.y - getNavBarHeight();
- } else {
- // top
- pointVert.x = (int) findX(m, b, 0);
- pointVert.y = EDGE_OVERLAP;
- }
-
- // Use the point that's closest to the start position
- final double distanceToVertPoint = distance(p, pointVert);
- final double distanceToHorizPoint = distance(p, pointHoriz);
- boolean useVert = distanceToVertPoint < distanceToHorizPoint;
- // Check if we're being flung along the current edge, use opposite point in this case
- // XXX: on*Edge methods should actually use 'down' position of view and compare 'up' but
- // this works well enough for now
- if (onSideEdge(bv, p) && Math.abs(velY) > Math.abs(velX)) {
- // Flinging along left or right edge, favor vert edge
- useVert = true;
-
- } else if (onTopBotEdge(bv, p) && Math.abs(velX) > Math.abs(velY)) {
- // Flinging along top or bottom edge
- useVert = false;
- }
-
- if (useVert) {
- pointVert.x = capX(pointVert.x, bv);
- pointVert.y = capY(pointVert.y, bv);
- return pointVert;
-
- }
- pointHoriz.x = capX(pointHoriz.x, bv);
- pointHoriz.y = capY(pointHoriz.y, bv);
- return pointHoriz;
- }
-
- /**
- * @return whether the view is on a side edge of the screen (i.e. left or right).
- */
- public boolean onSideEdge(View fv, Point p) {
- return p.x + fv.getWidth() + EDGE_OVERLAP <= mDisplaySize.x
- - EDGE_OVERLAP
- || p.x >= EDGE_OVERLAP;
- }
-
- /**
- * @return whether the view is on a top or bottom edge of the screen.
- */
- public boolean onTopBotEdge(View bv, Point p) {
- return p.y >= getStatusBarHeight() + EDGE_OVERLAP
- || p.y + bv.getHeight() + EDGE_OVERLAP <= mDisplaySize.y
- - EDGE_OVERLAP;
- }
-
- /**
- * @return constrained x value based on screen size and how much a view can overlap with a side
- * edge.
- */
- public int capX(float x, View bv) {
- // Floating things can't stick to top or bottom edges, so figure out if it's closer to
- // left or right and just use that side + the overlap.
- final float centerX = x + bv.getWidth() / 2;
- if (centerX > mDisplaySize.x / 2) {
- // Right side
- return mDisplaySize.x - bv.getWidth() - EDGE_OVERLAP;
- } else {
- // Left side
- return EDGE_OVERLAP;
- }
- }
-
- /**
- * @return constrained y value based on screen size and how much a view can overlap with a top
- * or bottom edge.
- */
- public int capY(float y, View bv) {
- final int height = bv.getHeight();
- if (y < getStatusBarHeight() + EDGE_OVERLAP) {
- return getStatusBarHeight() + EDGE_OVERLAP;
- }
- if (y + height + EDGE_OVERLAP > mDisplaySize.y - EDGE_OVERLAP) {
- return mDisplaySize.y - height - EDGE_OVERLAP;
- }
- return (int) y;
- }
-
- /**
- * Animation to translate the provided view.
- */
- public AnimatorSet animateMagnetTo(final BubbleStackView bv) {
- Point pos = bv.getPosition();
-
- // Find the distance to each edge
- final int leftDistance = pos.x;
- final int rightDistance = mDisplaySize.x - leftDistance;
- final int topDistance = pos.y;
- final int botDistance = mDisplaySize.y - topDistance;
-
- int smallest;
- // Find the closest one
- int[] distances = {
- leftDistance, rightDistance, topDistance, botDistance
- };
- Arrays.sort(distances);
- smallest = distances[0];
-
- // Animate to the closest edge
- Point p = new Point();
- if (smallest == leftDistance) {
- p.x = capX(EDGE_OVERLAP, bv);
- p.y = capY(topDistance, bv);
- }
- if (smallest == rightDistance) {
- p.x = capX(mDisplaySize.x, bv);
- p.y = capY(topDistance, bv);
- }
- if (smallest == topDistance) {
- p.x = capX(leftDistance, bv);
- p.y = capY(0, bv);
- }
- if (smallest == botDistance) {
- p.x = capX(leftDistance, bv);
- p.y = capY(mDisplaySize.y, bv);
- }
- return getTranslateAnim(bv, p, MAGNET_ANIM_TIME);
- }
-
- /**
- * Animation to fling the provided view.
- */
- public AnimatorSet animateFlingTo(final BubbleStackView bv, float velX, float velY) {
- Point pos = bv.getPosition();
- Point endPos = getPointOnEdge(bv, pos, velX, velY);
- endPos = new Point(capX(endPos.x, bv), capY(endPos.y, bv));
- final double distance = Math.sqrt(Math.pow(endPos.x - pos.x, 2)
- + Math.pow(endPos.y - pos.y, 2));
- final float sumVel = Math.abs(velX) + Math.abs(velY);
- final int duration = Math.max(Math.min(200, (int) (distance * 1000f / (sumVel / 2))), 50);
- return getTranslateAnim(bv, endPos, duration);
- }
-
- /**
- * Animation to translate the provided view.
- */
- public AnimatorSet getTranslateAnim(final FloatingView v, Point p, int duration) {
- return getTranslateAnim(v, p, duration, 0);
- }
-
- /**
- * Animation to translate the provided view.
- */
- public AnimatorSet getTranslateAnim(final FloatingView v, Point p,
- int duration, int startDelay) {
- return getTranslateAnim(v, p, duration, startDelay, null);
- }
-
- /**
- * Animation to translate the provided view.
- *
- * @param v the view to translate.
- * @param p the point to translate to.
- * @param duration the duration of the animation.
- * @param startDelay the start delay of the animation.
- * @param listener the listener to add to the animation.
- *
- * @return the animation.
- */
- public static AnimatorSet getTranslateAnim(final FloatingView v, Point p, int duration,
- int startDelay, AnimatorListener listener) {
- Point curPos = v.getPosition();
- final ValueAnimator animX = ValueAnimator.ofFloat(curPos.x, p.x);
- animX.setDuration(duration);
- animX.setStartDelay(startDelay);
- animX.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
- @Override
- public void onAnimationUpdate(ValueAnimator animation) {
- float value = (float) animation.getAnimatedValue();
- v.setPositionX((int) value);
- }
- });
-
- final ValueAnimator animY = ValueAnimator.ofFloat(curPos.y, p.y);
- animY.setDuration(duration);
- animY.setStartDelay(startDelay);
- animY.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
- @Override
- public void onAnimationUpdate(ValueAnimator animation) {
- float value = (float) animation.getAnimatedValue();
- v.setPositionY((int) value);
- }
- });
- if (listener != null) {
- animY.addListener(listener);
- }
-
- AnimatorSet set = new AnimatorSet();
- set.playTogether(animX, animY);
- set.setInterpolator(FAST_OUT_SLOW_IN);
- return set;
- }
-
-
- // TODO -- now that this is in system we should be able to get these better, but ultimately
- // makes more sense to move to movement bounds style a la PIP
- /**
- * Returns the status bar height.
- */
- public int getStatusBarHeight() {
- Resources res = mContext.getResources();
- int resourceId = res.getIdentifier("status_bar_height", "dimen", "android");
- if (resourceId > 0) {
- return res.getDimensionPixelSize(resourceId);
- }
- return 0;
- }
-
- /**
- * Returns the status bar height.
- */
- public int getNavBarHeight() {
- Resources res = mContext.getResources();
- int resourceId = res.getIdentifier("navigation_bar_height", "dimen", "android");
- if (resourceId > 0) {
- return res.getDimensionPixelSize(resourceId);
- }
- return 0;
- }
-}
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
index dcd121b..8bf35cd 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
@@ -16,59 +16,88 @@
package com.android.systemui.bubbles;
+import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
-import android.animation.Animator;
-import android.animation.AnimatorListenerAdapter;
-import android.animation.AnimatorSet;
import android.app.ActivityView;
import android.app.PendingIntent;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Point;
+import android.graphics.PointF;
+import android.graphics.Rect;
import android.graphics.RectF;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
-import android.view.ViewPropertyAnimator;
import android.view.ViewTreeObserver;
import android.view.WindowManager;
-import android.view.animation.AccelerateInterpolator;
-import android.view.animation.OvershootInterpolator;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
+import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.dynamicanimation.animation.SpringAnimation;
+import androidx.dynamicanimation.animation.SpringForce;
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.widget.ViewClippingUtil;
import com.android.systemui.R;
+import com.android.systemui.bubbles.animation.ExpandedAnimationController;
+import com.android.systemui.bubbles.animation.PhysicsAnimationLayout;
+import com.android.systemui.bubbles.animation.StackAnimationController;
import com.android.systemui.statusbar.notification.collection.NotificationEntry;
import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow;
import com.android.systemui.statusbar.notification.stack.ExpandableViewState;
-import com.android.systemui.statusbar.notification.stack.ViewState;
/**
* Renders bubbles in a stack and handles animating expanded and collapsed states.
*/
public class BubbleStackView extends FrameLayout implements BubbleTouchHandler.FloatingView {
-
private static final String TAG = "BubbleStackView";
+
+ /**
+ * Friction applied to fling animations. Since the stack must land on one of the sides of the
+ * screen, we want less friction horizontally so that the stack has a better chance of making it
+ * to the side without needing a spring.
+ */
+ private static final float FLING_FRICTION_X = 1.15f;
+ private static final float FLING_FRICTION_Y = 1.5f;
+
+ /**
+ * Damping ratio to use for the stack spring animation used to spring the stack to its final
+ * position after a fling.
+ */
+ private static final float SPRING_DAMPING_RATIO = 0.85f;
+
+ /**
+ * Minimum fling velocity required to trigger moving the stack from one side of the screen to
+ * the other.
+ */
+ private static final float ESCAPE_VELOCITY = 750f;
+
private Point mDisplaySize;
- private FrameLayout mBubbleContainer;
+ private final SpringAnimation mExpandedViewXAnim;
+ private final SpringAnimation mExpandedViewYAnim;
+
+ private PhysicsAnimationLayout mBubbleContainer;
+ private StackAnimationController mStackAnimationController;
+ private ExpandedAnimationController mExpandedAnimationController;
+
private BubbleExpandedViewContainer mExpandedViewContainer;
private int mBubbleSize;
private int mBubblePadding;
+ private int mExpandedAnimateXDistance;
+ private int mExpandedAnimateYDistance;
private boolean mIsExpanded;
private int mExpandedBubbleHeight;
private BubbleTouchHandler mTouchHandler;
private BubbleView mExpandedBubble;
- private Point mCollapsedPosition;
private BubbleController.BubbleExpandListener mExpandListener;
private boolean mViewUpdatedRequested = false;
@@ -110,8 +139,12 @@
setOnTouchListener(mTouchHandler);
Resources res = getResources();
- mBubbleSize = res.getDimensionPixelSize(R.dimen.bubble_size);
+ mBubbleSize = res.getDimensionPixelSize(R.dimen.individual_bubble_size);
mBubblePadding = res.getDimensionPixelSize(R.dimen.bubble_padding);
+ mExpandedAnimateXDistance =
+ res.getDimensionPixelSize(R.dimen.bubble_expanded_animate_x_distance);
+ mExpandedAnimateYDistance =
+ res.getDimensionPixelSize(R.dimen.bubble_expanded_animate_y_distance);
mExpandedBubbleHeight = res.getDimensionPixelSize(R.dimen.bubble_expanded_default_height);
mDisplaySize = new Point();
@@ -120,6 +153,19 @@
int padding = res.getDimensionPixelSize(R.dimen.bubble_expanded_view_padding);
int elevation = res.getDimensionPixelSize(R.dimen.bubble_elevation);
+
+ mStackAnimationController = new StackAnimationController();
+ mExpandedAnimationController = new ExpandedAnimationController();
+
+ mBubbleContainer = new PhysicsAnimationLayout(context);
+ mBubbleContainer.setMaxRenderedChildren(
+ getResources().getInteger(R.integer.bubbles_max_rendered));
+ mBubbleContainer.setController(mStackAnimationController);
+ mBubbleContainer.setElevation(elevation);
+ mBubbleContainer.setPadding(padding, 0, padding, 0);
+ mBubbleContainer.setClipChildren(false);
+ addView(mBubbleContainer, new FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT));
+
mExpandedViewContainer = (BubbleExpandedViewContainer)
LayoutInflater.from(context).inflate(R.layout.bubble_expanded_view,
this /* parent */, false /* attachToRoot */);
@@ -128,11 +174,19 @@
mExpandedViewContainer.setClipChildren(false);
addView(mExpandedViewContainer);
- mBubbleContainer = new FrameLayout(context);
- mBubbleContainer.setElevation(elevation);
- mBubbleContainer.setPadding(padding, 0, padding, 0);
- mBubbleContainer.setClipChildren(false);
- addView(mBubbleContainer);
+ mExpandedViewXAnim =
+ new SpringAnimation(mExpandedViewContainer, DynamicAnimation.TRANSLATION_X);
+ mExpandedViewXAnim.setSpring(
+ new SpringForce()
+ .setStiffness(SpringForce.STIFFNESS_LOW)
+ .setDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY));
+
+ mExpandedViewYAnim =
+ new SpringAnimation(mExpandedViewContainer, DynamicAnimation.TRANSLATION_Y);
+ mExpandedViewYAnim.setSpring(
+ new SpringForce()
+ .setStiffness(SpringForce.STIFFNESS_LOW)
+ .setDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY));
setClipChildren(false);
}
@@ -144,38 +198,6 @@
}
@Override
- public void onMeasure(int widthSpec, int heightSpec) {
- super.onMeasure(widthSpec, heightSpec);
-
- int bubbleHeightSpec = MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(heightSpec),
- MeasureSpec.UNSPECIFIED);
- if (mIsExpanded) {
- ViewGroup parent = (ViewGroup) getParent();
- int parentWidth = MeasureSpec.makeMeasureSpec(
- MeasureSpec.getSize(parent.getWidth()), MeasureSpec.EXACTLY);
- int parentHeight = MeasureSpec.makeMeasureSpec(
- MeasureSpec.getSize(parent.getHeight()), MeasureSpec.EXACTLY);
- measureChild(mBubbleContainer, parentWidth, bubbleHeightSpec);
-
- int expandedViewHeight = MeasureSpec.makeMeasureSpec(MeasureSpec.getSize(heightSpec),
- MeasureSpec.UNSPECIFIED);
- measureChild(mExpandedViewContainer, parentWidth, expandedViewHeight);
- setMeasuredDimension(widthSpec, parentHeight);
- } else {
- // Not expanded
- measureChild(mExpandedViewContainer, 0, 0);
-
- // Bubbles are translated a little to stack on top of each other
- widthSpec = MeasureSpec.makeMeasureSpec(getStackWidth(), MeasureSpec.EXACTLY);
- measureChild(mBubbleContainer, widthSpec, bubbleHeightSpec);
-
- heightSpec = MeasureSpec.makeMeasureSpec(mBubbleContainer.getMeasuredHeight(),
- MeasureSpec.EXACTLY);
- setMeasuredDimension(widthSpec, heightSpec);
- }
- }
-
- @Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
float x = ev.getRawX();
float y = ev.getRawY();
@@ -293,9 +315,11 @@
boolean updatePosition) {
bubbleView.update(entry);
if (updatePosition && !mIsExpanded) {
- // If alerting it gets promoted to top of the stack
- mBubbleContainer.removeView(bubbleView);
- mBubbleContainer.addView(bubbleView, 0);
+ // If alerting it gets promoted to top of the stack.
+ if (mBubbleContainer.indexOfChild(bubbleView) != 0) {
+ mBubbleContainer.removeViewAndThen(bubbleView,
+ () -> mBubbleContainer.addView(bubbleView, 0));
+ }
requestUpdate();
}
if (mIsExpanded && bubbleView.equals(mExpandedBubble)) {
@@ -359,36 +383,51 @@
if (mIsExpanded != shouldExpand) {
mIsExpanded = shouldExpand;
updateExpandedBubble();
+ applyCurrentState();
+ //requestUpdate();
+
+ mIsAnimating = true;
+
+ Runnable updateAfter = () -> {
+ applyCurrentState();
+ mIsAnimating = false;
+ requestUpdate();
+ };
if (shouldExpand) {
- // Save current position so that we might return there
- savePosition();
+ mBubbleContainer.setController(mExpandedAnimationController);
+ mExpandedAnimationController.expandFromStack(
+ mStackAnimationController.getStackPosition(), updateAfter);
+ } else {
+ mBubbleContainer.cancelAllAnimations();
+ mExpandedAnimationController.collapseBackToStack(
+ () -> {
+ mBubbleContainer.setController(mStackAnimationController);
+ updateAfter.run();
+ });
}
- // Determine the translation for the stack
- Point position = shouldExpand
- ? BubbleController.getExpandPoint(this, mBubbleSize, mDisplaySize)
- : mCollapsedPosition;
- int delay = shouldExpand ? 0 : 100;
- AnimatorSet translationAnim = BubbleMovementHelper.getTranslateAnim(this, position,
- 200, delay, null);
- if (!shouldExpand) {
- // First collapse the stack, then translate, maybe should expand at same time?
- animateStackExpansion(() -> translationAnim.start());
- } else {
- // First translate, then expand
- translationAnim.addListener(new AnimatorListenerAdapter() {
- @Override
- public void onAnimationStart(Animator animation) {
- mIsAnimating = true;
- }
- @Override
- public void onAnimationEnd(Animator animation) {
- animateStackExpansion(() -> mIsAnimating = false);
- }
- });
- translationAnim.start();
+ final float xStart =
+ mStackAnimationController.getStackPosition().x < getWidth() / 2
+ ? -mExpandedAnimateXDistance
+ : mExpandedAnimateXDistance;
+
+ final float yStart = Math.min(
+ mStackAnimationController.getStackPosition().y,
+ mExpandedAnimateYDistance);
+ final float yDest = getStatusBarHeight() + mExpandedBubble.getHeight() + mBubblePadding;
+
+ if (shouldExpand) {
+ mExpandedViewContainer.setTranslationX(xStart);
+ mExpandedViewContainer.setTranslationY(yStart);
+ mExpandedViewContainer.setAlpha(0f);
}
+
+ mExpandedViewXAnim.animateToFinalPosition(shouldExpand ? 0f : xStart);
+ mExpandedViewYAnim.animateToFinalPosition(shouldExpand ? yDest : yStart);
+ mExpandedViewContainer.animate()
+ .setDuration(100)
+ .alpha(shouldExpand ? 1f : 0f);
}
}
@@ -401,14 +440,6 @@
+ mBubbleContainer.getPaddingStart();
}
- /**
- * Saves the current position of the stack, used to save user placement of the stack to
- * return to after an animation.
- */
- private void savePosition() {
- mCollapsedPosition = getPosition();
- }
-
private void notifyExpansionChanged(BubbleView bubbleView, boolean expanded) {
if (mExpandListener != null) {
NotificationEntry entry = bubbleView != null ? bubbleView.getEntry() : null;
@@ -420,31 +451,151 @@
return getBubbleAt(0);
}
- private BubbleView getBubbleAt(int i) {
+ /** Return the BubbleView at the given index from the bubble container. */
+ public BubbleView getBubbleAt(int i) {
return mBubbleContainer.getChildCount() > i
? (BubbleView) mBubbleContainer.getChildAt(i)
: null;
}
@Override
- public void setPosition(int x, int y) {
- setPositionX(x);
- setPositionY(y);
+ public void setPosition(float x, float y) {
+ mStackAnimationController.moveFirstBubbleWithStackFollowing(x, y);
}
@Override
- public void setPositionX(int x) {
- setTranslationX(x);
+ public void setPositionX(float x) {
+ // Unsupported, use setPosition(x, y).
}
@Override
- public void setPositionY(int y) {
- setTranslationY(y);
+ public void setPositionY(float y) {
+ // Unsupported, use setPosition(x, y).
}
@Override
- public Point getPosition() {
- return new Point((int) getTranslationX(), (int) getTranslationY());
+ public PointF getPosition() {
+ return mStackAnimationController.getStackPosition();
+ }
+
+ /** Called when a drag operation on an individual bubble has started. */
+ public void onBubbleDragStart(BubbleView bubble) {
+ // TODO: Save position and snap back if not dismissed.
+ }
+
+ /** Called with the coordinates to which an individual bubble has been dragged. */
+ public void onBubbleDragged(BubbleView bubble, float x, float y) {
+ bubble.setTranslationX(x);
+ bubble.setTranslationY(y);
+ }
+
+ /** Called when a drag operation on an individual bubble has finished. */
+ public void onBubbleDragFinish(BubbleView bubble, float x, float y, float velX, float velY) {
+ // TODO: Add fling to bottom to dismiss.
+ }
+
+ void onDragStart() {
+ if (mIsExpanded) {
+ return;
+ }
+
+ mStackAnimationController.cancelStackPositionAnimations();
+ mBubbleContainer.setController(mStackAnimationController);
+ mIsAnimating = false;
+ }
+
+ void onDragged(float x, float y) {
+ // TODO: We can drag if animating - just need to reroute inflight anims to drag point.
+ if (mIsExpanded) {
+ return;
+ }
+
+ mStackAnimationController.moveFirstBubbleWithStackFollowing(x, y);
+ }
+
+ void onDragFinish(float x, float y, float velX, float velY) {
+ // TODO: Add fling to bottom to dismiss.
+
+ if (mIsExpanded || mIsAnimating) {
+ return;
+ }
+
+ final boolean stackOnLeftSide = x
+ - mBubbleContainer.getChildAt(0).getWidth() / 2
+ < mDisplaySize.x / 2;
+
+ final boolean stackShouldFlingLeft = stackOnLeftSide
+ ? velX < ESCAPE_VELOCITY
+ : velX < -ESCAPE_VELOCITY;
+
+ final RectF stackBounds = mStackAnimationController.getAllowableStackPositionRegion();
+
+ // Target X translation (either the left or right side of the screen).
+ final float destinationRelativeX = stackShouldFlingLeft
+ ? stackBounds.left : stackBounds.right;
+
+ // Minimum velocity required for the stack to make it to the side of the screen.
+ final float escapeVelocity = getMinXVelocity(
+ x,
+ destinationRelativeX,
+ FLING_FRICTION_X);
+
+ // Use the touch event's velocity if it's sufficient, otherwise use the minimum velocity so
+ // that it'll make it all the way to the side of the screen.
+ final float startXVelocity = stackShouldFlingLeft
+ ? Math.min(escapeVelocity, velX)
+ : Math.max(escapeVelocity, velX);
+
+ mStackAnimationController.flingThenSpringFirstBubbleWithStackFollowing(
+ DynamicAnimation.TRANSLATION_X,
+ startXVelocity,
+ FLING_FRICTION_X,
+ new SpringForce()
+ .setStiffness(SpringForce.STIFFNESS_LOW)
+ .setDampingRatio(SPRING_DAMPING_RATIO),
+ destinationRelativeX);
+
+ mStackAnimationController.flingThenSpringFirstBubbleWithStackFollowing(
+ DynamicAnimation.TRANSLATION_Y,
+ velY,
+ FLING_FRICTION_Y,
+ new SpringForce()
+ .setStiffness(SpringForce.STIFFNESS_LOW)
+ .setDampingRatio(SPRING_DAMPING_RATIO),
+ /* destination */ null);
+ }
+
+ /**
+ * Minimum velocity, in pixels/second, required to get from x to destX while being slowed by a
+ * given frictional force.
+ *
+ * This is not derived using real math, I just made it up because the math in FlingAnimation
+ * looks hard and this seems to work. It doesn't actually matter because if it doesn't make it
+ * to the edge via Fling, it'll get Spring'd there anyway.
+ *
+ * TODO(tsuji, or someone who likes math): Figure out math.
+ */
+ private float getMinXVelocity(float x, float destX, float friction) {
+ return (destX - x) * (friction * 5) + ESCAPE_VELOCITY;
+ }
+
+ @Override
+ public void getBoundsOnScreen(Rect outRect) {
+ if (!mIsExpanded) {
+ mBubbleContainer.getChildAt(0).getBoundsOnScreen(outRect);
+ } else {
+ mBubbleContainer.getBoundsOnScreen(outRect);
+ }
+ }
+
+ private int getStatusBarHeight() {
+ if (getRootWindowInsets() != null) {
+ return Math.max(
+ getRootWindowInsets().getSystemWindowInsetTop(),
+ getRootWindowInsets().getDisplayCutout().getSafeInsetTop());
+ }
+
+ return 0;
}
private boolean isIntersecting(View view, float x, float y) {
@@ -510,9 +661,8 @@
mExpandedViewContainer.setHeaderText(null);
}
- int pointerPosition = mExpandedBubble.getPosition().x
- + (mExpandedBubble.getWidth() / 2);
- mExpandedViewContainer.setPointerPosition(pointerPosition);
+ float pointerPosition = mExpandedBubble.getPosition().x + (mExpandedBubble.getWidth() / 2);
+ mExpandedViewContainer.setPointerPosition((int) pointerPosition);
}
private void applyCurrentState() {
@@ -522,7 +672,6 @@
if (!mIsExpanded) {
mExpandedViewContainer.setExpandedView(null);
} else {
- mExpandedViewContainer.setTranslationY(mBubbleContainer.getHeight());
View expandedView = mExpandedViewContainer.getExpandedView();
if (expandedView instanceof ActivityView) {
if (expandedView.isAttachedToWindow()) {
@@ -537,53 +686,6 @@
BubbleView bv = (BubbleView) mBubbleContainer.getChildAt(i);
bv.updateDotVisibility();
bv.setZ(bubbsCount - i);
-
- int transX = mIsExpanded ? (bv.getWidth() + mBubblePadding) * i : mBubblePadding * i;
- ViewState viewState = new ViewState();
- viewState.initFrom(bv);
- viewState.xTranslation = transX;
- viewState.applyToView(bv);
-
- if (mIsExpanded) {
- // Save the position so we can magnet back, tag is retrieved in BubbleTouchHandler
- bv.setTag(new Point(transX, 0));
- }
- }
- }
-
- private void animateStackExpansion(Runnable endRunnable) {
- int childCount = mBubbleContainer.getChildCount();
- for (int i = 0; i < childCount; i++) {
- BubbleView child = (BubbleView) mBubbleContainer.getChildAt(i);
- int transX = mIsExpanded ? (mBubbleSize + mBubblePadding) * i : mBubblePadding * i;
- int duration = childCount > 1 ? 200 : 0;
- if (mIsExpanded) {
- // Save the position so we can magnet back, tag is retrieved in BubbleTouchHandler
- child.setTag(new Point(transX, 0));
- }
- ViewPropertyAnimator anim = child
- .animate()
- .setStartDelay(15 * i)
- .setDuration(duration)
- .setInterpolator(mIsExpanded
- ? new OvershootInterpolator()
- : new AccelerateInterpolator())
- .translationY(0)
- .translationX(transX);
- final int fi = i;
- // Probably want this choreographed with translation somehow / make it snappier
- anim.withStartAction(() -> mIsAnimating = true);
- anim.withEndAction(() -> {
- if (endRunnable != null) {
- endRunnable.run();
- }
- if (fi == mBubbleContainer.getChildCount() - 1) {
- applyCurrentState();
- mIsAnimating = false;
- requestUpdate();
- }
- });
- anim.start();
}
}
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java
index 97784b0..22cd2fc 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java
@@ -19,7 +19,7 @@
import static com.android.systemui.pip.phone.PipDismissViewController.SHOW_TARGET_DELAY;
import android.content.Context;
-import android.graphics.Point;
+import android.graphics.PointF;
import android.os.Handler;
import android.view.MotionEvent;
import android.view.VelocityTracker;
@@ -37,18 +37,16 @@
private BubbleController mController = Dependency.get(BubbleController.class);
private PipDismissViewController mDismissViewController;
- private BubbleMovementHelper mMovementHelper;
// The position of the bubble on down event
- private int mBubbleDownPosX;
- private int mBubbleDownPosY;
+ private float mBubbleDownPosX;
+ private float mBubbleDownPosY;
// The touch position on down event
- private int mDownX = -1;
- private int mDownY = -1;
+ private float mDownX = -1;
+ private float mDownY = -1;
private boolean mMovedEnough;
private int mTouchSlopSquared;
- private float mMinFlingVelocity;
private VelocityTracker mVelocityTracker;
private boolean mInDismissTarget;
@@ -71,32 +69,27 @@
/**
* Sets the position of the view.
*/
- void setPosition(int x, int y);
+ void setPosition(float x, float y);
/**
* Sets the x position of the view.
*/
- void setPositionX(int x);
+ void setPositionX(float x);
/**
* Sets the y position of the view.
*/
- void setPositionY(int y);
+ void setPositionY(float y);
/**
* @return the position of the view.
*/
- Point getPosition();
+ PointF getPosition();
}
public BubbleTouchHandler(Context context) {
final int touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
mTouchSlopSquared = touchSlop * touchSlop;
-
- // Multiply by 3 for better fling
- mMinFlingVelocity = ViewConfiguration.get(context).getScaledMinimumFlingVelocity() * 3;
-
- mMovementHelper = new BubbleMovementHelper(context);
mDismissViewController = new PipDismissViewController(context);
}
@@ -119,9 +112,11 @@
FloatingView floatingView = (FloatingView) targetView;
boolean isBubbleStack = floatingView instanceof BubbleStackView;
- Point startPos = floatingView.getPosition();
- int rawX = (int) event.getRawX();
- int rawY = (int) event.getRawY();
+ PointF startPos = floatingView.getPosition();
+ float rawX = event.getRawX();
+ float rawY = event.getRawY();
+ float x = mBubbleDownPosX + rawX - mDownX;
+ float y = mBubbleDownPosY + rawY - mDownY;
switch (action) {
case MotionEvent.ACTION_DOWN:
trackMovement(event);
@@ -134,6 +129,13 @@
mDownX = rawX;
mDownY = rawY;
mMovedEnough = false;
+
+ if (isBubbleStack) {
+ stack.onDragStart();
+ } else {
+ stack.onBubbleDragStart((BubbleView) floatingView);
+ }
+
break;
case MotionEvent.ACTION_MOVE:
@@ -145,22 +147,23 @@
mDownX = rawX;
mDownY = rawY;
}
- final int deltaX = rawX - mDownX;
- final int deltaY = rawY - mDownY;
+ final float deltaX = rawX - mDownX;
+ final float deltaY = rawY - mDownY;
if ((deltaX * deltaX) + (deltaY * deltaY) > mTouchSlopSquared && !mMovedEnough) {
mMovedEnough = true;
}
- int x = mBubbleDownPosX + rawX - mDownX;
- int y = mBubbleDownPosY + rawY - mDownY;
if (mMovedEnough) {
- if (floatingView instanceof BubbleView && mBubbleDraggingOut == null) {
+ if (floatingView instanceof BubbleView) {
mBubbleDraggingOut = ((BubbleView) floatingView);
+ stack.onBubbleDragged(mBubbleDraggingOut, x, y);
+ } else {
+ stack.onDragged(x, y);
}
- floatingView.setPosition(x, y);
}
// TODO - when we're in the target stick to it / animate in some way?
- mInDismissTarget = mDismissViewController.updateTarget((View) floatingView);
+ mInDismissTarget = mDismissViewController.updateTarget(
+ isBubbleStack ? stack.getBubbleAt(0) : (View) floatingView);
break;
case MotionEvent.ACTION_CANCEL:
@@ -181,19 +184,9 @@
final float velX = mVelocityTracker.getXVelocity();
final float velY = mVelocityTracker.getYVelocity();
if (isBubbleStack) {
- if ((Math.abs(velY) > mMinFlingVelocity)
- || (Math.abs(velX) > mMinFlingVelocity)) {
- // It's being flung somewhere
- mMovementHelper.animateFlingTo(stack, velX, velY).start();
- } else {
- // Magnet back to nearest edge
- mMovementHelper.animateMagnetTo(stack).start();
- }
+ stack.onDragFinish(x, y, velX, velY);
} else {
- // Individual bubble got dragged but not dismissed.. lets animate it back
- // into position
- Point toGoTo = (Point) ((View) floatingView).getTag();
- mMovementHelper.getTranslateAnim(floatingView, toGoTo, 100, 0).start();
+ stack.onBubbleDragFinish(mBubbleDraggingOut, x, y, velX, velY);
}
} else if (floatingView.equals(stack.getExpandedBubble())) {
stack.collapseStack();
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleView.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleView.java
index 7b6e79b..4601939 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleView.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleView.java
@@ -22,7 +22,7 @@
import android.app.PendingIntent;
import android.content.Context;
import android.graphics.Color;
-import android.graphics.Point;
+import android.graphics.PointF;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.Icon;
@@ -266,24 +266,24 @@
}
@Override
- public void setPosition(int x, int y) {
+ public void setPosition(float x, float y) {
setPositionX(x);
setPositionY(y);
}
@Override
- public void setPositionX(int x) {
+ public void setPositionX(float x) {
setTranslationX(x);
}
@Override
- public void setPositionY(int y) {
+ public void setPositionY(float y) {
setTranslationY(y);
}
@Override
- public Point getPosition() {
- return new Point((int) getTranslationX(), (int) getTranslationY());
+ public PointF getPosition() {
+ return new PointF(getTranslationX(), getTranslationY());
}
/**
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/animation/ExpandedAnimationController.java b/packages/SystemUI/src/com/android/systemui/bubbles/animation/ExpandedAnimationController.java
new file mode 100644
index 0000000..f3ca938
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/animation/ExpandedAnimationController.java
@@ -0,0 +1,157 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.bubbles.animation;
+
+import android.graphics.PointF;
+import android.view.View;
+import android.view.WindowInsets;
+
+import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.dynamicanimation.animation.SpringForce;
+
+import com.android.systemui.R;
+
+import com.google.android.collect.Sets;
+
+import java.util.Set;
+
+/**
+ * Animation controller for bubbles when they're in their expanded state, or animating to/from the
+ * expanded state. This controls the expansion animation as well as bubbles 'dragging out' to be
+ * dismissed.
+ */
+public class ExpandedAnimationController
+ extends PhysicsAnimationLayout.PhysicsAnimationController {
+
+ /**
+ * The stack position from which the bubbles were expanded. Saved in {@link #expandFromStack}
+ * and used to return to stack form in {@link #collapseBackToStack}.
+ */
+ private PointF mExpandedFrom;
+
+ /** Horizontal offset between bubbles, which we need to know to re-stack them. */
+ private float mStackOffsetPx;
+ /** Spacing between bubbles in the expanded state. */
+ private float mBubblePaddingPx;
+ /** Size of each bubble. */
+ private float mBubbleSizePx;
+
+ @Override
+ protected void setLayout(PhysicsAnimationLayout layout) {
+ super.setLayout(layout);
+ mStackOffsetPx = layout.getResources().getDimensionPixelSize(
+ R.dimen.bubble_stack_offset);
+ mBubblePaddingPx = layout.getResources().getDimensionPixelSize(
+ R.dimen.bubble_padding);
+ mBubbleSizePx = layout.getResources().getDimensionPixelSize(
+ R.dimen.individual_bubble_size);
+ }
+
+ /**
+ * Animates expanding the bubbles into a row along the top of the screen.
+ *
+ * @return The y-value to which the bubbles were expanded, in case that's useful.
+ */
+ public float expandFromStack(PointF expandedFrom, Runnable after) {
+ mExpandedFrom = expandedFrom;
+
+ // 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();
+ }
+
+ /** Animate collapsing the bubbles back to their stacked position. */
+ 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(mExpandedFrom.x) ? -1 : 1;
+ for (int i = 0; i < mLayout.getChildCount(); i++) {
+ mLayout.animatePositionForChildAtIndex(
+ i, mExpandedFrom.x + (sideMultiplier * i * mStackOffsetPx), mExpandedFrom.y);
+ }
+
+ runAfterTranslationsEnd(after);
+ }
+
+ /** The Y value of the row of expanded bubbles. */
+ private float getExpandedY() {
+ final WindowInsets insets = mLayout.getRootWindowInsets();
+ if (insets != null) {
+ return mBubblePaddingPx + Math.max(
+ insets.getSystemWindowInsetTop(),
+ insets.getDisplayCutout().getSafeInsetTop());
+ }
+
+ return mBubblePaddingPx;
+ }
+
+ /** 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(
+ DynamicAnimation.TRANSLATION_X,
+ DynamicAnimation.TRANSLATION_Y);
+ }
+
+ @Override
+ int getNextAnimationInChain(DynamicAnimation.ViewProperty property, int index) {
+ return NONE;
+ }
+
+ @Override
+ float getOffsetForChainedPropertyAnimation(DynamicAnimation.ViewProperty property) {
+ return 0;
+ }
+
+ @Override
+ SpringForce getSpringForce(DynamicAnimation.ViewProperty property, View view) {
+ return new SpringForce()
+ .setStiffness(SpringForce.STIFFNESS_LOW)
+ .setDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY);
+ }
+
+ @Override
+ void onChildAdded(View child, int index) {
+ // TODO: Animate the new bubble into the row, and push the other bubbles out of the way.
+ child.setTranslationY(getExpandedY());
+ }
+
+ @Override
+ void onChildToBeRemoved(View child, int index, Runnable actuallyRemove) {
+ // TODO: Animate the bubble out, and pull the other bubbles into its position.
+ actuallyRemove.run();
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/animation/OneTimeEndListener.java b/packages/SystemUI/src/com/android/systemui/bubbles/animation/OneTimeEndListener.java
new file mode 100644
index 0000000..4e0abc8
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/animation/OneTimeEndListener.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.bubbles.animation;
+
+import androidx.dynamicanimation.animation.DynamicAnimation;
+
+/**
+ * End listener that removes itself from its animation when called for the first time. Useful since
+ * anonymous OnAnimationEndListener instances can't pass themselves to
+ * {@link DynamicAnimation#removeEndListener}, but can call through to this superclass
+ * implementation.
+ */
+public class OneTimeEndListener implements DynamicAnimation.OnAnimationEndListener {
+
+ @Override
+ public void onAnimationEnd(DynamicAnimation animation, boolean canceled, float value,
+ float velocity) {
+ animation.removeEndListener(this);
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayout.java b/packages/SystemUI/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayout.java
new file mode 100644
index 0000000..1ced3a4
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayout.java
@@ -0,0 +1,496 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.bubbles.animation;
+
+import android.content.Context;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+
+import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.dynamicanimation.animation.SpringAnimation;
+import androidx.dynamicanimation.animation.SpringForce;
+
+import com.android.systemui.R;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Layout that constructs physics-based animations for each of its children, which behave according
+ * to settings provided by a {@link PhysicsAnimationController} instance.
+ *
+ * See physics-animation-layout.md.
+ */
+public class PhysicsAnimationLayout extends FrameLayout {
+ private static final String TAG = "Bubbs.PAL";
+
+ /**
+ * Controls the construction, configuration, and use of the physics animations supplied by this
+ * layout.
+ */
+ abstract static class PhysicsAnimationController {
+
+ /**
+ * Constant to return from {@link #getNextAnimationInChain} if the animation should not be
+ * chained at all.
+ */
+ protected static final int NONE = -1;
+
+ /** Set of properties for which the layout should construct physics animations. */
+ abstract Set<DynamicAnimation.ViewProperty> getAnimatedProperties();
+
+ /**
+ * Returns the index of the next animation after the given index in the animation chain, or
+ * {@link #NONE} if it should not be chained, or if the chain should end at the given index.
+ *
+ * If a next index is returned, an update listener will be added to the animation at the
+ * given index that dispatches value updates to the animation at the next index. This
+ * creates a 'following' effect.
+ *
+ * Typical implementations of this method will return either index + 1, or index - 1, to
+ * create forward or backward chains between adjacent child views, but this is not required.
+ */
+ abstract int getNextAnimationInChain(DynamicAnimation.ViewProperty property, int index);
+
+ /**
+ * Offsets to be added to the value that chained animations of the given property dispatch
+ * to subsequent child animations.
+ *
+ * This is used for things like maintaining the 'stack' effect in Bubbles, where bubbles
+ * stack off to the left or right side slightly.
+ */
+ abstract float getOffsetForChainedPropertyAnimation(DynamicAnimation.ViewProperty property);
+
+ /**
+ * Returns the SpringForce to be used for the given child view's property animation. Despite
+ * these usually being similar or identical across properties and views, {@link SpringForce}
+ * also contains the SpringAnimation's final position, so we have to construct a new one for
+ * each animation rather than using a constant.
+ */
+ abstract SpringForce getSpringForce(DynamicAnimation.ViewProperty property, View view);
+
+ /**
+ * Called when a new child is added at the specified index. Controllers can use this
+ * opportunity to animate in the new view.
+ */
+ abstract void onChildAdded(View child, int index);
+
+ /**
+ * Called when a child is to be removed from the layout. Controllers can use this
+ * opportunity to animate out the new view before calling the provided callback to actually
+ * remove it.
+ *
+ * Controllers should be careful to ensure that actuallyRemove is called on all code paths
+ * or child views will never be removed.
+ */
+ abstract void onChildToBeRemoved(View child, int index, Runnable actuallyRemove);
+
+ protected PhysicsAnimationLayout mLayout;
+
+ PhysicsAnimationController() { }
+
+ protected void setLayout(PhysicsAnimationLayout layout) {
+ this.mLayout = layout;
+ }
+
+ protected PhysicsAnimationLayout getLayout() {
+ return mLayout;
+ }
+ }
+
+ /**
+ * End listeners that are called when every child's animation of the given property has
+ * finished.
+ */
+ protected final HashMap<DynamicAnimation.ViewProperty, DynamicAnimation.OnAnimationEndListener>
+ mEndListenerForProperty = new HashMap<>();
+
+ /**
+ * List of views that were passed to removeView, but are currently being animated out. These
+ * views will be actually removed by the controller (via super.removeView) once they're done
+ * animating out.
+ */
+ private final List<View> mViewsToBeActuallyRemoved = new ArrayList<>();
+
+ /** The currently active animation controller. */
+ private PhysicsAnimationController mController;
+
+ /**
+ * The maximum number of children to render and animate at a time. See
+ * {@link #setMaxRenderedChildren}.
+ */
+ private int mMaxRenderedChildren = 5;
+
+ public PhysicsAnimationLayout(Context context) {
+ super(context);
+ }
+
+ /**
+ * The maximum number of children to render and animate at a time. Any child views added beyond
+ * this limit will be set to {@link View#GONE}. If any animations attempt to run on the view,
+ * the corresponding property will be set with no animation.
+ */
+ public void setMaxRenderedChildren(int max) {
+ this.mMaxRenderedChildren = max;
+ }
+
+ /**
+ * Sets the animation controller and constructs or reconfigures the layout's physics animations
+ * to meet the controller's specifications.
+ */
+ public void setController(PhysicsAnimationController controller) {
+ cancelAllAnimations();
+ mEndListenerForProperty.clear();
+
+ this.mController = controller;
+ mController.setLayout(this);
+
+ // Set up animations for this controller's animated properties.
+ for (DynamicAnimation.ViewProperty property : mController.getAnimatedProperties()) {
+ setUpAnimationsForProperty(property);
+ }
+ }
+
+ /**
+ * Sets an end listener that will be called when all child animations for a given property have
+ * stopped running.
+ */
+ public void setEndListenerForProperty(
+ DynamicAnimation.OnAnimationEndListener listener,
+ DynamicAnimation.ViewProperty property) {
+ mEndListenerForProperty.put(property, listener);
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * Returns the index of the view that precedes the given index, ignoring views that were passed
+ * to removeView, but are currently being animated out before actually being removed.
+ *
+ * @return index of the preceding view, or -1 if there are none.
+ */
+ public int getPrecedingNonRemovedViewIndex(int index) {
+ for (int i = index + 1; i < getChildCount(); i++) {
+ View precedingView = getChildAt(i);
+ if (!mViewsToBeActuallyRemoved.contains(precedingView)) {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ @Override
+ public void addView(View child, int index, ViewGroup.LayoutParams params) {
+ super.addView(child, index, params);
+ setChildrenVisibility();
+
+ // Set up animations for the new view, if the controller is set. If it isn't set, we'll be
+ // setting up animations for all children when setController is called.
+ if (mController != null) {
+ for (DynamicAnimation.ViewProperty property : mController.getAnimatedProperties()) {
+ setUpAnimationForChild(property, child, index);
+ }
+
+ mController.onChildAdded(child, index);
+ }
+ }
+
+ @Override
+ public void removeView(View view) {
+ removeViewAndThen(view, /* callback */ null);
+ }
+
+ /**
+ * Let the controller know that this view should be removed, and then call the callback once the
+ * controller has finished any removal animations and the view has actually been removed.
+ */
+ public void removeViewAndThen(View view, Runnable callback) {
+ if (mController != null) {
+ final int index = indexOfChild(view);
+ // Remove the view only if it exists in this layout, and we're not already working on
+ // animating its removal.
+ if (index > -1 && !mViewsToBeActuallyRemoved.contains(view)) {
+ mViewsToBeActuallyRemoved.add(view);
+ setChildrenVisibility();
+
+ // Tell the controller to animate this view out, and call the callback when it wants
+ // to actually remove the view.
+ mController.onChildToBeRemoved(view, index, () -> {
+ removeViewImmediateAndThen(view, callback);
+ mViewsToBeActuallyRemoved.remove(view);
+ });
+ }
+ } else {
+ // Without a controller, nobody will animate this view out, so it gets an unceremonious
+ // departure.
+ removeViewImmediateAndThen(view, callback);
+ }
+ }
+
+ /** Checks whether any animations of the given properties are still running. */
+ public boolean arePropertiesAnimating(DynamicAnimation.ViewProperty... properties) {
+ for (int i = 0; i < getChildCount(); i++) {
+ for (DynamicAnimation.ViewProperty property : properties) {
+ if (getAnimationAtIndex(property, i).isRunning()) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /** Cancels all animations that are running on all child views, for all properties. */
+ public void cancelAllAnimations() {
+ if (mController == null) {
+ return;
+ }
+
+ for (int i = 0; i < getChildCount(); i++) {
+ for (DynamicAnimation.ViewProperty property : mController.getAnimatedProperties()) {
+ getAnimationAtIndex(property, i).cancel();
+ }
+ }
+ }
+
+ /**
+ * 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) {
+ if (index < getChildCount()) {
+ final SpringAnimation animation = getAnimationAtIndex(property, index);
+ 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();
+ }
+ });
+ }
+
+ if (startVel != Float.MAX_VALUE) {
+ animation.setStartVelocity(startVel);
+ }
+
+ animation.animateToFinalPosition(value);
+ }
+ }
+
+ /** 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) {
+ return x + (getChildAt(0).getWidth() / 2) < getWidth() / 2;
+ } else {
+ return false; // If there's no first child, really anything is correct, right?
+ }
+ }
+
+ /** ViewProperty's toString is useless, this returns a readable name for debug logging. */
+ protected static String getReadablePropertyName(DynamicAnimation.ViewProperty property) {
+ if (property.equals(DynamicAnimation.TRANSLATION_X)) {
+ return "TRANSLATION_X";
+ } else if (property.equals(DynamicAnimation.TRANSLATION_Y)) {
+ return "TRANSLATION_Y";
+ } else if (property.equals(DynamicAnimation.SCALE_X)) {
+ return "SCALE_X";
+ } else if (property.equals(DynamicAnimation.SCALE_Y)) {
+ return "SCALE_Y";
+ } else if (property.equals(DynamicAnimation.ALPHA)) {
+ return "ALPHA";
+ } else {
+ return "Unknown animation property.";
+ }
+ }
+
+
+ /** Immediately removes the view, without notifying the controller, then runs the callback. */
+ private void removeViewImmediateAndThen(View view, Runnable callback) {
+ super.removeView(view);
+
+ if (callback != null) {
+ callback.run();
+ }
+
+ setChildrenVisibility();
+ }
+
+ /**
+ * Retrieves the animation of the given property from the view at the given index via the view
+ * tag system.
+ */
+ private SpringAnimation getAnimationAtIndex(
+ DynamicAnimation.ViewProperty property, int index) {
+ return (SpringAnimation) getChildAt(index).getTag(getTagIdForProperty(property));
+ }
+
+ /** Sets up SpringAnimations of the given property for each child view in the layout. */
+ private void setUpAnimationsForProperty(DynamicAnimation.ViewProperty property) {
+ for (int i = 0; i < getChildCount(); i++) {
+ setUpAnimationForChild(property, getChildAt(i), i);
+ }
+ }
+
+ /** Constructs a SpringAnimation of the given property for a child view. */
+ private void setUpAnimationForChild(
+ DynamicAnimation.ViewProperty property, View child, int index) {
+ SpringAnimation newAnim = new SpringAnimation(child, property);
+ newAnim.addUpdateListener((animation, value, velocity) -> {
+ final int nextAnimInChain =
+ mController.getNextAnimationInChain(property, indexOfChild(child));
+ if (nextAnimInChain == PhysicsAnimationController.NONE) {
+ return;
+ }
+
+ final int animIndex = indexOfChild(child);
+ final float offset =
+ mController.getOffsetForChainedPropertyAnimation(property);
+
+ // If this property's animations should be chained, then check to see if there is a
+ // subsequent animation within the rendering limit, and if so, tell it to animate to
+ // this animation's new value (plus the offset).
+ if (nextAnimInChain < Math.min(
+ getChildCount(),
+ mMaxRenderedChildren + mViewsToBeActuallyRemoved.size())) {
+ getAnimationAtIndex(property, animIndex + 1)
+ .animateToFinalPosition(value + offset);
+ } else if (nextAnimInChain < getChildCount()) {
+ // If the next child view is not rendered, update the property directly without
+ // animating it, so that the view is still in the correct state if it later
+ // becomes visible.
+ for (int i = nextAnimInChain; i < getChildCount(); i++) {
+ // 'value' here is the value of the last child within the rendering limit,
+ // not the first child's value - so we want to subtract the last child's
+ // index when calculating the offset.
+ property.setValue(getChildAt(i), value + offset * (i - animIndex));
+ }
+ }
+ });
+
+ newAnim.setSpring(mController.getSpringForce(property, child));
+ newAnim.addEndListener(new AllAnimationsForPropertyFinishedEndListener(property));
+ child.setTag(getTagIdForProperty(property), newAnim);
+ }
+
+ /** Hides children beyond the max rendering count. */
+ private void setChildrenVisibility() {
+ for (int i = 0; i < getChildCount(); i++) {
+ getChildAt(i).setVisibility(
+ // Ignore views that are animating out when calculating whether to hide the
+ // view. That is, if we're supposed to render 5 views, but 4 are animating out
+ // and will soon be removed, render up to 9 views temporarily.
+ i < (mMaxRenderedChildren + mViewsToBeActuallyRemoved.size())
+ ? View.VISIBLE
+ : View.GONE);
+ }
+ }
+
+ /** Return a stable ID to use as a tag key for the given property's animations. */
+ private int getTagIdForProperty(DynamicAnimation.ViewProperty property) {
+ if (property.equals(DynamicAnimation.TRANSLATION_X)) {
+ return R.id.translation_x_dynamicanimation_tag;
+ } else if (property.equals(DynamicAnimation.TRANSLATION_Y)) {
+ return R.id.translation_y_dynamicanimation_tag;
+ } else if (property.equals(DynamicAnimation.SCALE_X)) {
+ return R.id.scale_x_dynamicanimation_tag;
+ } else if (property.equals(DynamicAnimation.SCALE_Y)) {
+ return R.id.scale_y_dynamicanimation_tag;
+ } else if (property.equals(DynamicAnimation.ALPHA)) {
+ return R.id.alpha_dynamicanimation_tag;
+ }
+
+ return -1;
+ }
+
+ /**
+ * End listener that is added to each individual DynamicAnimation, which dispatches to a single
+ * listener when every other animation of the given property is no longer running.
+ *
+ * This is required since chained DynamicAnimations can stop and start again due to changes in
+ * upstream animations. This means that adding an end listener to just the last animation is not
+ * sufficient. By firing only when every other animation on the property has stopped running, we
+ * ensure that no animation will be restarted after the single end listener is called.
+ */
+ protected class AllAnimationsForPropertyFinishedEndListener
+ implements DynamicAnimation.OnAnimationEndListener {
+ private DynamicAnimation.ViewProperty mProperty;
+
+ AllAnimationsForPropertyFinishedEndListener(DynamicAnimation.ViewProperty property) {
+ this.mProperty = property;
+ }
+
+ @Override
+ 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);
+ }
+ }
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/animation/StackAnimationController.java b/packages/SystemUI/src/com/android/systemui/bubbles/animation/StackAnimationController.java
new file mode 100644
index 0000000..a113a63
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/animation/StackAnimationController.java
@@ -0,0 +1,447 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.bubbles.animation;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Point;
+import android.graphics.PointF;
+import android.graphics.RectF;
+import android.util.Log;
+import android.view.View;
+import android.view.WindowInsets;
+import android.view.WindowManager;
+
+import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.dynamicanimation.animation.FlingAnimation;
+import androidx.dynamicanimation.animation.FloatPropertyCompat;
+import androidx.dynamicanimation.animation.SpringAnimation;
+import androidx.dynamicanimation.animation.SpringForce;
+
+import com.android.systemui.R;
+
+import com.google.android.collect.Sets;
+
+import java.util.HashMap;
+import java.util.Set;
+
+/**
+ * Animation controller for bubbles when they're in their stacked state. Stacked bubbles sit atop
+ * each other with a slight offset to the left or right (depending on which side of the screen they
+ * are on). Bubbles 'follow' each other when dragged, and can be flung to the left or right sides of
+ * the screen.
+ */
+public class StackAnimationController extends
+ PhysicsAnimationLayout.PhysicsAnimationController {
+
+ private static final String TAG = "Bubbs.StackCtrl";
+
+ /** Scale factor to use initially for new bubbles being animated in. */
+ private static final float ANIMATE_IN_STARTING_SCALE = 1.15f;
+
+ /** Translation factor (multiplied by stack offset) to use for new bubbles being animated in. */
+ private static final int ANIMATE_IN_TRANSLATION_FACTOR = 4;
+
+ /**
+ * Values to use for the default {@link SpringForce} provided to the physics animation layout.
+ */
+ private static final float DEFAULT_STIFFNESS = 2500f;
+ private static final float DEFAULT_BOUNCINESS = 0.85f;
+
+ /**
+ * The canonical position of the stack. This is typically the position of the first bubble, but
+ * we need to keep track of it separately from the first bubble's translation in case there are
+ * no bubbles, or the first bubble was just added and being animated to its new position.
+ */
+ private PointF mStackPosition = new PointF();
+
+ /**
+ * Animations on the stack position itself, which would have been started in
+ * {@link #flingThenSpringFirstBubbleWithStackFollowing}. These animations dispatch to
+ * {@link #moveFirstBubbleWithStackFollowing} to move the entire stack (with 'following' effect)
+ * to a legal position on the side of the screen.
+ */
+ private HashMap<DynamicAnimation.ViewProperty, DynamicAnimation> mStackPositionAnimations =
+ new HashMap<>();
+
+ /** Horizontal offset of bubbles in the stack. */
+ private float mStackOffset;
+ /** Diameter of the bubbles themselves. */
+ private int mIndividualBubbleSize;
+ /** Size of spacing around the bubbles, separating it from the edge of the screen. */
+ private int mBubblePadding;
+ /** How far offscreen the stack rests. */
+ private int mBubbleOffscreen;
+ /** How far down the screen the stack starts, when there is no pre-existing location. */
+ private int mStackStartingVerticalOffset;
+
+ private Point mDisplaySize;
+ private RectF mAllowableStackPositionRegion;
+
+ @Override
+ protected void setLayout(PhysicsAnimationLayout layout) {
+ super.setLayout(layout);
+
+ Resources res = layout.getResources();
+ mStackOffset = res.getDimensionPixelSize(R.dimen.bubble_stack_offset);
+ mIndividualBubbleSize = res.getDimensionPixelSize(R.dimen.individual_bubble_size);
+ mBubblePadding = res.getDimensionPixelSize(R.dimen.bubble_padding);
+ mBubbleOffscreen = res.getDimensionPixelSize(R.dimen.bubble_stack_offscreen);
+ mStackStartingVerticalOffset =
+ res.getDimensionPixelSize(R.dimen.bubble_stack_starting_offset_y);
+
+ mDisplaySize = new Point();
+ WindowManager wm =
+ (WindowManager) layout.getContext().getSystemService(Context.WINDOW_SERVICE);
+ wm.getDefaultDisplay().getSize(mDisplaySize);
+ }
+
+ /**
+ * Instantly move the first bubble to the given point, and animate the rest of the stack behind
+ * it with the 'following' effect.
+ */
+ public void moveFirstBubbleWithStackFollowing(float x, float y) {
+ moveFirstBubbleWithStackFollowing(DynamicAnimation.TRANSLATION_X, x);
+ moveFirstBubbleWithStackFollowing(DynamicAnimation.TRANSLATION_Y, y);
+ }
+
+ /**
+ * The position of the stack - typically the position of the first bubble; if no bubbles have
+ * been added yet, it will be where the first bubble will go when added.
+ */
+ public PointF getStackPosition() {
+ return mStackPosition;
+ }
+
+ /**
+ * Flings the first bubble along the given property's axis, using the provided configuration
+ * values. When the animation ends - either by hitting the min/max, or by friction sufficiently
+ * reducing momentum - a SpringAnimation takes over to snap the bubble to the given final
+ * position.
+ */
+ public void flingThenSpringFirstBubbleWithStackFollowing(
+ DynamicAnimation.ViewProperty property,
+ float vel,
+ float friction,
+ SpringForce spring,
+ Float finalPosition) {
+ Log.d(TAG, String.format("Flinging %s.",
+ PhysicsAnimationLayout.getReadablePropertyName(property)));
+
+ StackPositionProperty firstBubbleProperty = new StackPositionProperty(property);
+ final float currentValue = firstBubbleProperty.getValue(this);
+ final RectF bounds = getAllowableStackPositionRegion();
+ final float min =
+ property.equals(DynamicAnimation.TRANSLATION_X)
+ ? bounds.left
+ : bounds.top;
+ final float max =
+ property.equals(DynamicAnimation.TRANSLATION_X)
+ ? bounds.right
+ : bounds.bottom;
+
+ FlingAnimation flingAnimation = new FlingAnimation(this, firstBubbleProperty);
+ flingAnimation.setFriction(friction)
+ .setStartVelocity(vel)
+
+ // If the bubble's property value starts beyond the desired min/max, use that value
+ // instead so that the animation won't immediately end. If, for example, the user
+ // drags the bubbles into the navigation bar, but then flings them upward, we want
+ // the fling to occur despite temporarily having a value outside of the min/max. If
+ // the bubbles are out of bounds and flung even farther out of bounds, the fling
+ // animation will halt immediately and the SpringAnimation will take over, springing
+ // it in reverse to the (legal) final position.
+ .setMinValue(Math.min(currentValue, min))
+ .setMaxValue(Math.max(currentValue, max))
+
+ .addEndListener((animation, canceled, endValue, endVelocity) -> {
+ if (!canceled) {
+ springFirstBubbleWithStackFollowing(property, spring, endVelocity,
+ finalPosition != null
+ ? finalPosition
+ : Math.max(min, Math.min(max, endValue)));
+ }
+ });
+
+ cancelStackPositionAnimation(property);
+ mStackPositionAnimations.put(property, flingAnimation);
+ flingAnimation.start();
+ }
+
+ /**
+ * Cancel any stack position animations that were started by calling
+ * @link #flingThenSpringFirstBubbleWithStackFollowing}, and remove any corresponding end
+ * listeners.
+ */
+ public void cancelStackPositionAnimations() {
+ cancelStackPositionAnimation(DynamicAnimation.TRANSLATION_X);
+ cancelStackPositionAnimation(DynamicAnimation.TRANSLATION_Y);
+
+ mLayout.removeEndListenerForProperty(DynamicAnimation.TRANSLATION_X);
+ mLayout.removeEndListenerForProperty(DynamicAnimation.TRANSLATION_Y);
+ }
+
+ /**
+ * Returns the region within which the stack is allowed to rest. This goes slightly off the left
+ * and right sides of the screen, below the status bar/cutout and above the navigation bar.
+ * While the stack is not allowed to rest outside of these bounds, it can temporarily be
+ * animated or dragged beyond them.
+ */
+ public RectF getAllowableStackPositionRegion() {
+ final WindowInsets insets = mLayout.getRootWindowInsets();
+ mAllowableStackPositionRegion = new RectF();
+
+ if (insets != null) {
+ mAllowableStackPositionRegion.left =
+ -mBubbleOffscreen
+ - mBubblePadding
+ + Math.max(
+ insets.getSystemWindowInsetLeft(),
+ insets.getDisplayCutout().getSafeInsetLeft());
+ mAllowableStackPositionRegion.right =
+ mLayout.getWidth()
+ - mIndividualBubbleSize
+ + mBubbleOffscreen
+ - mBubblePadding
+ - Math.max(
+ insets.getSystemWindowInsetRight(),
+ insets.getDisplayCutout().getSafeInsetRight());
+
+ mAllowableStackPositionRegion.top =
+ mBubblePadding
+ + Math.max(
+ insets.getSystemWindowInsetTop(),
+ insets.getDisplayCutout().getSafeInsetTop());
+ mAllowableStackPositionRegion.bottom =
+ mLayout.getHeight()
+ - mIndividualBubbleSize
+ - mBubblePadding
+ - Math.max(
+ insets.getSystemWindowInsetBottom(),
+ insets.getDisplayCutout().getSafeInsetBottom());
+ }
+
+ return mAllowableStackPositionRegion;
+ }
+
+ @Override
+ Set<DynamicAnimation.ViewProperty> getAnimatedProperties() {
+ return Sets.newHashSet(
+ DynamicAnimation.TRANSLATION_X, // For positioning.
+ DynamicAnimation.TRANSLATION_Y,
+ DynamicAnimation.ALPHA, // For fading in new bubbles.
+ DynamicAnimation.SCALE_X, // For 'popping in' new bubbles.
+ DynamicAnimation.SCALE_Y);
+ }
+
+ @Override
+ int getNextAnimationInChain(DynamicAnimation.ViewProperty property, int index) {
+ if (property.equals(DynamicAnimation.TRANSLATION_X)
+ || property.equals(DynamicAnimation.TRANSLATION_Y)) {
+ return index + 1; // Just chain them linearly.
+ } else {
+ return NONE;
+ }
+ }
+
+
+ @Override
+ float getOffsetForChainedPropertyAnimation(DynamicAnimation.ViewProperty property) {
+ if (property.equals(DynamicAnimation.TRANSLATION_X)) {
+ // Offset to the left if we're on the left, or the right otherwise.
+ return mLayout.isFirstChildXLeftOfCenter(mStackPosition.x)
+ ? -mStackOffset : mStackOffset;
+ } else {
+ return 0f;
+ }
+ }
+
+ @Override
+ SpringForce getSpringForce(DynamicAnimation.ViewProperty property, View view) {
+ return new SpringForce()
+ .setDampingRatio(DEFAULT_BOUNCINESS)
+ .setStiffness(DEFAULT_STIFFNESS);
+ }
+
+ @Override
+ void onChildAdded(View child, int index) {
+ // If this is the first child added, position the stack in its starting position.
+ if (mLayout.getChildCount() == 1) {
+ moveStackToStartPosition();
+ }
+
+ if (mLayout.indexOfChild(child) == 0) {
+ 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_IN_TRANSLATION_FACTOR * xOffset));
+ mLayout.animateValueForChildAtIndex(
+ DynamicAnimation.TRANSLATION_X, 0, mStackPosition.x);
+ }
+ }
+
+ @Override
+ void onChildToBeRemoved(View child, int index, Runnable actuallyRemove) {
+ // Animate the child out, actually removing it once its alpha is zero.
+ mLayout.animateValueForChildAtIndex(
+ DynamicAnimation.ALPHA, index, 0f, () -> {
+ actuallyRemove.run();
+ });
+ mLayout.animateValueForChildAtIndex(
+ DynamicAnimation.SCALE_X, index, ANIMATE_IN_STARTING_SCALE);
+ mLayout.animateValueForChildAtIndex(
+ DynamicAnimation.SCALE_Y, index, ANIMATE_IN_STARTING_SCALE);
+
+ final boolean hasPrecedingChild = index + 1 < mLayout.getChildCount();
+ if (hasPrecedingChild) {
+ final int precedingViewIndex = mLayout.getPrecedingNonRemovedViewIndex(index);
+ if (precedingViewIndex >= 0) {
+ final float offsetX =
+ getOffsetForChainedPropertyAnimation(DynamicAnimation.TRANSLATION_X);
+ mLayout.animatePositionForChildAtIndex(
+ precedingViewIndex,
+ mStackPosition.x + (index * offsetX),
+ mStackPosition.y);
+ }
+ }
+ }
+
+ /** Moves the stack, without any animation, to the starting position. */
+ private void moveStackToStartPosition() {
+ mLayout.post(() -> setStackPosition(
+ getAllowableStackPositionRegion().right,
+ getAllowableStackPositionRegion().top + mStackStartingVerticalOffset));
+ }
+
+ /**
+ * Moves the first bubble instantly to the given X or Y translation, and instructs subsequent
+ * bubbles to animate 'following' to the new location.
+ */
+ private void moveFirstBubbleWithStackFollowing(
+ DynamicAnimation.ViewProperty property, float value) {
+
+ // Update the canonical stack position.
+ if (property.equals(DynamicAnimation.TRANSLATION_X)) {
+ mStackPosition.x = value;
+ } else if (property.equals(DynamicAnimation.TRANSLATION_Y)) {
+ mStackPosition.y = value;
+ }
+
+ if (mLayout.getChildCount() > 0) {
+ property.setValue(mLayout.getChildAt(0), value);
+ mLayout.animateValueForChildAtIndex(
+ property,
+ /* index */ 1,
+ value + getOffsetForChainedPropertyAnimation(property));
+ }
+ }
+
+ /** Moves the stack to a position instantly, with no animation. */
+ private void setStackPosition(float x, float y) {
+ Log.d(TAG, String.format("Setting position to (%f, %f).", x, y));
+ mStackPosition.set(x, y);
+
+ cancelStackPositionAnimations();
+
+ // Since we're not using the chained animations, apply the offsets manually.
+ final float xOffset = getOffsetForChainedPropertyAnimation(DynamicAnimation.TRANSLATION_X);
+ final float yOffset = getOffsetForChainedPropertyAnimation(DynamicAnimation.TRANSLATION_Y);
+ for (int i = 0; i < mLayout.getChildCount(); i++) {
+ mLayout.getChildAt(i).setTranslationX(x + (i * xOffset));
+ mLayout.getChildAt(i).setTranslationY(y + (i * yOffset));
+ }
+ }
+
+ /**
+ * Springs the first bubble to the given final position, with the rest of the stack 'following'.
+ */
+ private void springFirstBubbleWithStackFollowing(
+ DynamicAnimation.ViewProperty property, SpringForce spring,
+ float vel, float finalPosition) {
+
+ Log.d(TAG, String.format("Springing %s to final position %f.",
+ PhysicsAnimationLayout.getReadablePropertyName(property),
+ finalPosition));
+
+ StackPositionProperty firstBubbleProperty = new StackPositionProperty(property);
+ SpringAnimation springAnimation =
+ new SpringAnimation(this, firstBubbleProperty)
+ .setSpring(spring)
+ .setStartVelocity(vel);
+
+ cancelStackPositionAnimation(property);
+ mStackPositionAnimations.put(property, springAnimation);
+ springAnimation.animateToFinalPosition(finalPosition);
+ }
+
+ /**
+ * Cancels any outstanding first bubble property animations that are running. This does not
+ * affect the SpringAnimations controlling the individual bubbles' 'following' effect - it only
+ * cancels animations started from {@link #springFirstBubbleWithStackFollowing} and
+ * {@link #flingThenSpringFirstBubbleWithStackFollowing}.
+ */
+ private void cancelStackPositionAnimation(DynamicAnimation.ViewProperty property) {
+ if (mStackPositionAnimations.containsKey(property)) {
+ mStackPositionAnimations.get(property).cancel();
+ }
+ }
+
+ /**
+ * FloatProperty that uses {@link #moveFirstBubbleWithStackFollowing} to set the first bubble's
+ * translation and animate the rest of the stack with it. A DynamicAnimation can animate this
+ * property directly to move the first bubble and cause the stack to 'follow' to the new
+ * location.
+ *
+ * This could also be achieved by simply animating the first bubble view and adding an update
+ * listener to dispatch movement to the rest of the stack. However, this would require
+ * duplication of logic in that update handler - it's simpler to keep all logic contained in the
+ * {@link #moveFirstBubbleWithStackFollowing} method.
+ */
+ private class StackPositionProperty
+ extends FloatPropertyCompat<StackAnimationController> {
+ private final DynamicAnimation.ViewProperty mProperty;
+
+ private StackPositionProperty(DynamicAnimation.ViewProperty property) {
+ super(property.toString());
+ mProperty = property;
+ }
+
+ @Override
+ public float getValue(StackAnimationController controller) {
+ return mProperty.getValue(mLayout.getChildAt(0));
+ }
+
+ @Override
+ public void setValue(StackAnimationController controller, float value) {
+ moveFirstBubbleWithStackFollowing(mProperty, value);
+ }
+ }
+}
+
diff --git a/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java
index 60a20cf..e802757 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java
@@ -61,7 +61,6 @@
private IActivityManager mActivityManager;
@Mock
private DozeParameters mDozeParameters;
- @Mock
private FrameLayout mStatusBarView;
@Captor
private ArgumentCaptor<NotificationEntryListener> mEntryListenerCaptor;
@@ -80,6 +79,7 @@
@Before
public void setUp() throws Exception {
MockitoAnnotations.initMocks(this);
+ mStatusBarView = new FrameLayout(mContext);
mDependency.injectTestDependency(NotificationEntryManager.class, mNotificationEntryManager);
// Bubbles get added to status bar window view
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
new file mode 100644
index 0000000..1bb7ef4
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/ExpandedAnimationControllerTest.java
@@ -0,0 +1,103 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.bubbles.animation;
+
+import static org.junit.Assert.assertEquals;
+
+import android.content.res.Resources;
+import android.graphics.PointF;
+import android.support.test.filters.SmallTest;
+import android.testing.AndroidTestingRunner;
+
+import androidx.dynamicanimation.animation.DynamicAnimation;
+
+import com.android.systemui.R;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.mockito.Spy;
+
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+public class ExpandedAnimationControllerTest extends PhysicsAnimationLayoutTestCase {
+
+ @Spy
+ private ExpandedAnimationController mExpandedController = new ExpandedAnimationController();
+
+ private int mStackOffset;
+ private float mBubblePadding;
+ private float mBubbleSize;
+
+ private PointF mExpansionPoint;
+
+ @Before
+ public void setUp() throws Exception {
+ super.setUp();
+ addOneMoreThanRenderLimitBubbles();
+ mLayout.setController(mExpandedController);
+ Resources res = mLayout.getResources();
+ mStackOffset = res.getDimensionPixelSize(R.dimen.bubble_stack_offset);
+ mBubblePadding = res.getDimensionPixelSize(R.dimen.bubble_padding);
+ mBubbleSize = res.getDimensionPixelSize(R.dimen.individual_bubble_size);
+ }
+
+ @Test
+ public void testExpansionAndCollapse() throws InterruptedException {
+ mExpansionPoint = new PointF(100, 100);
+ Runnable afterExpand = Mockito.mock(Runnable.class);
+ mExpandedController.expandFromStack(mExpansionPoint, afterExpand);
+
+ waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
+
+ testExpanded();
+ Mockito.verify(afterExpand).run();
+
+ Runnable afterCollapse = Mockito.mock(Runnable.class);
+ mExpandedController.collapseBackToStack(afterCollapse);
+
+ waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
+
+ testStackedAtPosition(mExpansionPoint.x, mExpansionPoint.y, -1);
+ Mockito.verify(afterExpand).run();
+ }
+
+ /** Check that children are in the correct positions for being stacked. */
+ private void testStackedAtPosition(float x, float y, int offsetMultiplier) {
+ // Make sure the rest of the stack moved again, including the first bubble not moving, and
+ // is stacked to the right now that we're on the right side of the screen.
+ for (int i = 0; i < mLayout.getChildCount(); i++) {
+ assertEquals(x + i * offsetMultiplier * mStackOffset,
+ mViews.get(i).getTranslationX(), 2f);
+ assertEquals(y, mViews.get(i).getTranslationY(), 2f);
+ }
+ }
+
+ /** Check that children are in the correct positions for being expanded. */
+ private void testExpanded() {
+ // Make sure the rest of the stack moved again, including the first bubble not moving, and
+ // is stacked to the right now that we're on the right side of the screen.
+ for (int i = 0; i < mLayout.getChildCount(); i++) {
+ assertEquals(mBubblePadding + (i * (mBubbleSize + mBubblePadding)),
+ mViews.get(i).getTranslationX(),
+ 2f);
+ assertEquals(mBubblePadding + mCutoutInsetSize,
+ mViews.get(i).getTranslationY(), 2f);
+ }
+ }
+}
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
new file mode 100644
index 0000000..bfc02d9
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayoutTest.java
@@ -0,0 +1,471 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.bubbles.animation;
+
+import static org.junit.Assert.assertEquals;
+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 android.os.SystemClock;
+import android.support.test.filters.SmallTest;
+import android.testing.AndroidTestingRunner;
+import android.view.View;
+import android.widget.FrameLayout;
+
+import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.dynamicanimation.animation.SpringForce;
+
+import com.google.android.collect.Sets;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InOrder;
+import org.mockito.Mockito;
+import org.mockito.Spy;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+/** Tests the PhysicsAnimationLayout itself, with a basic test animation controller. */
+public class PhysicsAnimationLayoutTest extends PhysicsAnimationLayoutTestCase {
+ static final float TEST_TRANSLATION_X_OFFSET = 15f;
+
+ @Spy
+ private TestableAnimationController mTestableController = new TestableAnimationController();
+
+ @Before
+ public void setUp() throws Exception {
+ super.setUp();
+
+ // By default, use translation animations, chain the X animations with the default
+ // 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));
+ mTestableController.setChainedProperties(Sets.newHashSet(DynamicAnimation.TRANSLATION_X));
+ mTestableController.setOffsetForProperty(
+ DynamicAnimation.TRANSLATION_X, TEST_TRANSLATION_X_OFFSET);
+ mTestableController.setRemoveImmediately(false);
+ }
+
+ @Test
+ public void testRenderVisibility() {
+ mLayout.setController(mTestableController);
+ addOneMoreThanRenderLimitBubbles();
+
+ // The last child should be GONE, the rest VISIBLE.
+ for (int i = 0; i < mMaxRenderedBubbles + 1; i++) {
+ assertEquals(i == mMaxRenderedBubbles ? View.GONE : View.VISIBLE,
+ mLayout.getChildAt(i).getVisibility());
+ }
+ }
+
+ @Test
+ public void testHierarchyChanges() {
+ mLayout.setController(mTestableController);
+ addOneMoreThanRenderLimitBubbles();
+
+ // Make sure the controller was notified of all the views we added.
+ for (View mView : mViews) {
+ Mockito.verify(mTestableController).onChildAdded(mView, 0);
+ }
+
+ // Remove some views and ensure the controller was notified, with the proper indices.
+ mTestableController.setRemoveImmediately(true);
+ mLayout.removeView(mViews.get(1));
+ mLayout.removeView(mViews.get(2));
+ Mockito.verify(mTestableController).onChildToBeRemoved(
+ eq(mViews.get(1)), eq(1), any());
+ Mockito.verify(mTestableController).onChildToBeRemoved(
+ eq(mViews.get(2)), eq(1), any());
+
+ // Make sure we still get view added notifications after doing some removals.
+ final View newBubble = new FrameLayout(mContext);
+ mLayout.addView(newBubble, 0);
+ Mockito.verify(mTestableController).onChildAdded(newBubble, 0);
+ }
+
+ @Test
+ public void testUpdateValueNotChained() throws InterruptedException {
+ mLayout.setController(mTestableController);
+ addOneMoreThanRenderLimitBubbles();
+
+ // Don't chain any values.
+ mTestableController.setChainedProperties(Sets.newHashSet());
+
+ // Child views should not be translated.
+ assertEquals(0, mLayout.getChildAt(0).getTranslationX(), .1f);
+ assertEquals(0, mLayout.getChildAt(1).getTranslationX(), .1f);
+
+ // Animate the first child's translation X.
+ final CountDownLatch animLatch = new CountDownLatch(1);
+ mLayout.animateValueForChildAtIndex(
+ DynamicAnimation.TRANSLATION_X,
+ 0,
+ 100,
+ animLatch::countDown);
+ animLatch.await(1, TimeUnit.SECONDS);
+
+ // Ensure that the first view has been translated, but not the second one.
+ assertEquals(100, mLayout.getChildAt(0).getTranslationX(), .1f);
+ assertEquals(0, mLayout.getChildAt(1).getTranslationX(), .1f);
+ }
+
+ @Test
+ public void testUpdateValueXChained() throws InterruptedException {
+ mLayout.setController(mTestableController);
+ addOneMoreThanRenderLimitBubbles();
+ testChainedTranslationAnimations();
+ }
+
+ @Test
+ public void testSetEndListeners() throws InterruptedException {
+ mLayout.setController(mTestableController);
+ addOneMoreThanRenderLimitBubbles();
+ mTestableController.setChainedProperties(Sets.newHashSet());
+
+ final CountDownLatch xLatch = new CountDownLatch(1);
+ OneTimeEndListener xEndListener = Mockito.spy(new OneTimeEndListener() {
+ @Override
+ public void onAnimationEnd(DynamicAnimation animation, boolean canceled, float value,
+ float velocity) {
+ super.onAnimationEnd(animation, canceled, value, velocity);
+ xLatch.countDown();
+ }
+ });
+
+ final CountDownLatch yLatch = new CountDownLatch(1);
+ final OneTimeEndListener yEndListener = Mockito.spy(new OneTimeEndListener() {
+ @Override
+ public void onAnimationEnd(DynamicAnimation animation, boolean canceled, float value,
+ float velocity) {
+ super.onAnimationEnd(animation, canceled, value, velocity);
+ yLatch.countDown();
+ }
+ });
+
+ // Set end listeners for both x and y.
+ mLayout.setEndListenerForProperty(xEndListener, DynamicAnimation.TRANSLATION_X);
+ mLayout.setEndListenerForProperty(yEndListener, DynamicAnimation.TRANSLATION_Y);
+
+ // Animate x, and wait for it to finish.
+ mLayout.animateValueForChildAtIndex(
+ DynamicAnimation.TRANSLATION_X,
+ 0,
+ 100);
+ 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());
+ }
+
+ @Test
+ public void testRemoveEndListeners() throws InterruptedException {
+ mLayout.setController(mTestableController);
+ addOneMoreThanRenderLimitBubbles();
+ mTestableController.setChainedProperties(Sets.newHashSet());
+
+ final CountDownLatch xLatch = new CountDownLatch(1);
+ OneTimeEndListener xEndListener = Mockito.spy(new OneTimeEndListener() {
+ @Override
+ public void onAnimationEnd(DynamicAnimation animation, boolean canceled, float value,
+ float velocity) {
+ super.onAnimationEnd(animation, canceled, value, velocity);
+ xLatch.countDown();
+ }
+ });
+
+ // Set the end listener.
+ mLayout.setEndListenerForProperty(xEndListener, DynamicAnimation.TRANSLATION_X);
+
+ // Animate x, and wait for it to finish.
+ mLayout.animateValueForChildAtIndex(
+ DynamicAnimation.TRANSLATION_X,
+ 0,
+ 100);
+ xLatch.await();
+
+ InOrder endListenerCalls = inOrder(xEndListener);
+ endListenerCalls.verify(xEndListener, Mockito.times(1))
+ .onAnimationEnd(
+ any(),
+ eq(false),
+ eq(100f),
+ anyFloat());
+
+ // Animate X again, remove the end listener.
+ mLayout.animateValueForChildAtIndex(
+ DynamicAnimation.TRANSLATION_X,
+ 0,
+ 1000);
+ mLayout.removeEndListenerForProperty(DynamicAnimation.TRANSLATION_X);
+ xLatch.await(1, TimeUnit.SECONDS);
+
+ // Make sure the end listener was not called.
+ endListenerCalls.verifyNoMoreInteractions();
+ }
+
+ @Test
+ public void testPrecedingNonRemovedIndex() {
+ mLayout.setController(mTestableController);
+ addOneMoreThanRenderLimitBubbles();
+
+ // Call removeView at index 4, but don't actually remove it yet (as if we're animating it
+ // out). The preceding, non-removed view index to 3 should initially be 4, but then 5 since
+ // 4 is on its way out.
+ assertEquals(4, mLayout.getPrecedingNonRemovedViewIndex(3));
+ mLayout.removeView(mViews.get(4));
+ assertEquals(5, mLayout.getPrecedingNonRemovedViewIndex(3));
+
+ // Call removeView at index 1, and actually remove it immediately. With the old view at 1
+ // instantly gone, the preceding view to 0 should be 1 in both cases.
+ assertEquals(1, mLayout.getPrecedingNonRemovedViewIndex(0));
+ mTestableController.setRemoveImmediately(true);
+ mLayout.removeView(mViews.get(1));
+ assertEquals(1, mLayout.getPrecedingNonRemovedViewIndex(0));
+ }
+
+ @Test
+ public void testSetController() throws InterruptedException {
+ // Add the bubbles, then set the controller, to make sure that a controller added to an
+ // already-initialized view works correctly.
+ addOneMoreThanRenderLimitBubbles();
+ mLayout.setController(mTestableController);
+ testChainedTranslationAnimations();
+
+ TestableAnimationController secondController =
+ Mockito.spy(new TestableAnimationController());
+ secondController.setAnimatedProperties(Sets.newHashSet(
+ DynamicAnimation.SCALE_X, DynamicAnimation.SCALE_Y));
+ secondController.setChainedProperties(Sets.newHashSet(
+ DynamicAnimation.SCALE_X));
+ secondController.setOffsetForProperty(
+ DynamicAnimation.SCALE_X, 10f);
+ secondController.setRemoveImmediately(true);
+
+ mLayout.setController(secondController);
+ mLayout.animateValueForChildAtIndex(
+ DynamicAnimation.SCALE_X,
+ 0,
+ 1.5f);
+
+ waitForPropertyAnimations(DynamicAnimation.SCALE_X);
+
+ // Make sure we never asked the original controller about any SCALE animations, that would
+ // mean the controller wasn't switched over properly.
+ Mockito.verify(mTestableController, Mockito.never())
+ .getNextAnimationInChain(eq(DynamicAnimation.SCALE_X), anyInt());
+ Mockito.verify(mTestableController, Mockito.never())
+ .getOffsetForChainedPropertyAnimation(eq(DynamicAnimation.SCALE_X));
+
+ // Make sure we asked the new controller about its animated properties, and configuration
+ // options.
+ Mockito.verify(secondController, Mockito.atLeastOnce())
+ .getAnimatedProperties();
+ Mockito.verify(secondController, Mockito.atLeastOnce())
+ .getNextAnimationInChain(eq(DynamicAnimation.SCALE_X), anyInt());
+ Mockito.verify(secondController, Mockito.atLeastOnce())
+ .getOffsetForChainedPropertyAnimation(eq(DynamicAnimation.SCALE_X));
+
+ mLayout.setController(mTestableController);
+ mLayout.animateValueForChildAtIndex(
+ DynamicAnimation.TRANSLATION_X,
+ 0,
+ 100f);
+
+ waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X);
+
+ // Make sure we never asked the second controller about the TRANSLATION_X animation.
+ Mockito.verify(secondController, Mockito.never())
+ .getNextAnimationInChain(eq(DynamicAnimation.TRANSLATION_X), anyInt());
+ Mockito.verify(secondController, Mockito.never())
+ .getOffsetForChainedPropertyAnimation(eq(DynamicAnimation.TRANSLATION_X));
+
+ }
+
+ @Test
+ public void testArePropertiesAnimating() throws InterruptedException {
+ mLayout.setController(mTestableController);
+ addOneMoreThanRenderLimitBubbles();
+
+ assertFalse(mLayout.arePropertiesAnimating(
+ DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y));
+
+ mLayout.animateValueForChildAtIndex(
+ DynamicAnimation.TRANSLATION_X,
+ 0,
+ 100);
+
+ // Wait for the animations to get underway.
+ SystemClock.sleep(50);
+
+ assertTrue(mLayout.arePropertiesAnimating(DynamicAnimation.TRANSLATION_X));
+ assertFalse(mLayout.arePropertiesAnimating(DynamicAnimation.TRANSLATION_Y));
+
+ waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X);
+
+ assertFalse(mLayout.arePropertiesAnimating(
+ DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y));
+ }
+
+ @Test
+ public void testCancelAllAnimations() throws InterruptedException {
+ mLayout.setController(mTestableController);
+ addOneMoreThanRenderLimitBubbles();
+
+ mLayout.animateValueForChildAtIndex(
+ DynamicAnimation.TRANSLATION_X,
+ 0,
+ 1000);
+ mLayout.animateValueForChildAtIndex(
+ DynamicAnimation.TRANSLATION_Y,
+ 0,
+ 1000);
+
+ mLayout.cancelAllAnimations();
+
+ waitForLayoutMessageQueue();
+
+ // Animations should be somewhere before their end point.
+ assertTrue(mViews.get(0).getTranslationX() < 1000);
+ assertTrue(mViews.get(0).getTranslationY() < 1000);
+ }
+
+
+ /** Standard test of chained translation animations. */
+ private void testChainedTranslationAnimations() throws InterruptedException {
+ assertEquals(0, mLayout.getChildAt(0).getTranslationX(), .1f);
+ assertEquals(0, mLayout.getChildAt(1).getTranslationX(), .1f);
+
+ mLayout.animateValueForChildAtIndex(
+ DynamicAnimation.TRANSLATION_X,
+ 0,
+ 100);
+
+ waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X);
+
+ // Since we enabled chaining, animating the first view to 100 should animate the second to
+ // 115 (since we set the offset to 15) and the third to 130, etc. Despite the sixth bubble
+ // not being visible, or animated, make sure that it has the appropriate chained
+ // translation.
+ for (int i = 0; i < mMaxRenderedBubbles + 1; i++) {
+ assertEquals(
+ 100 + i * TEST_TRANSLATION_X_OFFSET,
+ mLayout.getChildAt(i).getTranslationX(), .1f);
+ }
+
+ // Ensure that the Y translations were unaffected.
+ assertEquals(0, mLayout.getChildAt(0).getTranslationY(), .1f);
+ assertEquals(0, mLayout.getChildAt(1).getTranslationY(), .1f);
+
+ // Animate the first child's Y translation.
+ mLayout.animateValueForChildAtIndex(
+ DynamicAnimation.TRANSLATION_Y,
+ 0,
+ 100);
+
+ waitForPropertyAnimations(DynamicAnimation.TRANSLATION_Y);
+
+ // Ensure that only the first view's Y translation chained, since we only chained X
+ // translations.
+ assertEquals(100, mLayout.getChildAt(0).getTranslationY(), .1f);
+ assertEquals(0, mLayout.getChildAt(1).getTranslationY(), .1f);
+ }
+
+ /**
+ * Animation controller with configuration methods whose return values can be set by individual
+ * tests.
+ */
+ private class TestableAnimationController
+ extends PhysicsAnimationLayout.PhysicsAnimationController {
+ private Set<DynamicAnimation.ViewProperty> mAnimatedProperties = new HashSet<>();
+ private Set<DynamicAnimation.ViewProperty> mChainedProperties = new HashSet<>();
+ private HashMap<DynamicAnimation.ViewProperty, Float> mOffsetForProperty = new HashMap<>();
+ private boolean mRemoveImmediately = false;
+
+ void setAnimatedProperties(
+ Set<DynamicAnimation.ViewProperty> animatedProperties) {
+ mAnimatedProperties = animatedProperties;
+ }
+
+ void setChainedProperties(
+ Set<DynamicAnimation.ViewProperty> chainedProperties) {
+ mChainedProperties = chainedProperties;
+ }
+
+ void setOffsetForProperty(
+ DynamicAnimation.ViewProperty property, float offset) {
+ mOffsetForProperty.put(property, offset);
+ }
+
+ public void setRemoveImmediately(boolean removeImmediately) {
+ mRemoveImmediately = removeImmediately;
+ }
+
+ @Override
+ Set<DynamicAnimation.ViewProperty> getAnimatedProperties() {
+ return mAnimatedProperties;
+ }
+
+ @Override
+ int getNextAnimationInChain(DynamicAnimation.ViewProperty property, int index) {
+ return mChainedProperties.contains(property) ? index + 1 : NONE;
+ }
+
+ @Override
+ float getOffsetForChainedPropertyAnimation(DynamicAnimation.ViewProperty property) {
+ return mOffsetForProperty.getOrDefault(property, 0f);
+ }
+
+ @Override
+ SpringForce getSpringForce(DynamicAnimation.ViewProperty property, View view) {
+ return new SpringForce();
+ }
+
+ @Override
+ void onChildAdded(View child, int index) {}
+
+ @Override
+ void onChildToBeRemoved(View child, int index, Runnable actuallyRemove) {
+ if (mRemoveImmediately) {
+ actuallyRemove.run();
+ }
+ }
+ }
+}
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
new file mode 100644
index 0000000..186a762
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayoutTestCase.java
@@ -0,0 +1,190 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.bubbles.animation;
+
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+import android.view.DisplayCutout;
+import android.view.View;
+import android.view.WindowInsets;
+import android.widget.FrameLayout;
+
+import androidx.dynamicanimation.animation.DynamicAnimation;
+
+import com.android.systemui.R;
+import com.android.systemui.SysuiTestCase;
+
+import org.junit.Before;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Test case for tests that involve the {@link PhysicsAnimationLayout}. This test case constructs a
+ * testable version of the layout, and provides some helpful methods to add views to the layout and
+ * wait for physics animations to finish running.
+ *
+ * See physics-animation-testing.md.
+ */
+public class PhysicsAnimationLayoutTestCase extends SysuiTestCase {
+ TestablePhysicsAnimationLayout mLayout;
+ List<View> mViews = new ArrayList<>();
+
+ Handler mMainThreadHandler;
+
+ int mMaxRenderedBubbles;
+ int mSystemWindowInsetSize = 50;
+ int mCutoutInsetSize = 100;
+
+ int mWidth = 1000;
+ int mHeight = 1000;
+
+ @Mock
+ private WindowInsets mWindowInsets;
+
+ @Mock
+ private DisplayCutout mCutout;
+
+ @Before
+ public void setUp() throws Exception {
+ MockitoAnnotations.initMocks(this);
+
+ mLayout = new TestablePhysicsAnimationLayout(mContext);
+ mLayout.setLeft(0);
+ mLayout.setRight(mWidth);
+ mLayout.setTop(0);
+ mLayout.setBottom(mHeight);
+
+ mMaxRenderedBubbles =
+ getContext().getResources().getInteger(R.integer.bubbles_max_rendered);
+ mMainThreadHandler = new Handler(Looper.getMainLooper());
+
+ when(mWindowInsets.getSystemWindowInsetTop()).thenReturn(mSystemWindowInsetSize);
+ when(mWindowInsets.getSystemWindowInsetBottom()).thenReturn(mSystemWindowInsetSize);
+ when(mWindowInsets.getSystemWindowInsetLeft()).thenReturn(mSystemWindowInsetSize);
+ when(mWindowInsets.getSystemWindowInsetRight()).thenReturn(mSystemWindowInsetSize);
+
+ when(mWindowInsets.getDisplayCutout()).thenReturn(mCutout);
+ when(mCutout.getSafeInsetTop()).thenReturn(mCutoutInsetSize);
+ when(mCutout.getSafeInsetBottom()).thenReturn(mCutoutInsetSize);
+ when(mCutout.getSafeInsetLeft()).thenReturn(mCutoutInsetSize);
+ when(mCutout.getSafeInsetRight()).thenReturn(mCutoutInsetSize);
+ }
+
+ /** Add one extra bubble over the limit, so we can make sure it's gone/chains appropriately. */
+ void addOneMoreThanRenderLimitBubbles() {
+ for (int i = 0; i < mMaxRenderedBubbles + 1; i++) {
+ final View newView = new FrameLayout(mContext);
+ mLayout.addView(newView, 0);
+ mViews.add(0, newView);
+
+ newView.setTranslationX(0);
+ newView.setTranslationY(0);
+ }
+ }
+
+ /**
+ * Uses a {@link java.util.concurrent.CountDownLatch} to wait for the given properties'
+ * animations to finish before allowing the test to proceed.
+ */
+ void waitForPropertyAnimations(DynamicAnimation.ViewProperty... properties)
+ 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);
+ }
+ animLatch.await(1, TimeUnit.SECONDS);
+ }
+
+ /** Uses a latch to wait for the message queue to finish. */
+ void waitForLayoutMessageQueue() throws InterruptedException {
+ // Wait for layout, then the view should be actually removed.
+ CountDownLatch layoutLatch = new CountDownLatch(1);
+ mLayout.post(layoutLatch::countDown);
+ layoutLatch.await(1, TimeUnit.SECONDS);
+ }
+
+ /**
+ * Testable subclass of the PhysicsAnimationLayout that ensures methods that trigger animations
+ * are run on the main thread, which is a requirement of DynamicAnimation.
+ */
+ protected class TestablePhysicsAnimationLayout extends PhysicsAnimationLayout {
+ public TestablePhysicsAnimationLayout(Context context) {
+ super(context);
+ }
+
+ @Override
+ public void setController(PhysicsAnimationController controller) {
+ mMainThreadHandler.post(() -> super.setController(controller));
+ try {
+ waitForLayoutMessageQueue();
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+ }
+
+ @Override
+ public void cancelAllAnimations() {
+ mMainThreadHandler.post(super::cancelAllAnimations);
+ }
+
+ @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;
+ }
+
+ /**
+ * Sets an end listener that will be called after the 'real' end listener that was already
+ * set.
+ */
+ private void setTestEndListenerForProperty(DynamicAnimation.OnAnimationEndListener listener,
+ DynamicAnimation.ViewProperty property) {
+ final DynamicAnimation.OnAnimationEndListener realEndListener =
+ mEndListenerForProperty.get(property);
+
+ setEndListenerForProperty((animation, canceled, value, velocity) -> {
+ if (realEndListener != null) {
+ realEndListener.onAnimationEnd(animation, canceled, value, velocity);
+ }
+
+ listener.onAnimationEnd(animation, canceled, value, velocity);
+ }, property);
+ }
+ }
+}
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
new file mode 100644
index 0000000..0f686df
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/StackAnimationControllerTest.java
@@ -0,0 +1,221 @@
+/*
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.systemui.bubbles.animation;
+
+import static org.junit.Assert.assertEquals;
+
+import android.graphics.PointF;
+import android.support.test.filters.SmallTest;
+import android.testing.AndroidTestingRunner;
+import android.view.View;
+import android.widget.FrameLayout;
+
+import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.dynamicanimation.animation.SpringForce;
+
+import com.android.systemui.R;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Spy;
+
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+public class StackAnimationControllerTest extends PhysicsAnimationLayoutTestCase {
+
+ @Spy
+ private TestableStackController mStackController = new TestableStackController();
+
+ private int mStackOffset;
+
+ @Before
+ public void setUp() throws Exception {
+ super.setUp();
+ addOneMoreThanRenderLimitBubbles();
+ mLayout.setController(mStackController);
+ mStackOffset = mLayout.getResources().getDimensionPixelSize(R.dimen.bubble_stack_offset);
+ }
+
+ /**
+ * Test moving around the stack, and make sure the position is updated correctly, and the stack
+ * direction is correct.
+ */
+ @Test
+ public void testMoveFirstBubbleWithStackFollowing() throws InterruptedException {
+ mStackController.moveFirstBubbleWithStackFollowing(200, 100);
+
+ // The first bubble should have moved instantly, the rest should be waiting for animation.
+ assertEquals(200, mViews.get(0).getTranslationX(), .1f);
+ assertEquals(100, mViews.get(0).getTranslationY(), .1f);
+ assertEquals(0, mViews.get(1).getTranslationX(), .1f);
+ assertEquals(0, mViews.get(1).getTranslationY(), .1f);
+
+ waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
+
+ // Make sure the rest of the stack got moved to the right place and is stacked to the left.
+ testStackedAtPosition(200, 100, -1);
+ assertEquals(new PointF(200, 100), mStackController.getStackPosition());
+
+ mStackController.moveFirstBubbleWithStackFollowing(1000, 500);
+
+ // The first bubble again should have moved instantly while the rest remained where they
+ // were until the animation takes over.
+ assertEquals(1000, mViews.get(0).getTranslationX(), .1f);
+ assertEquals(500, mViews.get(0).getTranslationY(), .1f);
+ assertEquals(200 + -mStackOffset, mViews.get(1).getTranslationX(), .1f);
+ assertEquals(100, mViews.get(1).getTranslationY(), .1f);
+
+ waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
+
+ // Make sure the rest of the stack moved again, including the first bubble not moving, and
+ // is stacked to the right now that we're on the right side of the screen.
+ testStackedAtPosition(1000, 500, 1);
+ assertEquals(new PointF(1000, 500), mStackController.getStackPosition());
+ }
+
+ @Test
+ public void testFlingSideways() throws InterruptedException {
+ // Hard fling directly upwards, no X velocity. The X fling should terminate pretty much
+ // immediately, and spring to 0f, the y fling is hard enough that it will overshoot the top
+ // but should bounce back down.
+ mStackController.flingThenSpringFirstBubbleWithStackFollowing(
+ DynamicAnimation.TRANSLATION_X,
+ 5000f, 1.15f, new SpringForce(), mWidth * 1f);
+ mStackController.flingThenSpringFirstBubbleWithStackFollowing(
+ DynamicAnimation.TRANSLATION_Y,
+ 0f, 1.15f, new SpringForce(), 0f);
+
+ // Nothing should move initially since the animations haven't begun, including the first
+ // view.
+ assertEquals(0f, mViews.get(0).getTranslationX(), 1f);
+ assertEquals(0f, mViews.get(0).getTranslationY(), 1f);
+
+ // Wait for the flinging.
+ waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X,
+ DynamicAnimation.TRANSLATION_Y);
+
+ // Wait for the springing.
+ waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X,
+ DynamicAnimation.TRANSLATION_Y);
+
+ // Once the dust has settled, we should have flung all the way to the right side, with the
+ // stack stacked off to the right now.
+ testStackedAtPosition(mWidth * 1f, 0f, 1);
+ }
+
+ @Test
+ public void testFlingUpFromBelowBottomCenter() throws InterruptedException {
+ // Move to the center of the screen, just past the bottom.
+ mStackController.moveFirstBubbleWithStackFollowing(mWidth / 2f, mHeight + 100);
+ waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
+
+ // Hard fling directly upwards, no X velocity. The X fling should terminate pretty much
+ // immediately, and spring to 0f, the y fling is hard enough that it will overshoot the top
+ // but should bounce back down.
+ mStackController.flingThenSpringFirstBubbleWithStackFollowing(
+ DynamicAnimation.TRANSLATION_X,
+ 0, 1.15f, new SpringForce(), 27f);
+ mStackController.flingThenSpringFirstBubbleWithStackFollowing(
+ DynamicAnimation.TRANSLATION_Y,
+ 5000f, 1.15f, new SpringForce(), 27f);
+
+ // Nothing should move initially since the animations haven't begun.
+ assertEquals(mWidth / 2f, mViews.get(0).getTranslationX(), .1f);
+ assertEquals(mHeight + 100, mViews.get(0).getTranslationY(), .1f);
+
+ waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X,
+ DynamicAnimation.TRANSLATION_Y);
+
+ // Once the dust has settled, we should have flung a bit but then sprung to the final
+ // destination which is (27, 27).
+ testStackedAtPosition(27, 27, -1);
+ }
+
+ @Test
+ public void testChildAdded() throws InterruptedException {
+ // Move the stack to y = 500.
+ mStackController.moveFirstBubbleWithStackFollowing(0f, 500f);
+ waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X,
+ DynamicAnimation.TRANSLATION_Y);
+
+ final View newView = new FrameLayout(mContext);
+ mLayout.addView(
+ newView,
+ 0,
+ new FrameLayout.LayoutParams(50, 50));
+
+ waitForPropertyAnimations(
+ DynamicAnimation.TRANSLATION_X,
+ DynamicAnimation.TRANSLATION_Y,
+ DynamicAnimation.SCALE_X,
+ DynamicAnimation.SCALE_Y);
+
+ // The new view should be at the top of the stack, in the correct position.
+ assertEquals(0f, newView.getTranslationX(), .1f);
+ assertEquals(500f, newView.getTranslationY(), .1f);
+ assertEquals(1f, newView.getScaleX(), .1f);
+ assertEquals(1f, newView.getScaleY(), .1f);
+ assertEquals(1f, newView.getAlpha(), .1f);
+ }
+
+ @Test
+ public void testChildRemoved() throws InterruptedException {
+ final View firstView = mLayout.getChildAt(0);
+ mLayout.removeView(firstView);
+
+ // The view should still be there, since the controller is animating it out and hasn't yet
+ // actually removed it from the parent view.
+ assertEquals(0, mLayout.indexOfChild(firstView));
+
+ waitForPropertyAnimations(DynamicAnimation.ALPHA);
+ waitForLayoutMessageQueue();
+
+ assertEquals(-1, mLayout.indexOfChild(firstView));
+
+ // The subsequent view should have been translated over to 0, not stacked off to the left.
+ assertEquals(0, mLayout.getChildAt(0).getTranslationX(), .1f);
+ }
+
+ /**
+ * Checks every child view to make sure it's stacked at the given coordinates, off to the left
+ * or right side depending on offset multiplier.
+ */
+ private void testStackedAtPosition(float x, float y, int offsetMultiplier) {
+ // Make sure the rest of the stack moved again, including the first bubble not moving, and
+ // is stacked to the right now that we're on the right side of the screen.
+ for (int i = 0; i < mLayout.getChildCount(); i++) {
+ assertEquals(x + i * offsetMultiplier * mStackOffset,
+ mViews.get(i).getTranslationX(), 2f);
+ assertEquals(y, mViews.get(i).getTranslationY(), 2f);
+ }
+ }
+
+ /**
+ * Testable version of the stack controller that dispatches its animations on the main thread.
+ */
+ private class TestableStackController extends StackAnimationController {
+ @Override
+ public void flingThenSpringFirstBubbleWithStackFollowing(
+ DynamicAnimation.ViewProperty property, float vel, float friction,
+ SpringForce spring, Float finalPosition) {
+ mMainThreadHandler.post(() ->
+ super.flingThenSpringFirstBubbleWithStackFollowing(
+ property, vel, friction, spring, finalPosition));
+ }
+ }
+}