summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/SystemUI/res/values/dimens.xml4
-rw-r--r--packages/SystemUI/src/com/android/systemui/bubbles/BubbleExpandedView.java112
-rw-r--r--packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java467
-rw-r--r--packages/SystemUI/src/com/android/systemui/bubbles/animation/AnimatableScaleMatrix.java137
-rw-r--r--packages/SystemUI/src/com/android/systemui/bubbles/animation/ExpandedAnimationController.java2
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java9
-rw-r--r--packages/SystemUI/tests/src/com/android/systemui/bubbles/NewNotifPipelineBubbleControllerTest.java9
7 files changed, 622 insertions, 118 deletions
diff --git a/packages/SystemUI/res/values/dimens.xml b/packages/SystemUI/res/values/dimens.xml
index 73d8e9a0d8a7..399099702a6a 100644
--- a/packages/SystemUI/res/values/dimens.xml
+++ b/packages/SystemUI/res/values/dimens.xml
@@ -1222,10 +1222,6 @@
<dimen name="bubble_dismiss_slop">16dp</dimen>
<!-- Height of button allowing users to adjust settings for bubbles. -->
<dimen name="bubble_manage_button_height">48dp</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 -->
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExpandedView.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExpandedView.java
index c3dcc0b3038c..89e97cd4b437 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExpandedView.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleExpandedView.java
@@ -22,6 +22,7 @@ import static android.content.Intent.FLAG_ACTIVITY_NEW_DOCUMENT;
import static android.graphics.PixelFormat.TRANSPARENT;
import static android.view.Display.INVALID_DISPLAY;
import static android.view.InsetsState.ITYPE_IME;
+import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
import static android.view.ViewRootImpl.NEW_INSETS_MODE_FULL;
import static android.view.ViewRootImpl.sNewInsetsMode;
import static android.view.WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS;
@@ -33,7 +34,6 @@ import static com.android.systemui.bubbles.BubbleDebugConfig.DEBUG_BUBBLE_EXPAND
import static com.android.systemui.bubbles.BubbleDebugConfig.TAG_BUBBLES;
import static com.android.systemui.bubbles.BubbleDebugConfig.TAG_WITH_CLASS_NAME;
-import android.annotation.Nullable;
import android.annotation.SuppressLint;
import android.app.ActivityOptions;
import android.app.ActivityTaskManager;
@@ -46,6 +46,7 @@ import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Color;
import android.graphics.Insets;
+import android.graphics.Outline;
import android.graphics.Point;
import android.graphics.Rect;
import android.graphics.drawable.ShapeDrawable;
@@ -55,12 +56,19 @@ import android.os.RemoteException;
import android.util.AttributeSet;
import android.util.Log;
import android.view.Gravity;
+import android.view.SurfaceControl;
+import android.view.SurfaceView;
import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewOutlineProvider;
import android.view.WindowInsets;
import android.view.WindowManager;
import android.view.accessibility.AccessibilityNodeInfo;
+import android.widget.FrameLayout;
import android.widget.LinearLayout;
+import androidx.annotation.Nullable;
+
import com.android.internal.policy.ScreenDecorationsUtils;
import com.android.systemui.Dependency;
import com.android.systemui.R;
@@ -88,6 +96,7 @@ public class BubbleExpandedView extends LinearLayout {
// The triangle pointing to the expanded view
private View mPointerView;
private int mPointerMargin;
+ @Nullable private int[] mExpandedViewContainerLocation;
private AlphaOptimizedButton mSettingsIcon;
@@ -121,6 +130,16 @@ public class BubbleExpandedView extends LinearLayout {
private View mVirtualImeView;
private WindowManager mVirtualDisplayWindowManager;
private boolean mImeShowing = false;
+ private float mCornerRadius = 0f;
+
+ /**
+ * Container for the ActivityView that has a solid, round-rect background that shows if the
+ * ActivityView hasn't loaded.
+ */
+ private FrameLayout mActivityViewContainer = new FrameLayout(getContext());
+
+ /** The SurfaceView that the ActivityView draws to. */
+ @Nullable private SurfaceView mActivitySurface;
private ActivityView.StateCallback mStateCallback = new ActivityView.StateCallback() {
@Override
@@ -269,7 +288,28 @@ public class BubbleExpandedView extends LinearLayout {
// Set ActivityView's alpha value as zero, since there is no view content to be shown.
setContentVisibility(false);
- addView(mActivityView);
+
+ mActivityViewContainer.setBackgroundColor(Color.WHITE);
+ mActivityViewContainer.setOutlineProvider(new ViewOutlineProvider() {
+ @Override
+ public void getOutline(View view, Outline outline) {
+ outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), mCornerRadius);
+ }
+ });
+ mActivityViewContainer.setClipToOutline(true);
+ mActivityViewContainer.addView(mActivityView);
+ mActivityViewContainer.setLayoutParams(
+ new ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT));
+ addView(mActivityViewContainer);
+
+ if (mActivityView != null
+ && mActivityView.getChildCount() > 0
+ && mActivityView.getChildAt(0) instanceof SurfaceView) {
+ // Retrieve the surface from the ActivityView so we can screenshot it and change its
+ // z-ordering. This should always be possible, since ActivityView's constructor adds the
+ // SurfaceView as its first child.
+ mActivitySurface = (SurfaceView) mActivityView.getChildAt(0);
+ }
// Expanded stack layout, top to bottom:
// Expanded view container
@@ -327,6 +367,39 @@ public class BubbleExpandedView extends LinearLayout {
return mBubble != null ? mBubble.getKey() : "null";
}
+ /**
+ * Asks the ActivityView's surface to draw on top of all other views in the window. This is
+ * useful for ordering surfaces during animations, but should otherwise be set to false so that
+ * bubbles and menus can draw over the ActivityView.
+ */
+ void setSurfaceZOrderedOnTop(boolean onTop) {
+ if (mActivitySurface == null) {
+ return;
+ }
+
+ mActivitySurface.setZOrderedOnTop(onTop, true);
+ }
+
+ /** Return a GraphicBuffer with the contents of the ActivityView's underlying surface. */
+ @Nullable SurfaceControl.ScreenshotGraphicBuffer snapshotActivitySurface() {
+ if (mActivitySurface == null) {
+ return null;
+ }
+
+ return SurfaceControl.captureLayers(
+ mActivitySurface.getSurfaceControl(),
+ new Rect(0, 0, mActivityView.getWidth(), mActivityView.getHeight()),
+ 1 /* scale */);
+ }
+
+ int[] getActivityViewLocationOnScreen() {
+ if (mActivityView != null) {
+ return mActivityView.getLocationOnScreen();
+ } else {
+ return new int[]{0, 0};
+ }
+ }
+
void setManageClickListener(OnClickListener manageClickListener) {
findViewById(R.id.settings_button).setOnClickListener(manageClickListener);
}
@@ -345,12 +418,12 @@ public class BubbleExpandedView extends LinearLayout {
void applyThemeAttrs() {
final TypedArray ta = mContext.obtainStyledAttributes(
new int[] {android.R.attr.dialogCornerRadius});
- float cornerRadius = ta.getDimensionPixelSize(0, 0);
+ mCornerRadius = ta.getDimensionPixelSize(0, 0);
ta.recycle();
if (mActivityView != null && ScreenDecorationsUtils.supportsRoundedCornersOnWindows(
mContext.getResources())) {
- mActivityView.setCornerRadius(cornerRadius);
+ mActivityView.setCornerRadius(mCornerRadius);
}
}
@@ -398,6 +471,7 @@ public class BubbleExpandedView extends LinearLayout {
mPointerView.setAlpha(alpha);
if (mActivityView != null) {
mActivityView.setAlpha(alpha);
+ mActivityView.bringToFront();
}
}
@@ -551,6 +625,11 @@ public class BubbleExpandedView extends LinearLayout {
if (DEBUG_BUBBLE_EXPANDED_VIEW) {
Log.d(TAG, "updateHeight: bubble=" + getBubbleKey());
}
+
+ if (mExpandedViewContainerLocation == null) {
+ return;
+ }
+
if (usingActivityView()) {
float desiredHeight = mOverflowHeight;
if (!mIsOverflow) {
@@ -558,7 +637,7 @@ public class BubbleExpandedView extends LinearLayout {
}
float height = Math.min(desiredHeight, getMaxExpandedHeight());
height = Math.max(height, mIsOverflow? mOverflowHeight : mMinHeight);
- LayoutParams lp = (LayoutParams) mActivityView.getLayoutParams();
+ ViewGroup.LayoutParams lp = mActivityView.getLayoutParams();
mNeedsNewHeight = lp.height != height;
if (!mKeyboardVisible) {
// If the keyboard is visible... don't adjust the height because that will cause
@@ -568,7 +647,8 @@ public class BubbleExpandedView extends LinearLayout {
mNeedsNewHeight = false;
}
if (DEBUG_BUBBLE_EXPANDED_VIEW) {
- Log.d(TAG, "updateHeight: bubble=" + getBubbleKey() + " height=" + height
+ Log.d(TAG, "updateHeight: bubble=" + getBubbleKey()
+ + " height=" + height
+ " mNeedsNewHeight=" + mNeedsNewHeight);
}
}
@@ -576,28 +656,40 @@ public class BubbleExpandedView extends LinearLayout {
private int getMaxExpandedHeight() {
mWindowManager.getDefaultDisplay().getRealSize(mDisplaySize);
- int[] windowLocation = mActivityView.getLocationOnScreen();
int bottomInset = getRootWindowInsets() != null
? getRootWindowInsets().getStableInsetBottom()
: 0;
- return mDisplaySize.y - windowLocation[1] - mSettingsIconHeight - mPointerHeight
+
+ return mDisplaySize.y
+ - mExpandedViewContainerLocation[1]
+ - getPaddingTop()
+ - getPaddingBottom()
+ - mSettingsIconHeight
+ - mPointerHeight
- mPointerMargin - bottomInset;
}
/**
* Update appearance of the expanded view being displayed.
+ *
+ * @param containerLocationOnScreen The location on-screen of the container the expanded view is
+ * added to. This allows us to calculate max height without
+ * waiting for layout.
*/
- public void updateView() {
+ public void updateView(int[] containerLocationOnScreen) {
if (DEBUG_BUBBLE_EXPANDED_VIEW) {
Log.d(TAG, "updateView: bubble="
+ getBubbleKey());
}
+
+ mExpandedViewContainerLocation = containerLocationOnScreen;
+
if (usingActivityView()
&& mActivityView.getVisibility() == VISIBLE
&& mActivityView.isAttachedToWindow()) {
mActivityView.onLocationChanged();
+ updateHeight();
}
- updateHeight();
}
/**
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
index 95c8d08841df..8de7226f85d2 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
@@ -56,6 +56,8 @@ import android.view.DisplayCutout;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.MotionEvent;
+import android.view.SurfaceControl;
+import android.view.SurfaceView;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewOutlineProvider;
@@ -83,6 +85,7 @@ import com.android.internal.widget.ViewClippingUtil;
import com.android.systemui.Interpolators;
import com.android.systemui.Prefs;
import com.android.systemui.R;
+import com.android.systemui.bubbles.animation.AnimatableScaleMatrix;
import com.android.systemui.bubbles.animation.ExpandedAnimationController;
import com.android.systemui.bubbles.animation.PhysicsAnimationLayout;
import com.android.systemui.bubbles.animation.StackAnimationController;
@@ -148,6 +151,16 @@ public class BubbleStackView extends FrameLayout
StackAnimationController.IME_ANIMATION_STIFFNESS,
StackAnimationController.DEFAULT_BOUNCINESS);
+ private final PhysicsAnimator.SpringConfig mScaleInSpringConfig =
+ new PhysicsAnimator.SpringConfig(300f, 0.9f);
+
+ private final PhysicsAnimator.SpringConfig mScaleOutSpringConfig =
+ new PhysicsAnimator.SpringConfig(900f, 1f);
+
+ private final PhysicsAnimator.SpringConfig mTranslateSpringConfig =
+ new PhysicsAnimator.SpringConfig(
+ SpringForce.STIFFNESS_LOW, SpringForce.DAMPING_RATIO_NO_BOUNCY);
+
/**
* Interface to synchronize {@link View} state and the screen.
*
@@ -187,8 +200,6 @@ public class BubbleStackView extends FrameLayout
private Point mDisplaySize;
- private final SpringAnimation mExpandedViewXAnim;
- private final SpringAnimation mExpandedViewYAnim;
private final BubbleData mBubbleData;
private final ValueAnimator mDesaturateAndDarkenAnimator;
@@ -200,6 +211,24 @@ public class BubbleStackView extends FrameLayout
private FrameLayout mExpandedViewContainer;
+ /** Matrix used to scale the expanded view container with a given pivot point. */
+ private final AnimatableScaleMatrix mExpandedViewContainerMatrix = new AnimatableScaleMatrix();
+
+ /**
+ * SurfaceView that we draw screenshots of animating-out bubbles into. This allows us to animate
+ * between bubble activities without needing both to be alive at the same time.
+ */
+ private SurfaceView mAnimatingOutSurfaceView;
+
+ /** Container for the animating-out SurfaceView. */
+ private FrameLayout mAnimatingOutSurfaceContainer;
+
+ /**
+ * Buffer containing a screenshot of the animating-out bubble. This is drawn into the
+ * SurfaceView during animations.
+ */
+ private SurfaceControl.ScreenshotGraphicBuffer mAnimatingOutBubbleBuffer;
+
private BubbleFlyoutView mFlyout;
/** Runnable that fades out the flyout and then sets it to GONE. */
private Runnable mHideFlyout = () -> animateFlyoutCollapsed(true, 0 /* velX */);
@@ -231,8 +260,7 @@ public class BubbleStackView extends FrameLayout
private int mBubblePaddingTop;
private int mBubbleTouchPadding;
private int mExpandedViewPadding;
- private int mExpandedAnimateXDistance;
- private int mExpandedAnimateYDistance;
+ private int mCornerRadius;
private int mPointerHeight;
private int mStatusBarHeight;
private int mImeOffset;
@@ -699,10 +727,6 @@ public class BubbleStackView extends FrameLayout
mBubbleElevation = res.getDimensionPixelSize(R.dimen.bubble_elevation);
mBubblePaddingTop = res.getDimensionPixelSize(R.dimen.bubble_padding_top);
mBubbleTouchPadding = res.getDimensionPixelSize(R.dimen.bubble_touch_padding);
- mExpandedAnimateXDistance =
- res.getDimensionPixelSize(R.dimen.bubble_expanded_animate_x_distance);
- mExpandedAnimateYDistance =
- res.getDimensionPixelSize(R.dimen.bubble_expanded_animate_y_distance);
mPointerHeight = res.getDimensionPixelSize(R.dimen.bubble_pointer_height);
mStatusBarHeight =
@@ -717,6 +741,11 @@ public class BubbleStackView extends FrameLayout
mExpandedViewPadding = res.getDimensionPixelSize(R.dimen.bubble_expanded_view_padding);
int elevation = res.getDimensionPixelSize(R.dimen.bubble_elevation);
+ final TypedArray ta = mContext.obtainStyledAttributes(
+ new int[] {android.R.attr.dialogCornerRadius});
+ mCornerRadius = ta.getDimensionPixelSize(0, 0);
+ ta.recycle();
+
final Runnable onBubbleAnimatedOut = () -> {
if (getBubbleCount() == 0) {
allBubblesAnimatedOutAction.run();
@@ -750,6 +779,24 @@ public class BubbleStackView extends FrameLayout
mExpandedViewContainer.setClipChildren(false);
addView(mExpandedViewContainer);
+ mAnimatingOutSurfaceContainer = new FrameLayout(getContext());
+ mAnimatingOutSurfaceContainer.setLayoutParams(
+ new ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT));
+ addView(mAnimatingOutSurfaceContainer);
+
+ mAnimatingOutSurfaceView = new SurfaceView(getContext());
+ mAnimatingOutSurfaceView.setUseAlpha();
+ mAnimatingOutSurfaceView.setZOrderOnTop(true);
+ mAnimatingOutSurfaceView.setCornerRadius(mCornerRadius);
+ mAnimatingOutSurfaceView.setLayoutParams(new ViewGroup.LayoutParams(0, 0));
+ mAnimatingOutSurfaceContainer.addView(mAnimatingOutSurfaceView);
+
+ mAnimatingOutSurfaceContainer.setPadding(
+ mExpandedViewPadding,
+ mExpandedViewPadding,
+ mExpandedViewPadding,
+ mExpandedViewPadding);
+
setUpManageMenu();
setUpFlyout();
@@ -795,26 +842,6 @@ public class BubbleStackView extends FrameLayout
// MagnetizedObjects.
mMagneticTarget = new MagnetizedObject.MagneticTarget(mDismissTargetCircle, dismissRadius);
- 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));
- mExpandedViewYAnim.addEndListener((anim, cancelled, value, velocity) -> {
- if (mIsExpanded && mExpandedBubble != null
- && mExpandedBubble.getExpandedView() != null) {
- mExpandedBubble.getExpandedView().updateView();
- }
- });
-
setClipChildren(false);
setFocusable(true);
mBubbleContainer.bringToFront();
@@ -849,7 +876,7 @@ public class BubbleStackView extends FrameLayout
if (mIsExpanded) {
mExpandedViewContainer.setTranslationY(getExpandedViewY());
if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) {
- mExpandedBubble.getExpandedView().updateView();
+ mExpandedBubble.getExpandedView().updateView(getLocationOnScreen());
}
}
@@ -973,15 +1000,10 @@ public class BubbleStackView extends FrameLayout
PhysicsAnimator.getInstance(mManageMenu).setDefaultSpringConfig(mManageSpringConfig);
- final TypedArray ta = mContext.obtainStyledAttributes(
- new int[] {android.R.attr.dialogCornerRadius});
- final int menuCornerRadius = ta.getDimensionPixelSize(0, 0);
- ta.recycle();
-
mManageMenu.setOutlineProvider(new ViewOutlineProvider() {
@Override
public void getOutline(View view, Outline outline) {
- outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), menuCornerRadius);
+ outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), mCornerRadius);
}
});
mManageMenu.setClipToOutline(true);
@@ -1467,6 +1489,31 @@ public class BubbleStackView extends FrameLayout
mBubbleData.setShowingOverflow(true);
}
+ // If we're expanded, screenshot the currently expanded bubble (before expanding the newly
+ // selected bubble) so we can animate it out.
+ if (mIsExpanded && mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) {
+ if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) {
+ // Before screenshotting, have the real ActivityView show on top of other surfaces
+ // so that the screenshot doesn't flicker on top of it.
+ mExpandedBubble.getExpandedView().setSurfaceZOrderedOnTop(true);
+ }
+
+ try {
+ screenshotAnimatingOutBubbleIntoSurface((success) -> {
+ mAnimatingOutSurfaceContainer.setVisibility(
+ success ? View.VISIBLE : View.INVISIBLE);
+ showNewlySelectedBubble(bubbleToSelect);
+ });
+ } catch (Exception e) {
+ showNewlySelectedBubble(bubbleToSelect);
+ e.printStackTrace();
+ }
+ } else {
+ showNewlySelectedBubble(bubbleToSelect);
+ }
+ }
+
+ private void showNewlySelectedBubble(BubbleViewProvider bubbleToSelect) {
final BubbleViewProvider previouslySelected = mExpandedBubble;
mExpandedBubble = bubbleToSelect;
updatePointerPosition();
@@ -1657,83 +1704,215 @@ public class BubbleStackView extends FrameLayout
}
private void beforeExpandedViewAnimation() {
+ mIsExpansionAnimating = true;
hideFlyoutImmediate();
updateExpandedBubble();
updateExpandedView();
- mIsExpansionAnimating = true;
}
private void afterExpandedViewAnimation() {
- updateExpandedView();
mIsExpansionAnimating = false;
+ updateExpandedView();
requestUpdate();
}
+ private void animateExpansion() {
+ mIsExpanded = true;
+ hideStackUserEducation(true /* fromExpansion */);
+ beforeExpandedViewAnimation();
+
+ mBubbleContainer.setActiveController(mExpandedAnimationController);
+ updateOverflowVisibility();
+ updatePointerPosition();
+ mExpandedAnimationController.expandFromStack(() -> {
+ afterExpandedViewAnimation();
+ maybeShowManageEducation(true);
+ } /* after */);
+
+ mExpandedViewContainer.setTranslationX(0);
+ mExpandedViewContainer.setTranslationY(getExpandedViewY());
+ mExpandedViewContainer.setAlpha(1f);
+
+ // X-value of the bubble we're expanding, once it's settled in its row.
+ final float bubbleWillBeAtX =
+ mExpandedAnimationController.getBubbleLeft(
+ mBubbleData.getBubbles().indexOf(mExpandedBubble));
+
+ // How far horizontally the bubble will be animating. We'll wait a bit longer for bubbles
+ // that are animating farther, so that the expanded view doesn't move as much.
+ final float horizontalDistanceAnimated =
+ Math.abs(bubbleWillBeAtX
+ - mStackAnimationController.getStackPosition().x);
+
+ // Wait for the path animation target to reach its end, and add a small amount of extra time
+ // if the bubble is moving a lot horizontally.
+ long startDelay = 0L;
+
+ // Should not happen since we lay out before expanding, but just in case...
+ if (getWidth() > 0) {
+ startDelay = (long)
+ (ExpandedAnimationController.EXPAND_COLLAPSE_TARGET_ANIM_DURATION
+ + (horizontalDistanceAnimated / getWidth()) * 30);
+ }
+
+ // Set the pivot point for the scale, so the expanded view animates out from the bubble.
+ mExpandedViewContainerMatrix.setScale(
+ 0f, 0f,
+ bubbleWillBeAtX + mBubbleSize / 2f, getExpandedViewY());
+ mExpandedViewContainer.setAnimationMatrix(mExpandedViewContainerMatrix);
+
+ if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) {
+ mExpandedBubble.getExpandedView().setSurfaceZOrderedOnTop(false);
+ }
+
+ postDelayed(() -> PhysicsAnimator.getInstance(mExpandedViewContainerMatrix)
+ .spring(AnimatableScaleMatrix.SCALE_X,
+ AnimatableScaleMatrix.getAnimatableValueForScaleFactor(1f),
+ mScaleInSpringConfig)
+ .spring(AnimatableScaleMatrix.SCALE_Y,
+ AnimatableScaleMatrix.getAnimatableValueForScaleFactor(1f),
+ mScaleInSpringConfig)
+ .addUpdateListener((target, values) -> {
+ mExpandedViewContainerMatrix.postTranslate(
+ mExpandedBubble.getIconView().getTranslationX()
+ - bubbleWillBeAtX,
+ 0);
+ mExpandedViewContainer.setAnimationMatrix(mExpandedViewContainerMatrix);
+ })
+ .withEndActions(() -> {
+ if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) {
+ mExpandedBubble.getExpandedView().setSurfaceZOrderedOnTop(false);
+ }
+ })
+ .start(), startDelay);
+
+ }
+
private void animateCollapse() {
// Hide the menu if it's visible.
showManageMenu(false);
mIsExpanded = false;
- final BubbleViewProvider previouslySelected = mExpandedBubble;
- beforeExpandedViewAnimation();
- maybeShowManageEducation(false);
- if (DEBUG_BUBBLE_STACK_VIEW) {
- Log.d(TAG, "animateCollapse");
- Log.d(TAG, BubbleDebugConfig.formatBubblesString(getBubblesOnScreen(),
- mExpandedBubble));
- }
- updateOverflowVisibility();
mBubbleContainer.cancelAllAnimations();
- mExpandedAnimationController.collapseBackToStack(
+
+ // If we were in the middle of swapping, the animating-out surface would have been scaling
+ // to zero - finish it off.
+ PhysicsAnimator.getInstance(mAnimatingOutSurfaceContainer).cancel();
+ mAnimatingOutSurfaceContainer.setScaleX(0f);
+ mAnimatingOutSurfaceContainer.setScaleY(0f);
+
+ if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) {
+ mExpandedBubble.getExpandedView().hideImeIfVisible();
+ }
+
+ final long startDelay =
+ (long) (ExpandedAnimationController.EXPAND_COLLAPSE_TARGET_ANIM_DURATION * 0.6f);
+ postDelayed(() -> mExpandedAnimationController.collapseBackToStack(
mStackAnimationController.getStackPositionAlongNearestHorizontalEdge()
/* collapseTo */,
() -> {
mBubbleContainer.setActiveController(mStackAnimationController);
+ }), startDelay);
+
+ // We want to visually collapse into this bubble during the animation.
+ final View expandingFromBubble = mExpandedBubble.getIconView();
+
+ // X-value the bubble is animating from (back into the stack).
+ final float expandingFromBubbleAtX =
+ mExpandedAnimationController.getBubbleLeft(
+ mBubbleData.getBubbles().indexOf(mExpandedBubble));
+
+ // Set the pivot point.
+ mExpandedViewContainerMatrix.setScale(
+ 1f, 1f,
+ expandingFromBubbleAtX + mBubbleSize / 2f,
+ getExpandedViewY());
+
+ PhysicsAnimator.getInstance(mExpandedViewContainerMatrix).cancel();
+ PhysicsAnimator.getInstance(mExpandedViewContainerMatrix)
+ .spring(AnimatableScaleMatrix.SCALE_X, 0f, mScaleOutSpringConfig)
+ .spring(AnimatableScaleMatrix.SCALE_Y, 0f, mScaleOutSpringConfig)
+ .addUpdateListener((target, values) -> {
+ if (expandingFromBubble != null) {
+ // Follow the bubble as it translates!
+ mExpandedViewContainerMatrix.postTranslate(
+ expandingFromBubble.getTranslationX()
+ - expandingFromBubbleAtX, 0f);
+ }
+
+ mExpandedViewContainer.setAnimationMatrix(mExpandedViewContainerMatrix);
+
+ // Hide early so we don't have a tiny little expanded view still visible at the
+ // end of the scale animation.
+ if (mExpandedViewContainerMatrix.getScaleX() < 0.05f) {
+ mExpandedViewContainer.setVisibility(View.INVISIBLE);
+ }
+ })
+ .withEndActions(() -> {
+ final BubbleViewProvider previouslySelected = mExpandedBubble;
+ beforeExpandedViewAnimation();
+ maybeShowManageEducation(false);
+
+ if (DEBUG_BUBBLE_STACK_VIEW) {
+ Log.d(TAG, "animateCollapse");
+ Log.d(TAG, BubbleDebugConfig.formatBubblesString(getBubblesOnScreen(),
+ mExpandedBubble));
+ }
+ updateOverflowVisibility();
+
afterExpandedViewAnimation();
previouslySelected.setContentVisibility(false);
- });
-
- mExpandedViewXAnim.animateToFinalPosition(getCollapsedX());
- mExpandedViewYAnim.animateToFinalPosition(getCollapsedY());
- mExpandedViewContainer.animate()
- .setDuration(100)
- .alpha(0f);
+ })
+ .start();
}
- private void animateExpansion() {
- mIsExpanded = true;
- hideStackUserEducation(true /* fromExpansion */);
- beforeExpandedViewAnimation();
+ private void animateSwitchBubbles() {
+ // The surface contains a screenshot of the animating out bubble, so we just need to animate
+ // it out (and then release the GraphicBuffer).
+ PhysicsAnimator.getInstance(mAnimatingOutSurfaceContainer).cancel();
+ PhysicsAnimator.getInstance(mAnimatingOutSurfaceContainer)
+ .spring(DynamicAnimation.SCALE_X, 0f, mScaleOutSpringConfig)
+ .spring(DynamicAnimation.SCALE_Y, 0f, mScaleOutSpringConfig)
+ .spring(DynamicAnimation.TRANSLATION_Y,
+ mAnimatingOutSurfaceContainer.getTranslationY() - mBubbleSize * 2,
+ mTranslateSpringConfig)
+ .withEndActions(this::releaseAnimatingOutBubbleBuffer)
+ .start();
- mBubbleContainer.setActiveController(mExpandedAnimationController);
- updateOverflowVisibility();
- mExpandedAnimationController.expandFromStack(() -> {
- updatePointerPosition();
- afterExpandedViewAnimation();
- maybeShowManageEducation(true);
- } /* after */);
+ float expandingFromBubbleDestinationX =
+ mExpandedAnimationController.getBubbleLeft(
+ mBubbleData.getBubbles().indexOf(mExpandedBubble));
- mExpandedViewContainer.setTranslationX(getCollapsedX());
- mExpandedViewContainer.setTranslationY(getCollapsedY());
- mExpandedViewContainer.setAlpha(0f);
+ mExpandedViewContainer.setAlpha(1f);
+ mExpandedViewContainer.setVisibility(View.VISIBLE);
- mExpandedViewXAnim.animateToFinalPosition(0f);
- mExpandedViewYAnim.animateToFinalPosition(getExpandedViewY());
- mExpandedViewContainer.animate()
- .setDuration(100)
- .alpha(1f);
- }
+ mExpandedViewContainerMatrix.setScale(
+ 0f, 0f, expandingFromBubbleDestinationX + mBubbleSize / 2f, getExpandedViewY());
+ mExpandedViewContainer.setAnimationMatrix(mExpandedViewContainerMatrix);
- private float getCollapsedX() {
- return mStackAnimationController.getStackPosition().x < getWidth() / 2
- ? -mExpandedAnimateXDistance
- : mExpandedAnimateXDistance;
- }
+ mExpandedViewContainer.postDelayed(() -> {
+ if (!mIsExpanded) {
+ return;
+ }
- private float getCollapsedY() {
- return Math.min(mStackAnimationController.getStackPosition().y,
- mExpandedAnimateYDistance);
+ PhysicsAnimator.getInstance(mExpandedViewContainerMatrix)
+ .spring(AnimatableScaleMatrix.SCALE_X,
+ AnimatableScaleMatrix.getAnimatableValueForScaleFactor(1f),
+ mScaleInSpringConfig)
+ .spring(AnimatableScaleMatrix.SCALE_Y,
+ AnimatableScaleMatrix.getAnimatableValueForScaleFactor(1f),
+ mScaleInSpringConfig)
+ .addUpdateListener((target, values) -> {
+ mExpandedViewContainer.setAnimationMatrix(mExpandedViewContainerMatrix);
+ })
+ .withEndActions(() -> {
+ if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) {
+ mExpandedBubble.getExpandedView().setSurfaceZOrderedOnTop(false);
+ }
+ })
+ .start();
+ }, 25);
}
private void notifyExpansionChanged(BubbleViewProvider bubble, boolean expanded) {
@@ -2248,11 +2427,114 @@ public class BubbleStackView extends FrameLayout
if (mIsExpanded && mExpandedBubble != null
&& mExpandedBubble.getExpandedView() != null) {
BubbleExpandedView bev = mExpandedBubble.getExpandedView();
+ bev.setContentVisibility(false);
+ mExpandedViewContainerMatrix.setScaleX(0f);
+ mExpandedViewContainerMatrix.setScaleY(0f);
+ mExpandedViewContainer.setVisibility(View.INVISIBLE);
+ mExpandedViewContainer.setAlpha(0f);
mExpandedViewContainer.addView(bev);
bev.setManageClickListener((view) -> showManageMenu(!mShowingManage));
bev.populateExpandedView();
- mExpandedViewContainer.setVisibility(VISIBLE);
- mExpandedViewContainer.setAlpha(1.0f);
+
+ if (!mIsExpansionAnimating) {
+ mSurfaceSynchronizer.syncSurfaceAndRun(() -> {
+ post(this::animateSwitchBubbles);
+ });
+ }
+ }
+ }
+
+ /**
+ * Requests a snapshot from the currently expanded bubble's ActivityView and displays it in a
+ * SurfaceView. This allows us to load a newly expanded bubble's Activity into the ActivityView,
+ * while animating the (screenshot of the) previously selected bubble's content away.
+ *
+ * @param onComplete Callback to run once we're done here - called with 'false' if something
+ * went wrong, or 'true' if the SurfaceView is now showing a screenshot of the
+ * expanded bubble.
+ */
+ private void screenshotAnimatingOutBubbleIntoSurface(Consumer<Boolean> onComplete) {
+ if (mExpandedBubble == null || mExpandedBubble.getExpandedView() == null) {
+ // You can't animate null.
+ onComplete.accept(false);
+ return;
+ }
+
+ final BubbleExpandedView animatingOutExpandedView = mExpandedBubble.getExpandedView();
+
+ // Release the previous screenshot if it hasn't been released already.
+ if (mAnimatingOutBubbleBuffer != null) {
+ releaseAnimatingOutBubbleBuffer();
+ }
+
+ try {
+ mAnimatingOutBubbleBuffer = animatingOutExpandedView.snapshotActivitySurface();
+ } catch (Exception e) {
+ // If we fail for any reason, print the stack trace and then notify the callback of our
+ // failure. This is not expected to occur, but it's not worth crashing over.
+ Log.wtf(TAG, e);
+ onComplete.accept(false);
+ }
+
+ if (mAnimatingOutBubbleBuffer == null
+ || mAnimatingOutBubbleBuffer.getGraphicBuffer() == null) {
+ // While no exception was thrown, we were unable to get a snapshot.
+ onComplete.accept(false);
+ return;
+ }
+
+ // Make sure the surface container's properties have been reset.
+ PhysicsAnimator.getInstance(mAnimatingOutSurfaceContainer).cancel();
+ mAnimatingOutSurfaceContainer.setScaleX(1f);
+ mAnimatingOutSurfaceContainer.setScaleY(1f);
+ mAnimatingOutSurfaceContainer.setTranslationX(0);
+ mAnimatingOutSurfaceContainer.setTranslationY(0);
+
+ final int[] activityViewLocation =
+ mExpandedBubble.getExpandedView().getActivityViewLocationOnScreen();
+ final int[] surfaceViewLocation = mAnimatingOutSurfaceView.getLocationOnScreen();
+
+ // Translate the surface to overlap the real ActivityView.
+ mAnimatingOutSurfaceContainer.setTranslationY(
+ activityViewLocation[1] - surfaceViewLocation[1]);
+
+ // Set the width/height of the SurfaceView to match the snapshot.
+ mAnimatingOutSurfaceView.getLayoutParams().width =
+ mAnimatingOutBubbleBuffer.getGraphicBuffer().getWidth();
+ mAnimatingOutSurfaceView.getLayoutParams().height =
+ mAnimatingOutBubbleBuffer.getGraphicBuffer().getHeight();
+ mAnimatingOutSurfaceView.requestLayout();
+
+ // Post to wait for layout.
+ post(() -> {
+ // The buffer might have been destroyed if the user is mashing on bubbles, that's okay.
+ if (mAnimatingOutBubbleBuffer.getGraphicBuffer().isDestroyed()) {
+ onComplete.accept(false);
+ return;
+ }
+
+ if (!mIsExpanded) {
+ onComplete.accept(false);
+ return;
+ }
+
+ // Attach the buffer! We're now displaying the snapshot.
+ mAnimatingOutSurfaceView.getHolder().getSurface().attachAndQueueBufferWithColorSpace(
+ mAnimatingOutBubbleBuffer.getGraphicBuffer(),
+ mAnimatingOutBubbleBuffer.getColorSpace());
+
+ mSurfaceSynchronizer.syncSurfaceAndRun(() -> post(() -> onComplete.accept(true)));
+ });
+ }
+
+ /**
+ * Releases the buffer containing the screenshot of the animating-out bubble, if it exists and
+ * isn't yet destroyed.
+ */
+ private void releaseAnimatingOutBubbleBuffer() {
+ if (mAnimatingOutBubbleBuffer != null
+ && !mAnimatingOutBubbleBuffer.getGraphicBuffer().isDestroyed()) {
+ mAnimatingOutBubbleBuffer.getGraphicBuffer().destroy();
}
}
@@ -2262,19 +2544,10 @@ public class BubbleStackView extends FrameLayout
}
mExpandedViewContainer.setVisibility(mIsExpanded ? VISIBLE : GONE);
- if (mIsExpanded) {
- final float y = getExpandedViewY();
- if (!mExpandedViewYAnim.isRunning()) {
- // We're not animating so set the value
- mExpandedViewContainer.setTranslationY(y);
- if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) {
- mExpandedBubble.getExpandedView().updateView();
- }
- } else {
- // We are animating so update the value; there is an end listener on the animator
- // that will ensure expandedeView.updateView gets called.
- mExpandedViewYAnim.animateToFinalPosition(y);
- }
+ if (mExpandedBubble != null && mExpandedBubble.getExpandedView() != null) {
+ mExpandedViewContainer.setTranslationY(getExpandedViewY());
+ mExpandedBubble.getExpandedView().updateView(
+ mExpandedViewContainer.getLocationOnScreen());
}
mStackOnLeftOrWillBe = mStackAnimationController.isStackOnLeftSide();
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/animation/AnimatableScaleMatrix.java b/packages/SystemUI/src/com/android/systemui/bubbles/animation/AnimatableScaleMatrix.java
new file mode 100644
index 000000000000..ae7833634794
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/animation/AnimatableScaleMatrix.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright (C) 2020 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.Matrix;
+
+import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.dynamicanimation.animation.FloatPropertyCompat;
+
+/**
+ * Matrix whose scale properties can be animated using physics animations, via the {@link #SCALE_X}
+ * and {@link #SCALE_Y} FloatProperties.
+ *
+ * This is useful when you need to perform a scale animation with a pivot point, since pivot points
+ * are not supported by standard View scale operations but are supported by matrices.
+ *
+ * NOTE: DynamicAnimation assumes that all custom properties are denominated in pixels, and thus
+ * considers 1 to be the smallest user-visible change for custom properties. This means that if you
+ * animate {@link #SCALE_X} and {@link #SCALE_Y} to 3f, for example, the animation would have only
+ * three frames.
+ *
+ * To work around this, whenever animating to a desired scale value, animate to the value returned
+ * by {@link #getAnimatableValueForScaleFactor} instead. The SCALE_X and SCALE_Y properties will
+ * convert that (larger) value into the appropriate scale factor when scaling the matrix.
+ */
+public class AnimatableScaleMatrix extends Matrix {
+
+ /**
+ * The X value of the scale.
+ *
+ * NOTE: This must be set or animated to the value returned by
+ * {@link #getAnimatableValueForScaleFactor}, not the desired scale factor itself.
+ */
+ public static final FloatPropertyCompat<AnimatableScaleMatrix> SCALE_X =
+ new FloatPropertyCompat<AnimatableScaleMatrix>("matrixScaleX") {
+ @Override
+ public float getValue(AnimatableScaleMatrix object) {
+ return getAnimatableValueForScaleFactor(object.mScaleX);
+ }
+
+ @Override
+ public void setValue(AnimatableScaleMatrix object, float value) {
+ object.setScaleX(value * DynamicAnimation.MIN_VISIBLE_CHANGE_SCALE);
+ }
+ };
+
+ /**
+ * The Y value of the scale.
+ *
+ * NOTE: This must be set or animated to the value returned by
+ * {@link #getAnimatableValueForScaleFactor}, not the desired scale factor itself.
+ */
+ public static final FloatPropertyCompat<AnimatableScaleMatrix> SCALE_Y =
+ new FloatPropertyCompat<AnimatableScaleMatrix>("matrixScaleY") {
+ @Override
+ public float getValue(AnimatableScaleMatrix object) {
+ return getAnimatableValueForScaleFactor(object.mScaleY);
+ }
+
+ @Override
+ public void setValue(AnimatableScaleMatrix object, float value) {
+ object.setScaleY(value * DynamicAnimation.MIN_VISIBLE_CHANGE_SCALE);
+ }
+ };
+
+ private float mScaleX = 1f;
+ private float mScaleY = 1f;
+
+ private float mPivotX = 0f;
+ private float mPivotY = 0f;
+
+ /**
+ * Return the value to animate SCALE_X or SCALE_Y to in order to achieve the desired scale
+ * factor.
+ */
+ public static float getAnimatableValueForScaleFactor(float scale) {
+ return scale * (1f / DynamicAnimation.MIN_VISIBLE_CHANGE_SCALE);
+ }
+
+ @Override
+ public void setScale(float sx, float sy, float px, float py) {
+ mScaleX = sx;
+ mScaleY = sy;
+ mPivotX = px;
+ mPivotY = py;
+ super.setScale(mScaleX, mScaleY, mPivotX, mPivotY);
+ }
+
+ public void setScaleX(float scaleX) {
+ mScaleX = scaleX;
+ super.setScale(mScaleX, mScaleY, mPivotX, mPivotY);
+ }
+
+ public void setScaleY(float scaleY) {
+ mScaleY = scaleY;
+ super.setScale(mScaleX, mScaleY, mPivotX, mPivotY);
+ }
+
+ public void setPivotX(float pivotX) {
+ mPivotX = pivotX;
+ super.setScale(mScaleX, mScaleY, mPivotX, mPivotY);
+ }
+
+ public void setPivotY(float pivotY) {
+ mPivotY = pivotY;
+ super.setScale(mScaleX, mScaleY, mPivotX, mPivotY);
+ }
+
+ public float getScaleX() {
+ return mScaleX;
+ }
+
+ public float getScaleY() {
+ return mScaleY;
+ }
+
+ public float getPivotX() {
+ return mPivotX;
+ }
+
+ public float getPivotY() {
+ return mPivotY;
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/animation/ExpandedAnimationController.java b/packages/SystemUI/src/com/android/systemui/bubbles/animation/ExpandedAnimationController.java
index 76ff1afef3f7..86fe10dddc2c 100644
--- a/packages/SystemUI/src/com/android/systemui/bubbles/animation/ExpandedAnimationController.java
+++ b/packages/SystemUI/src/com/android/systemui/bubbles/animation/ExpandedAnimationController.java
@@ -56,7 +56,7 @@ public class ExpandedAnimationController
private static final int ANIMATE_TRANSLATION_FACTOR = 4;
/** Duration of the expand/collapse target path animation. */
- private static final int EXPAND_COLLAPSE_TARGET_ANIM_DURATION = 175;
+ public static final int EXPAND_COLLAPSE_TARGET_ANIM_DURATION = 175;
/** Stiffness for the expand/collapse path-following animation. */
private static final int EXPAND_COLLAPSE_ANIM_STIFFNESS = 1000;
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 c89f6c2597d0..29a7f9bab1a6 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleControllerTest.java
@@ -446,7 +446,8 @@ public class BubbleControllerTest extends SysuiTestCase {
BubbleStackView stackView = mBubbleController.getStackView();
mBubbleData.setExpanded(true);
assertTrue(mBubbleController.isStackExpanded());
- verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow2.getEntry().getKey());
+ verify(mBubbleExpandListener, atLeastOnce()).onBubbleExpandChanged(
+ true, mRow2.getEntry().getKey());
assertTrue(mSysUiStateBubblesExpanded);
@@ -464,9 +465,11 @@ public class BubbleControllerTest extends SysuiTestCase {
mRow.getEntry()));
// collapse for previous bubble
- verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow2.getEntry().getKey());
+ verify(mBubbleExpandListener, atLeastOnce()).onBubbleExpandChanged(
+ false, mRow2.getEntry().getKey());
// expand for selected bubble
- verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().getKey());
+ verify(mBubbleExpandListener, atLeastOnce()).onBubbleExpandChanged(
+ true, mRow.getEntry().getKey());
// Collapse
mBubbleController.collapseStack();
diff --git a/packages/SystemUI/tests/src/com/android/systemui/bubbles/NewNotifPipelineBubbleControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/bubbles/NewNotifPipelineBubbleControllerTest.java
index ead95ca1665e..e91867b61017 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/bubbles/NewNotifPipelineBubbleControllerTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/NewNotifPipelineBubbleControllerTest.java
@@ -399,7 +399,8 @@ public class NewNotifPipelineBubbleControllerTest extends SysuiTestCase {
BubbleStackView stackView = mBubbleController.getStackView();
mBubbleData.setExpanded(true);
assertTrue(mBubbleController.isStackExpanded());
- verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow2.getEntry().getKey());
+ verify(mBubbleExpandListener, atLeastOnce()).onBubbleExpandChanged(
+ true, mRow.getEntry().getKey());
// Last added is the one that is expanded
assertEquals(mRow2.getEntry(), mBubbleData.getSelectedBubble().getEntry());
@@ -414,9 +415,11 @@ public class NewNotifPipelineBubbleControllerTest extends SysuiTestCase {
mRow.getEntry()));
// collapse for previous bubble
- verify(mBubbleExpandListener).onBubbleExpandChanged(false, mRow2.getEntry().getKey());
+ verify(mBubbleExpandListener, atLeastOnce()).onBubbleExpandChanged(
+ false, mRow2.getEntry().getKey());
// expand for selected bubble
- verify(mBubbleExpandListener).onBubbleExpandChanged(true, mRow.getEntry().getKey());
+ verify(mBubbleExpandListener, atLeastOnce()).onBubbleExpandChanged(
+ true, mRow.getEntry().getKey());
// Collapse
mBubbleController.collapseStack();