Adds the new dismiss target, with fling/magnet to dismiss.

Test: atest SystemUITests
Bug: 123541855
Change-Id: Id2a149f8551eb58b240ece35569ca394c06e811d
This commit is contained in:
Joshua Tsuji
2019-04-22 17:36:11 -04:00
parent 10f78591a7
commit 4accf598a9
15 changed files with 1121 additions and 122 deletions

View File

@@ -0,0 +1,27 @@
<!--
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.
-->
<!--
The transparent circle outline that encircles the bubbles when they're in the dismiss target.
-->
<shape
xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<stroke
android:width="1dp"
android:color="#66FFFFFF" />
</shape>

View File

@@ -0,0 +1,26 @@
<!--
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.
-->
<!-- The 'X' bubble dismiss icon. This is just ic_close with a stroke. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24.0dp"
android:height="24.0dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:pathData="M19.000000,6.400000l-1.400000,-1.400000 -5.600000,5.600000 -5.600000,-5.600000 -1.400000,1.400000 5.600000,5.600000 -5.600000,5.600000 1.400000,1.400000 5.600000,-5.600000 5.600000,5.600000 1.400000,-1.400000 -5.600000,-5.600000z"
android:fillColor="#FFFFFFFF"
android:strokeColor="#FF000000"/>
</vector>

View File

@@ -0,0 +1,66 @@
<!--
~ 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
-->
<!-- Bubble dismiss target consisting of an X icon and the text 'Dismiss'. -->
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="@dimen/pip_dismiss_gradient_height"
android:layout_gravity="bottom|center_horizontal">
<LinearLayout
android:id="@+id/bubble_dismiss_icon_container"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center"
android:paddingBottom="@dimen/bubble_dismiss_target_padding_y"
android:paddingTop="@dimen/bubble_dismiss_target_padding_y"
android:paddingLeft="@dimen/bubble_dismiss_target_padding_x"
android:paddingRight="@dimen/bubble_dismiss_target_padding_x"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="horizontal">
<ImageView
android:id="@+id/bubble_dismiss_close_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/bubble_dismiss_icon" />
<TextView
android:id="@+id/bubble_dismiss_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="9dp"
android:layout_marginBottom="9dp"
android:layout_marginLeft="8dp"
android:textAppearance="@*android:style/TextAppearance.DeviceDefault.Body1"
android:textColor="@android:color/white"
android:shadowColor="@android:color/black"
android:shadowDx="-1"
android:shadowDy="1"
android:shadowRadius="0.01"
android:text="@string/bubble_dismiss_text" />
</LinearLayout>
<FrameLayout
android:id="@+id/bubble_dismiss_circle"
android:layout_width="@dimen/bubble_dismiss_encircle_size"
android:layout_height="@dimen/bubble_dismiss_encircle_size"
android:layout_gravity="center"
android:alpha="0"
android:background="@drawable/bubble_dismiss_circle" />
</FrameLayout>

View File

@@ -1095,6 +1095,8 @@
<dimen name="bubble_padding">8dp</dimen>
<!-- Size of individual bubbles. -->
<dimen name="individual_bubble_size">52dp</dimen>
<!-- Size of the circle around the bubbles when they're in the dismiss target. -->
<dimen name="bubble_dismiss_encircle_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 -->
@@ -1131,7 +1133,12 @@
<dimen name="bubble_header_icon_size">48dp</dimen>
<!-- Space between the pointer triangle and the bubble expanded view -->
<dimen name="bubble_pointer_margin">8dp</dimen>
<!-- Height of the permission prompt shown with bubbles -->
<dimen name="bubble_permission_height">120dp</dimen>
<!-- Padding applied to the bubble dismiss target. Touches in this padding cause the bubbles to
snap to the dismiss target. -->
<dimen name="bubble_dismiss_target_padding_x">40dp</dimen>
<dimen name="bubble_dismiss_target_padding_y">20dp</dimen>
<!-- Size of the RAT type for CellularTile -->
<dimen name="celltile_rat_type_size">10sp</dimen>
</resources>

View File

@@ -2442,4 +2442,6 @@
<string name="bubble_accessibility_action_move_bottom_left">Move bottom left</string>
<!-- Action in accessibility menu to move the stack of bubbles to the bottom right of the screen. [CHAR LIMIT=30]-->
<string name="bubble_accessibility_action_move_bottom_right">Move bottom right</string>
<!-- Text used for the bubble dismiss area. Bubbles dragged to, or flung towards, this area will go away. [CHAR LIMIT=20] -->
<string name="bubble_dismiss_text">Dismiss</string>
</resources>

View File

@@ -108,7 +108,7 @@ public class BubbleController implements ConfigurationController.ConfigurationLi
static final int DISMISS_ACCESSIBILITY_ACTION = 6;
static final int DISMISS_NO_LONGER_BUBBLE = 7;
static final int MAX_BUBBLES = 5; // TODO: actually enforce this
public static final int MAX_BUBBLES = 5; // TODO: actually enforce this
// Enables some subset of notifs to automatically become bubbles
private static final boolean DEBUG_ENABLE_AUTO_BUBBLE = false;

View File

@@ -0,0 +1,227 @@
/*
* 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;
import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
import android.view.animation.AccelerateDecelerateInterpolator;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.dynamicanimation.animation.DynamicAnimation;
import androidx.dynamicanimation.animation.SpringAnimation;
import androidx.dynamicanimation.animation.SpringForce;
import com.android.systemui.R;
/** Dismiss view that contains a scrim gradient, as well as a dismiss icon, text, and circle. */
public class BubbleDismissView extends FrameLayout {
/** Duration for animations involving the dismiss target text/icon/gradient. */
private static final int DISMISS_TARGET_ANIMATION_BASE_DURATION = 150;
private View mDismissGradient;
private LinearLayout mDismissTarget;
private ImageView mDismissIcon;
private TextView mDismissText;
private View mDismissCircle;
private SpringAnimation mDismissTargetAlphaSpring;
private SpringAnimation mDismissTargetVerticalSpring;
public BubbleDismissView(Context context) {
super(context);
setVisibility(GONE);
mDismissGradient = new FrameLayout(mContext);
FrameLayout.LayoutParams gradientParams =
new FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT);
gradientParams.gravity = Gravity.BOTTOM;
mDismissGradient.setLayoutParams(gradientParams);
Drawable gradient = mContext.getResources().getDrawable(R.drawable.pip_dismiss_scrim);
gradient.setAlpha((int) (255 * 0.85f));
mDismissGradient.setBackground(gradient);
mDismissGradient.setVisibility(GONE);
addView(mDismissGradient);
LayoutInflater.from(context).inflate(R.layout.bubble_dismiss_target, this, true);
mDismissTarget = findViewById(R.id.bubble_dismiss_icon_container);
mDismissIcon = findViewById(R.id.bubble_dismiss_close_icon);
mDismissText = findViewById(R.id.bubble_dismiss_text);
mDismissCircle = findViewById(R.id.bubble_dismiss_circle);
// Set up the basic target area animations. These are very simple animations that don't need
// fancy interpolators.
final AccelerateDecelerateInterpolator interpolator =
new AccelerateDecelerateInterpolator();
mDismissGradient.animate()
.setDuration(DISMISS_TARGET_ANIMATION_BASE_DURATION)
.setInterpolator(interpolator);
mDismissText.animate()
.setDuration(DISMISS_TARGET_ANIMATION_BASE_DURATION)
.setInterpolator(interpolator);
mDismissIcon.animate()
.setDuration(DISMISS_TARGET_ANIMATION_BASE_DURATION)
.setInterpolator(interpolator);
mDismissCircle.animate()
.setDuration(DISMISS_TARGET_ANIMATION_BASE_DURATION / 2)
.setInterpolator(interpolator);
mDismissTargetAlphaSpring =
new SpringAnimation(mDismissTarget, DynamicAnimation.ALPHA)
.setSpring(new SpringForce()
.setStiffness(SpringForce.STIFFNESS_LOW)
.setDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY));
mDismissTargetVerticalSpring =
new SpringAnimation(mDismissTarget, DynamicAnimation.TRANSLATION_Y)
.setSpring(new SpringForce()
.setStiffness(SpringForce.STIFFNESS_MEDIUM)
.setDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY));
mDismissTargetAlphaSpring.addEndListener((anim, canceled, alpha, velocity) -> {
// Since DynamicAnimations end when they're 'nearly' done, we can't rely on alpha being
// exactly zero when this listener is triggered. However, if it's less than 50% we can
// safely assume it was animating out rather than in.
if (alpha < 0.5f) {
// If the alpha spring was animating the view out, set it to GONE when it's done.
setVisibility(GONE);
}
});
}
/** Springs in the dismiss target and fades in the gradient. */
void springIn() {
setVisibility(View.VISIBLE);
// Fade in the dismiss target (icon + text).
mDismissTarget.setAlpha(0f);
mDismissTargetAlphaSpring.animateToFinalPosition(1f);
// Spring up the dismiss target (icon + text).
mDismissTarget.setTranslationY(mDismissTarget.getHeight() / 2f);
mDismissTargetVerticalSpring.animateToFinalPosition(0);
// Fade in the gradient.
mDismissGradient.setVisibility(VISIBLE);
mDismissGradient.animate().alpha(1f);
// Make sure the dismiss elements are in the separated position (in case we hid the target
// while they were condensed to cover the bubbles being in the target).
mDismissIcon.setAlpha(1f);
mDismissIcon.setScaleX(1f);
mDismissIcon.setScaleY(1f);
mDismissIcon.setTranslationX(0f);
mDismissText.setAlpha(1f);
mDismissText.setTranslationX(0f);
}
/** Springs out the dismiss target and fades out the gradient. */
void springOut() {
// Fade out the target.
mDismissTargetAlphaSpring.animateToFinalPosition(0f);
// Spring the target down a bit.
mDismissTargetVerticalSpring.animateToFinalPosition(mDismissTarget.getHeight() / 2f);
// Fade out the gradient and then set it to GONE so it's not in the SBV hierarchy.
mDismissGradient.animate().alpha(0f).withEndAction(
() -> mDismissGradient.setVisibility(GONE));
// Pop out the dismiss circle.
mDismissCircle.animate().alpha(0f).scaleX(1.2f).scaleY(1.2f);
}
/**
* Encircles the center of the dismiss target, pulling the X towards the center and hiding the
* text.
*/
void animateEncircleCenterWithX(boolean encircle) {
// Pull the text towards the center if we're encircling (it'll be faded out, leaving only
// the X icon over the bubbles), or back to normal if we're un-encircling.
final float textTranslation = encircle
? -mDismissIcon.getWidth() / 4f
: 0f;
// Center the icon if we're encircling, or put it back to normal if not.
final float iconTranslation = encircle
? mDismissTarget.getWidth() / 2f
- mDismissIcon.getWidth() / 2f
- mDismissIcon.getLeft()
: 0f;
// Fade in/out the text and translate it.
mDismissText.animate()
.alpha(encircle ? 0f : 1f)
.translationX(textTranslation);
mDismissIcon.animate()
.setDuration(150)
.translationX(iconTranslation);
// Fade out the gradient if we're encircling (the bubbles will 'absorb' it by darkening
// themselves).
mDismissGradient.animate()
.alpha(encircle ? 0f : 1f);
// Prepare the circle to be 'dropped in'.
if (encircle) {
mDismissCircle.setAlpha(0f);
mDismissCircle.setScaleX(1.2f);
mDismissCircle.setScaleY(1.2f);
}
// Drop in the circle, or pull it back up.
mDismissCircle.animate()
.alpha(encircle ? 1f : 0f)
.scaleX(encircle ? 1f : 0f)
.scaleY(encircle ? 1f : 0f);
}
/** Animates the circle and the centered icon out. */
void animateEncirclingCircleDisappearance() {
// Pop out the dismiss icon and circle.
mDismissIcon.animate()
.setDuration(50)
.scaleX(0.9f)
.scaleY(0.9f)
.alpha(0f);
mDismissCircle.animate()
.scaleX(0.9f)
.scaleY(0.9f)
.alpha(0f);
}
/** Returns the Y value of the center of the dismiss target. */
float getDismissTargetCenterY() {
return getTop() + mDismissTarget.getTop() + mDismissTarget.getHeight() / 2f;
}
/** Returns the dismiss target, which contains the text/icon and any added padding. */
View getDismissTarget() {
return mDismissTarget;
}
}

View File

@@ -19,12 +19,18 @@ 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.ValueAnimator;
import android.annotation.NonNull;
import android.content.Context;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Color;
import android.graphics.ColorMatrix;
import android.graphics.ColorMatrixColorFilter;
import android.graphics.Outline;
import android.graphics.Paint;
import android.graphics.Point;
import android.graphics.PointF;
import android.graphics.Rect;
@@ -32,6 +38,8 @@ import android.graphics.RectF;
import android.graphics.drawable.LayerDrawable;
import android.graphics.drawable.ShapeDrawable;
import android.os.Bundle;
import android.os.VibrationEffect;
import android.os.Vibrator;
import android.service.notification.StatusBarNotification;
import android.util.Log;
import android.util.StatsLog;
@@ -84,6 +92,9 @@ public class BubbleStackView extends FrameLayout {
/** Max width of the flyout, in terms of percent of the screen width. */
private static final float FLYOUT_MAX_WIDTH_PERCENT = .6f;
/** Percent to darken the bubbles when they're in the dismiss target. */
private static final float DARKEN_PERCENT = 0.3f;
/** How long to wait, in milliseconds, before hiding the flyout. */
@VisibleForTesting
static final int FLYOUT_HIDE_AFTER = 5000;
@@ -131,6 +142,10 @@ public class BubbleStackView extends FrameLayout {
private final SpringAnimation mExpandedViewYAnim;
private final BubbleData mBubbleData;
private final Vibrator mVibrator;
private final ValueAnimator mDesaturateAndDarkenAnimator;
private final Paint mDesaturateAndDarkenPaint = new Paint();
private PhysicsAnimationLayout mBubbleContainer;
private StackAnimationController mStackAnimationController;
private ExpandedAnimationController mExpandedAnimationController;
@@ -183,6 +198,20 @@ public class BubbleStackView extends FrameLayout {
private boolean mViewUpdatedRequested = false;
private boolean mIsExpansionAnimating = false;
private boolean mShowingDismiss = false;
/**
* Whether the user is currently dragging their finger within the dismiss target. In this state
* the stack will be magnetized to the center of the target, so we shouldn't move it until the
* touch exits the dismiss target area.
*/
private boolean mDraggingInDismissTarget = false;
/** Whether the stack is magneting towards the dismiss target. */
private boolean mAnimatingMagnet = false;
/** The view to desaturate/darken when magneted to the dismiss target. */
private View mDesaturateAndDarkenTargetView;
private LayoutInflater mInflater;
@@ -222,6 +251,8 @@ public class BubbleStackView extends FrameLayout {
@NonNull private final SurfaceSynchronizer mSurfaceSynchronizer;
private BubbleDismissView mDismissContainer;
private Runnable mAfterMagnet;
public BubbleStackView(Context context, BubbleData data,
@Nullable SurfaceSynchronizer synchronizer) {
@@ -253,6 +284,8 @@ public class BubbleStackView extends FrameLayout {
WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
wm.getDefaultDisplay().getSize(mDisplaySize);
mVibrator = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE);
int padding = res.getDimensionPixelSize(R.dimen.bubble_expanded_view_padding);
int elevation = res.getDimensionPixelSize(R.dimen.bubble_elevation);
@@ -286,6 +319,13 @@ public class BubbleStackView extends FrameLayout {
addView(mFlyoutContainer);
setupFlyout();
mDismissContainer = new BubbleDismissView(mContext);
mDismissContainer.setLayoutParams(new FrameLayout.LayoutParams(
MATCH_PARENT,
getResources().getDimensionPixelSize(R.dimen.pip_dismiss_gradient_height),
Gravity.BOTTOM));
addView(mDismissContainer);
mExpandedViewXAnim =
new SpringAnimation(mExpandedViewContainer, DynamicAnimation.TRANSLATION_X);
mExpandedViewXAnim.setSpring(
@@ -342,6 +382,29 @@ public class BubbleStackView extends FrameLayout {
// This must be a separate OnDrawListener since it should be called for every draw.
getViewTreeObserver().addOnDrawListener(mSystemGestureExcludeUpdater);
final ColorMatrix animatedMatrix = new ColorMatrix();
final ColorMatrix darkenMatrix = new ColorMatrix();
mDesaturateAndDarkenAnimator = ValueAnimator.ofFloat(1f, 0f);
mDesaturateAndDarkenAnimator.addUpdateListener(animation -> {
final float animatedValue = (float) animation.getAnimatedValue();
animatedMatrix.setSaturation(animatedValue);
final float animatedDarkenValue = (1f - animatedValue) * DARKEN_PERCENT;
darkenMatrix.setScale(
1f - animatedDarkenValue /* red */,
1f - animatedDarkenValue /* green */,
1f - animatedDarkenValue /* blue */,
1f /* alpha */);
// Concat the matrices so that the animatedMatrix both desaturates and darkens.
animatedMatrix.postConcat(darkenMatrix);
// Update the paint and apply it to the bubble container.
mDesaturateAndDarkenPaint.setColorFilter(new ColorMatrixColorFilter(animatedMatrix));
mDesaturateAndDarkenTargetView.setLayerPaint(mDesaturateAndDarkenPaint);
});
}
/**
@@ -838,23 +901,22 @@ public class BubbleStackView extends FrameLayout {
}
mExpandedAnimationController.dragBubbleOut(bubble, x, y);
springInDismissTarget();
}
/** Called when a drag operation on an individual bubble has finished. */
public void onBubbleDragFinish(
View bubble, float x, float y, float velX, float velY, boolean dismissed) {
View bubble, float x, float y, float velX, float velY) {
if (DEBUG) {
Log.d(TAG, "onBubbleDragFinish: bubble=" + bubble + ", dismissed=" + dismissed);
Log.d(TAG, "onBubbleDragFinish: bubble=" + bubble);
}
if (!mIsExpanded || mIsExpansionAnimating) {
return;
}
if (dismissed) {
mExpandedAnimationController.prepareForDismissalWithVelocity(bubble, velX, velY);
} else {
mExpandedAnimationController.snapBubbleBack(bubble, velX, velY);
}
mExpandedAnimationController.snapBubbleBack(bubble, velX, velY);
springOutDismissTargetAndHideCircle();
}
void onDragStart() {
@@ -870,6 +932,7 @@ public class BubbleStackView extends FrameLayout {
hideFlyoutImmediate();
mIsDragging = true;
mDraggingInDismissTarget = false;
}
void onDragged(float x, float y) {
@@ -877,7 +940,8 @@ public class BubbleStackView extends FrameLayout {
return;
}
mStackAnimationController.moveFirstBubbleWithStackFollowing(x, y);
springInDismissTarget();
mStackAnimationController.moveStackFromTouch(x, y);
}
void onDragFinish(float x, float y, float velX, float velY) {
@@ -894,10 +958,171 @@ public class BubbleStackView extends FrameLayout {
mStackAnimationController.flingStackThenSpringToEdge(x, velX, velY);
logBubbleEvent(null /* no bubble associated with bubble stack move */,
StatsLog.BUBBLE_UICHANGED__ACTION__STACK_MOVED);
springOutDismissTargetAndHideCircle();
}
void onDragFinishAsDismiss() {
mIsDragging = false;
/** Prepares and starts the desaturate/darken animation on the bubble stack. */
private void animateDesaturateAndDarken(View targetView, boolean desaturateAndDarken) {
mDesaturateAndDarkenTargetView = targetView;
if (desaturateAndDarken) {
// Use the animated paint for the bubbles.
mDesaturateAndDarkenTargetView.setLayerType(
View.LAYER_TYPE_HARDWARE, mDesaturateAndDarkenPaint);
mDesaturateAndDarkenAnimator.removeAllListeners();
mDesaturateAndDarkenAnimator.start();
} else {
mDesaturateAndDarkenAnimator.removeAllListeners();
mDesaturateAndDarkenAnimator.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
super.onAnimationEnd(animation);
// Stop using the animated paint.
resetDesaturationAndDarken();
}
});
mDesaturateAndDarkenAnimator.reverse();
}
}
private void resetDesaturationAndDarken() {
mDesaturateAndDarkenAnimator.removeAllListeners();
mDesaturateAndDarkenAnimator.cancel();
mDesaturateAndDarkenTargetView.setLayerType(View.LAYER_TYPE_NONE, null);
}
/**
* Magnets the stack to the target, while also transforming the target to encircle the stack and
* desaturating/darkening the bubbles.
*/
void animateMagnetToDismissTarget(
View magnetView, boolean toTarget, float x, float y, float velX, float velY) {
mDraggingInDismissTarget = toTarget;
if (toTarget) {
// The Y-value for the bubble stack to be positioned in the center of the dismiss target
final float destY = mDismissContainer.getDismissTargetCenterY() - mBubbleSize / 2f;
mAnimatingMagnet = true;
final Runnable afterMagnet = () -> {
mAnimatingMagnet = false;
if (mAfterMagnet != null) {
mAfterMagnet.run();
}
};
if (magnetView == this) {
mStackAnimationController.magnetToDismiss(velX, velY, destY, afterMagnet);
animateDesaturateAndDarken(mBubbleContainer, true);
} else {
mExpandedAnimationController.magnetBubbleToDismiss(
magnetView, velX, velY, destY, afterMagnet);
animateDesaturateAndDarken(magnetView, true);
}
mDismissContainer.animateEncircleCenterWithX(true);
} else {
mAnimatingMagnet = false;
if (magnetView == this) {
mStackAnimationController.demagnetizeFromDismissToPoint(x, y, velX, velY);
animateDesaturateAndDarken(mBubbleContainer, false);
} else {
mExpandedAnimationController.demagnetizeBubbleTo(x, y, velX, velY);
animateDesaturateAndDarken(magnetView, false);
}
mDismissContainer.animateEncircleCenterWithX(false);
}
mVibrator.vibrate(VibrationEffect.get(toTarget
? VibrationEffect.EFFECT_CLICK
: VibrationEffect.EFFECT_TICK));
}
/**
* Magnets the stack to the dismiss target if it's not already there. Then, dismiss the stack
* using the 'implode' animation and animate out the target.
*/
void magnetToStackIfNeededThenAnimateDismissal(
View touchedView, float velX, float velY, Runnable after) {
final Runnable animateDismissal = () -> {
mAfterMagnet = null;
mVibrator.vibrate(VibrationEffect.get(VibrationEffect.EFFECT_CLICK));
mDismissContainer.animateEncirclingCircleDisappearance();
// 'Implode' the stack and then hide the dismiss target.
if (touchedView == this) {
mStackAnimationController.implodeStack(
() -> {
mAnimatingMagnet = false;
mShowingDismiss = false;
mDraggingInDismissTarget = false;
after.run();
resetDesaturationAndDarken();
});
} else {
mExpandedAnimationController.dismissDraggedOutBubble(() -> {
mAnimatingMagnet = false;
mShowingDismiss = false;
mDraggingInDismissTarget = false;
resetDesaturationAndDarken();
after.run();
});
}
};
if (mAnimatingMagnet) {
// If the magnet animation is currently playing, dismiss the stack after it's done. This
// happens if the stack is flung towards the target.
mAfterMagnet = animateDismissal;
} else if (mDraggingInDismissTarget) {
// If we're in the dismiss target, but not animating, we already magneted - dismiss
// immediately.
animateDismissal.run();
} else {
// Otherwise, we need to start the magnet animation and then dismiss afterward.
animateMagnetToDismissTarget(touchedView, true, -1 /* x */, -1 /* y */, velX, velY);
mAfterMagnet = animateDismissal;
}
}
/** Animates in the dismiss target, including the gradient behind it. */
private void springInDismissTarget() {
if (mShowingDismiss) {
return;
}
mShowingDismiss = true;
// Show the dismiss container and bring it to the front so the bubbles will go behind it.
mDismissContainer.springIn();
mDismissContainer.bringToFront();
mDismissContainer.setZ(Short.MAX_VALUE - 1);
}
/**
* Animates the dismiss target out, as well as the circle that encircles the bubbles, if they
* were dragged into the target and encircled.
*/
private void springOutDismissTargetAndHideCircle() {
if (!mShowingDismiss) {
return;
}
mDismissContainer.springOut();
mShowingDismiss = false;
}
/** Whether the location of the given MotionEvent is within the dismiss target area. */
public boolean isInDismissTarget(MotionEvent ev) {
return isIntersecting(mDismissContainer.getDismissTarget(), ev.getRawX(), ev.getRawY());
}
/**
@@ -1066,7 +1291,7 @@ public class BubbleStackView extends FrameLayout {
private void setupFlyout() {
// Retrieve the styled floating background color.
TypedArray ta = mContext.obtainStyledAttributes(
new int[] {android.R.attr.colorBackgroundFloating});
new int[]{android.R.attr.colorBackgroundFloating});
final int floatingBackgroundColor = ta.getColor(0, Color.WHITE);
ta.recycle();

View File

@@ -16,8 +16,6 @@
package com.android.systemui.bubbles;
import static com.android.systemui.pip.phone.PipDismissViewController.SHOW_TARGET_DELAY;
import android.content.Context;
import android.graphics.PointF;
import android.os.Handler;
@@ -27,17 +25,35 @@ import android.view.View;
import android.view.ViewConfiguration;
import com.android.systemui.Dependency;
import com.android.systemui.pip.phone.PipDismissViewController;
/**
* Handles interpreting touches on a {@link BubbleStackView}. This includes expanding, collapsing,
* dismissing, and flings.
*/
class BubbleTouchHandler implements View.OnTouchListener {
/** Velocity required to dismiss a bubble without dragging it into the dismiss target. */
private static final float DISMISS_MIN_VELOCITY = 4000f;
/** Velocity required to dismiss the stack without dragging it into the dismiss target. */
private static final float STACK_DISMISS_MIN_VELOCITY = 4000f;
/**
* Velocity required to dismiss an individual bubble without dragging it into the dismiss
* target.
*
* This is higher than the stack dismiss velocity since unlike the stack, a downward fling could
* also be an attempted gesture to return the bubble to the row of expanded bubbles, which would
* usually be below the dragged bubble. By increasing the required velocity, it's less likely
* that the user is trying to drop it back into the row vs. fling it away.
*/
private static final float INDIVIDUAL_BUBBLE_DISMISS_MIN_VELOCITY = 6000f;
private static final String TAG = "BubbleTouchHandler";
/**
* When the stack is flung towards the bottom of the screen, it'll be dismissed if it's flung
* towards the center of the screen (where the dismiss target is). This value is the width of
* the target area to be considered 'towards the target'. For example 50% means that the stack
* needs to be flung towards the middle 50%, and the 25% on the left and right sides won't
* count.
*/
private static final float DISMISS_FLING_TARGET_WIDTH_PERCENT = 0.5f;
private final PointF mTouchDown = new PointF();
private final PointF mViewPositionOnTouchDown = new PointF();
@@ -45,7 +61,6 @@ class BubbleTouchHandler implements View.OnTouchListener {
private final BubbleData mBubbleData;
private BubbleController mController = Dependency.get(BubbleController.class);
private PipDismissViewController mDismissViewController;
private boolean mMovedEnough;
private int mTouchSlopSquared;
@@ -53,12 +68,6 @@ class BubbleTouchHandler implements View.OnTouchListener {
private boolean mInDismissTarget;
private Handler mHandler = new Handler();
private Runnable mShowDismissAffordance = new Runnable() {
@Override
public void run() {
mDismissViewController.showDismissTarget();
}
};
/** View that was initially touched, when we received the first ACTION_DOWN event. */
private View mTouchedView;
@@ -67,7 +76,6 @@ class BubbleTouchHandler implements View.OnTouchListener {
BubbleData bubbleData, Context context) {
final int touchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
mTouchSlopSquared = touchSlop * touchSlop;
mDismissViewController = new PipDismissViewController(context);
mBubbleData = bubbleData;
mStack = stackView;
}
@@ -104,11 +112,6 @@ class BubbleTouchHandler implements View.OnTouchListener {
mTouchDown.set(rawX, rawY);
if (!isFlyout) {
mDismissViewController.createDismissTarget();
mHandler.postDelayed(mShowDismissAffordance, SHOW_TARGET_DELAY);
}
if (isStack) {
mViewPositionOnTouchDown.set(mStack.getStackPosition());
mStack.onDragStart();
@@ -140,9 +143,18 @@ class BubbleTouchHandler implements View.OnTouchListener {
}
}
// TODO - when we're in the target stick to it / animate in some way?
mInDismissTarget = mDismissViewController.updateTarget(
isStack ? mStack.getBubbleAt(0) : mTouchedView);
final boolean currentlyInDismissTarget = mStack.isInDismissTarget(event);
if (currentlyInDismissTarget != mInDismissTarget) {
mInDismissTarget = currentlyInDismissTarget;
mVelocityTracker.computeCurrentVelocity(/* maxVelocity */ 1000);
final float velX = mVelocityTracker.getXVelocity();
final float velY = mVelocityTracker.getYVelocity();
// If the touch event is within the dismiss target, magnet the stack to it.
mStack.animateMagnetToDismissTarget(
mTouchedView, mInDismissTarget, viewX, viewY, velX, velY);
}
break;
case MotionEvent.ACTION_CANCEL:
@@ -151,28 +163,40 @@ class BubbleTouchHandler implements View.OnTouchListener {
case MotionEvent.ACTION_UP:
trackMovement(event);
if (mInDismissTarget && isStack) {
mController.dismissStack(BubbleController.DISMISS_USER_GESTURE);
mStack.onDragFinishAsDismiss();
mVelocityTracker.computeCurrentVelocity(/* maxVelocity */ 1000);
final float velX = mVelocityTracker.getXVelocity();
final float velY = mVelocityTracker.getYVelocity();
final boolean shouldDismiss =
isStack
? mInDismissTarget
|| isFastFlingTowardsDismissTarget(rawX, rawY, velX, velY)
: mInDismissTarget
|| velY > INDIVIDUAL_BUBBLE_DISMISS_MIN_VELOCITY;
if (shouldDismiss) {
final String individualBubbleKey =
isStack ? null : ((BubbleView) mTouchedView).getKey();
mStack.magnetToStackIfNeededThenAnimateDismissal(mTouchedView, velX, velY,
() -> {
if (isStack) {
mController.dismissStack(BubbleController.DISMISS_USER_GESTURE);
} else {
mController.removeBubble(
individualBubbleKey,
BubbleController.DISMISS_USER_GESTURE);
}
});
} else if (isFlyout) {
// TODO(b/129768381): Expand if tapped, dismiss if swiped away.
if (!mBubbleData.isExpanded() && !mMovedEnough) {
mBubbleData.setExpanded(true);
}
} else if (mMovedEnough) {
mVelocityTracker.computeCurrentVelocity(/* maxVelocity */ 1000);
final float velX = mVelocityTracker.getXVelocity();
final float velY = mVelocityTracker.getYVelocity();
if (isStack) {
mStack.onDragFinish(viewX, viewY, velX, velY);
} else {
final boolean dismissed = mInDismissTarget || velY > DISMISS_MIN_VELOCITY;
mStack.onBubbleDragFinish(
mTouchedView, viewX, viewY, velX, velY, /* dismissed */ dismissed);
if (dismissed) {
mController.removeBubble(((BubbleView) mTouchedView).getKey(),
BubbleController.DISMISS_USER_GESTURE);
}
mStack.onBubbleDragFinish(mTouchedView, viewX, viewY, velX, velY);
}
} else if (mTouchedView == mStack.getExpandedBubbleView()) {
mBubbleData.setExpanded(false);
@@ -191,9 +215,38 @@ class BubbleTouchHandler implements View.OnTouchListener {
return true;
}
/**
* Whether the given touch data represents a powerful fling towards the bottom-center of the
* screen (the dismiss target).
*/
private boolean isFastFlingTowardsDismissTarget(
float rawX, float rawY, float velX, float velY) {
// Not a fling downward towards the target if velocity is zero or negative.
if (velY <= 0) {
return false;
}
float bottomOfScreenInterceptX = rawX;
// Only do math if the X velocity is non-zero, otherwise X won't change.
if (velX != 0) {
// Rise over run...
final float slope = velY / velX;
// ...y = mx + b, b = y / mx...
final float yIntercept = rawY - slope * rawX;
// ...calculate the x value when y = bottom of the screen.
bottomOfScreenInterceptX = (mStack.getHeight() - yIntercept) / slope;
}
final float dismissTargetWidth =
mStack.getWidth() * DISMISS_FLING_TARGET_WIDTH_PERCENT;
return velY > STACK_DISMISS_MIN_VELOCITY
&& bottomOfScreenInterceptX > dismissTargetWidth / 2f
&& bottomOfScreenInterceptX < mStack.getWidth() - dismissTargetWidth / 2f;
}
/** Clears all touch-related state. */
private void resetForNextGesture() {
cleanUpDismissTarget();
if (mVelocityTracker != null) {
mVelocityTracker.recycle();
mVelocityTracker = null;
@@ -203,15 +256,6 @@ class BubbleTouchHandler implements View.OnTouchListener {
mInDismissTarget = false;
}
/**
* Removes the dismiss target and cancels any pending callbacks to show it.
*/
private void cleanUpDismissTarget() {
mHandler.removeCallbacks(mShowDismissAffordance);
mDismissViewController.destroyDismissTarget();
}
private void trackMovement(MotionEvent event) {
if (mVelocityTracker == null) {
mVelocityTracker = VelocityTracker.obtain();

View File

@@ -64,6 +64,20 @@ public class ExpandedAnimationController
/** Size of dismiss target at bottom of screen. */
private float mPipDismissHeight;
/** Whether the dragged-out bubble is in the dismiss target. */
private boolean mIndividualBubbleWithinDismissTarget = false;
/**
* Whether the dragged out bubble is springing towards the touch point, rather than using the
* default behavior of moving directly to the touch point.
*
* This happens when the user's finger exits the dismiss area while the bubble is magnetized to
* the center. Since the touch point differs from the bubble location, we need to animate the
* bubble back to the touch point to avoid a jarring instant location change from the center of
* the target to the touch point just outside the target bounds.
*/
private boolean mSpringingBubbleToTouch = false;
public ExpandedAnimationController(Point displaySize) {
mDisplaySize = displaySize;
}
@@ -151,8 +165,23 @@ public class ExpandedAnimationController
* bubble is dragged back into the row.
*/
public void dragBubbleOut(View bubbleView, float x, float y) {
bubbleView.setTranslationX(x);
bubbleView.setTranslationY(y);
if (mSpringingBubbleToTouch) {
if (mLayout.arePropertiesAnimatingOnView(
bubbleView, DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y)) {
animationForChild(mBubbleDraggingOut)
.translationX(x)
.translationY(y)
.withStiffness(SpringForce.STIFFNESS_HIGH)
.start();
} else {
mSpringingBubbleToTouch = false;
}
}
if (!mSpringingBubbleToTouch && !mIndividualBubbleWithinDismissTarget) {
bubbleView.setTranslationX(x);
bubbleView.setTranslationY(y);
}
final boolean draggedOutEnough =
y > getExpandedY() + mBubbleSizePx || y < getExpandedY() - mBubbleSizePx;
@@ -164,6 +193,53 @@ public class ExpandedAnimationController
}
}
/** Plays a dismiss animation on the dragged out bubble. */
public void dismissDraggedOutBubble(Runnable after) {
mIndividualBubbleWithinDismissTarget = false;
// Fill the space from the soon to be dismissed bubble.
animateStackByBubbleWidthsStartingFrom(
/* numBubbleWidths */ -1,
/* startIndex */ mLayout.indexOfChild(mBubbleDraggingOut) + 1);
animationForChild(mBubbleDraggingOut)
.withStiffness(SpringForce.STIFFNESS_HIGH)
.scaleX(1.1f)
.scaleY(1.1f)
.alpha(0f, after)
.start();
}
/** Magnets the given bubble to the dismiss target. */
public void magnetBubbleToDismiss(
View bubbleView, float velX, float velY, float destY, Runnable after) {
mIndividualBubbleWithinDismissTarget = true;
mSpringingBubbleToTouch = false;
animationForChild(bubbleView)
.withStiffness(SpringForce.STIFFNESS_MEDIUM)
.withDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY)
.withPositionStartVelocities(velX, velY)
.translationX(mLayout.getWidth() / 2f - mBubbleSizePx / 2f)
.translationY(destY, after)
.start();
}
/**
* Springs the dragged-out bubble towards the given coordinates and sets flags to have touch
* events update the spring's final position until it's settled.
*/
public void demagnetizeBubbleTo(float x, float y, float velX, float velY) {
mIndividualBubbleWithinDismissTarget = false;
mSpringingBubbleToTouch = true;
animationForChild(mBubbleDraggingOut)
.translationX(x)
.translationY(y)
.withPositionStartVelocities(velX, velY)
.withStiffness(SpringForce.STIFFNESS_HIGH)
.start();
}
/**
* Snaps a bubble back to its position within the bubble row, and animates the rest of the
* bubbles to accommodate it if it was previously dragged out past the threshold.
@@ -274,28 +350,21 @@ public class ExpandedAnimationController
@Override
void onChildRemoved(View child, int index, Runnable finishRemoval) {
// Bubble pops out to the top.
// TODO: Reverse this when bubbles are at the bottom.
final PhysicsAnimationLayout.PhysicsPropertyAnimator animator = animationForChild(child);
animator.alpha(0f, finishRemoval /* endAction */);
// If we're removing the dragged-out bubble, that means it got dismissed.
if (child.equals(mBubbleDraggingOut)) {
animator.position(
mLayout.getWidth() / 2f - mBubbleSizePx / 2f,
mLayout.getHeight() + mBubbleSizePx)
.withPositionStartVelocities(mBubbleDraggingOutVelX, mBubbleDraggingOutVelY)
.scaleX(ANIMATE_SCALE_PERCENT)
.scaleY(ANIMATE_SCALE_PERCENT);
mBubbleDraggingOut = null;
finishRemoval.run();
} else {
animator.translationY(getExpandedY() - mBubbleSizePx * ANIMATE_TRANSLATION_FACTOR);
animator.alpha(0f, finishRemoval /* endAction */)
.withStiffness(SpringForce.STIFFNESS_HIGH)
.withDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY)
.scaleX(1.1f)
.scaleY(1.1f)
.start();
}
animator.start();
// Animate all the other bubbles to their new positions sans this bubble.
animateBubblesAfterIndexToCorrectX(index);
}

View File

@@ -290,6 +290,10 @@ public class PhysicsAnimationLayout extends FrameLayout {
final Runnable checkIfAllFinished = () -> {
if (!arePropertiesAnimating(properties)) {
action.run();
for (DynamicAnimation.ViewProperty property : properties) {
removeEndActionForProperty(property);
}
}
};
@@ -379,10 +383,21 @@ public class PhysicsAnimationLayout extends FrameLayout {
/** 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;
}
if (arePropertiesAnimatingOnView(getChildAt(i), properties)) {
return true;
}
}
return false;
}
/** Checks whether any animations of the given properties are running on the given view. */
public boolean arePropertiesAnimatingOnView(
View view, DynamicAnimation.ViewProperty... properties) {
for (DynamicAnimation.ViewProperty property : properties) {
final SpringAnimation animation = getAnimationFromView(property, view);
if (animation != null && animation.isRunning()) {
return true;
}
}
@@ -556,7 +571,11 @@ public class PhysicsAnimationLayout extends FrameLayout {
DynamicAnimation anim, boolean canceled, float value, float velocity) {
if (!arePropertiesAnimating(mProperty)) {
if (mEndActionForProperty.containsKey(mProperty)) {
mEndActionForProperty.get(mProperty).run();
final Runnable callback = mEndActionForProperty.get(mProperty);
if (callback != null) {
callback.run();
}
}
}
}
@@ -578,6 +597,12 @@ public class PhysicsAnimationLayout extends FrameLayout {
/** Start delay to use when start is called. */
private long mStartDelay = 0;
/** Damping ratio to use for the animations. */
private float mDampingRatio = -1;
/** Stiffness to use for the animations. */
private float mStiffness = -1;
/** End actions to call when animations for the given property complete. */
private Map<DynamicAnimation.ViewProperty, Runnable[]> mEndActionsForProperty =
new HashMap<>();
@@ -686,6 +711,24 @@ public class PhysicsAnimationLayout extends FrameLayout {
return this;
}
/**
* Set the damping ratio to use for this animation. If not supplied, will default to the
* value from {@link PhysicsAnimationController#getSpringForce}.
*/
public PhysicsPropertyAnimator withDampingRatio(float dampingRatio) {
mDampingRatio = dampingRatio;
return this;
}
/**
* Set the stiffness to use for this animation. If not supplied, will default to the
* value from {@link PhysicsAnimationController#getSpringForce}.
*/
public PhysicsPropertyAnimator withStiffness(float stiffness) {
mStiffness = stiffness;
return this;
}
/**
* Set the start velocities to use for TRANSLATION_X and TRANSLATION_Y animations. This
* overrides any value set via {@link #withStartVelocity(float)} for those properties.
@@ -711,12 +754,14 @@ public class PhysicsAnimationLayout extends FrameLayout {
// If there are end actions, set an end listener on the layout for all the properties
// we're about to animate.
if (after != null) {
if (after != null && after.length > 0) {
final DynamicAnimation.ViewProperty[] propertiesArray =
properties.toArray(new DynamicAnimation.ViewProperty[0]);
for (Runnable callback : after) {
setEndActionForMultipleProperties(callback, propertiesArray);
}
setEndActionForMultipleProperties(() -> {
for (Runnable callback : after) {
callback.run();
}
}, propertiesArray);
}
// If we used position-specific end actions, we'll need to listen for both TRANSLATION_X
@@ -746,12 +791,15 @@ public class PhysicsAnimationLayout extends FrameLayout {
// Actually start the animations.
for (DynamicAnimation.ViewProperty property : properties) {
final SpringForce defaultSpringForce = mController.getSpringForce(property, mView);
animateValueForChild(
property,
mView,
mAnimatedProperties.get(property),
mPositionStartVelocities.getOrDefault(property, mDefaultStartVelocity),
mStartDelay,
mStiffness >= 0 ? mStiffness : defaultSpringForce.getStiffness(),
mDampingRatio >= 0 ? mDampingRatio : defaultSpringForce.getDampingRatio(),
mEndActionsForProperty.get(property));
}
@@ -760,6 +808,8 @@ public class PhysicsAnimationLayout extends FrameLayout {
mPositionStartVelocities.clear();
mDefaultStartVelocity = 0;
mStartDelay = 0;
mStiffness = -1;
mDampingRatio = -1;
mEndActionsForProperty.clear();
}
@@ -778,6 +828,8 @@ public class PhysicsAnimationLayout extends FrameLayout {
float value,
float startVel,
long startDelay,
float stiffness,
float dampingRatio,
Runnable[] afterCallbacks) {
if (view != null) {
final SpringAnimation animation =
@@ -795,6 +847,9 @@ public class PhysicsAnimationLayout extends FrameLayout {
});
}
animation.getSpring().setStiffness(stiffness);
animation.getSpring().setDampingRatio(dampingRatio);
if (startVel > 0) {
animation.setStartVelocity(startVel);
}

View File

@@ -30,7 +30,6 @@ import androidx.dynamicanimation.animation.SpringAnimation;
import androidx.dynamicanimation.animation.SpringForce;
import com.android.systemui.R;
import com.android.systemui.bubbles.BubbleController;
import com.google.android.collect.Sets;
@@ -116,6 +115,25 @@ public class StackAnimationController extends
*/
private boolean mIsMovingFromFlinging = false;
/**
* Whether the stack is within the dismiss target (either by being dragged, magnet'd, or flung).
*/
private boolean mWithinDismissTarget = false;
/**
* Whether the first bubble is springing towards the touch point, rather than using the default
* behavior of moving directly to the touch point with the rest of the stack following it.
*
* This happens when the user's finger exits the dismiss area while the stack is magnetized to
* the center. Since the touch point differs from the stack location, we need to animate the
* stack back to the touch point to avoid a jarring instant location change from the center of
* the target to the touch point just outside the target bounds.
*
* This is reset once the spring animations end, since that means the first bubble has
* successfully 'caught up' to the touch.
*/
private boolean mFirstBubbleSpringingToTouch = false;
/** Horizontal offset of bubbles in the stack. */
private float mStackOffset;
/** Diameter of the bubbles themselves. */
@@ -445,6 +463,120 @@ public class StackAnimationController extends
return allowableRegion;
}
/** Moves the stack in response to a touch event. */
public void moveStackFromTouch(float x, float y) {
// If we're springing to the touch point to 'catch up' after dragging out of the dismiss
// target, then update the stack position animations instead of moving the bubble directly.
if (mFirstBubbleSpringingToTouch) {
final SpringAnimation springToTouchX =
(SpringAnimation) mStackPositionAnimations.get(DynamicAnimation.TRANSLATION_X);
final SpringAnimation springToTouchY =
(SpringAnimation) mStackPositionAnimations.get(DynamicAnimation.TRANSLATION_Y);
// If either animation is still running, we haven't caught up. Update the animations.
if (springToTouchX.isRunning() || springToTouchY.isRunning()) {
springToTouchX.animateToFinalPosition(x);
springToTouchY.animateToFinalPosition(y);
} else {
// If the animations have finished, the stack is now at the touch point. We can
// resume moving the bubble directly.
mFirstBubbleSpringingToTouch = false;
}
}
if (!mFirstBubbleSpringingToTouch && !mWithinDismissTarget) {
moveFirstBubbleWithStackFollowing(x, y);
}
}
/**
* Demagnetizes the stack, springing it towards the given point. This also sets flags so that
* subsequent touch events will update the final position of the demagnetization spring instead
* of directly moving the bubbles, until demagnetization is complete.
*/
public void demagnetizeFromDismissToPoint(float x, float y, float velX, float velY) {
mWithinDismissTarget = false;
mFirstBubbleSpringingToTouch = true;
springFirstBubbleWithStackFollowing(
DynamicAnimation.TRANSLATION_X,
new SpringForce()
.setDampingRatio(DEFAULT_BOUNCINESS)
.setStiffness(DEFAULT_STIFFNESS),
velX, x);
springFirstBubbleWithStackFollowing(
DynamicAnimation.TRANSLATION_Y,
new SpringForce()
.setDampingRatio(DEFAULT_BOUNCINESS)
.setStiffness(DEFAULT_STIFFNESS),
velY, y);
}
/**
* Spring the stack towards the dismiss target, respecting existing velocity. This also sets
* flags so that subsequent touch events will not move the stack until it's demagnetized.
*/
public void magnetToDismiss(float velX, float velY, float destY, Runnable after) {
mWithinDismissTarget = true;
mFirstBubbleSpringingToTouch = false;
animationForChildAtIndex(0)
.translationX(mLayout.getWidth() / 2f - mIndividualBubbleSize / 2f)
.translationY(destY, after)
.withPositionStartVelocities(velX, velY)
.withStiffness(SpringForce.STIFFNESS_MEDIUM)
.withDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY)
.start();
}
/**
* 'Implode' the stack by shrinking the bubbles via chained animations and fading them out.
*/
public void implodeStack(Runnable after) {
// Pop and fade the bubbles sequentially.
animationForChildAtIndex(0)
.scaleX(0.5f)
.scaleY(0.5f)
.alpha(0f)
.withDampingRatio(SpringForce.DAMPING_RATIO_NO_BOUNCY)
.withStiffness(SpringForce.STIFFNESS_HIGH)
.start(() -> {
// Run the callback and reset flags. The child translation animations might
// still be running, but that's fine. Once the alpha is at 0f they're no longer
// visible anyway.
after.run();
mWithinDismissTarget = false;
});
}
/**
* Springs the first bubble to the given final position, with the rest of the stack 'following'.
*/
protected void springFirstBubbleWithStackFollowing(
DynamicAnimation.ViewProperty property, SpringForce spring,
float vel, float finalPosition) {
if (mLayout.getChildCount() == 0) {
return;
}
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);
}
@Override
Set<DynamicAnimation.ViewProperty> getAnimatedProperties() {
return Sets.newHashSet(
@@ -459,7 +591,9 @@ public class StackAnimationController extends
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.
return index + 1;
} else if (mWithinDismissTarget) {
return index + 1; // Chain all animations in dismiss (scale, alpha, etc. are used).
} else {
return NONE;
}
@@ -469,9 +603,15 @@ public class StackAnimationController extends
@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;
// If we're in the dismiss target, have the bubbles pile on top of each other with no
// offset.
if (mWithinDismissTarget) {
return 0f;
} else {
// Offset to the left if we're on the left, or the right otherwise.
return mLayout.isFirstChildXLeftOfCenter(mStackPosition.x)
? -mStackOffset : mStackOffset;
}
} else {
return 0f;
}
@@ -480,11 +620,8 @@ public class StackAnimationController extends
@Override
SpringForce getSpringForce(DynamicAnimation.ViewProperty property, View view) {
return new SpringForce()
.setDampingRatio(BubbleController.getBubbleBounciness(
mLayout.getContext(), DEFAULT_BOUNCINESS))
.setStiffness(BubbleController.getBubbleStiffness(
mLayout.getContext(),
mIsMovingFromFlinging ? FLING_FOLLOW_STIFFNESS : DEFAULT_STIFFNESS));
.setDampingRatio(DEFAULT_BOUNCINESS)
.setStiffness(mIsMovingFromFlinging ? FLING_FOLLOW_STIFFNESS : DEFAULT_STIFFNESS);
}
@Override
@@ -593,32 +730,6 @@ public class StackAnimationController extends
.start();
}
/**
* 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) {
if (mLayout.getChildCount() == 0) {
return;
}
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

View File

@@ -17,6 +17,8 @@
package com.android.systemui.bubbles.animation;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.mockito.Mockito.verify;
import android.content.res.Resources;
import android.graphics.Point;
@@ -69,14 +71,14 @@ public class ExpandedAnimationControllerTest extends PhysicsAnimationLayoutTestC
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
testBubblesInCorrectExpandedPositions();
Mockito.verify(afterExpand).run();
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();
verify(afterExpand).run();
}
@Test
@@ -140,6 +142,78 @@ public class ExpandedAnimationControllerTest extends PhysicsAnimationLayoutTestC
testBubblesInCorrectExpandedPositions();
}
@Test
public void testMagnetToDismiss_dismiss() throws InterruptedException {
expand();
final View draggedOutView = mViews.get(0);
final Runnable after = Mockito.mock(Runnable.class);
mExpandedController.prepareForBubbleDrag(draggedOutView);
mExpandedController.dragBubbleOut(draggedOutView, 25, 25);
// Magnet to dismiss, verify the bubble is at the dismiss target and the callback was
// called.
mExpandedController.magnetBubbleToDismiss(
mViews.get(0), 100 /* velX */, 100 /* velY */, 1000 /* destY */, after);
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
verify(after).run();
assertEquals(1000, mViews.get(0).getTranslationY(), .1f);
// Dismiss the now-magneted bubble, verify that the callback was called.
final Runnable afterDismiss = Mockito.mock(Runnable.class);
mExpandedController.dismissDraggedOutBubble(afterDismiss);
waitForPropertyAnimations(DynamicAnimation.ALPHA);
verify(after).run();
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
assertEquals(mBubblePadding, mViews.get(1).getTranslationX(), 1f);
}
@Test
public void testMagnetToDismiss_demagnetizeThenDrag() throws InterruptedException {
expand();
final View draggedOutView = mViews.get(0);
final Runnable after = Mockito.mock(Runnable.class);
mExpandedController.prepareForBubbleDrag(draggedOutView);
mExpandedController.dragBubbleOut(draggedOutView, 25, 25);
// Magnet to dismiss, verify the bubble is at the dismiss target and the callback was
// called.
mExpandedController.magnetBubbleToDismiss(
draggedOutView, 100 /* velX */, 100 /* velY */, 1000 /* destY */, after);
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
verify(after).run();
assertEquals(1000, mViews.get(0).getTranslationY(), .1f);
// Demagnetize the bubble towards (25, 25).
mExpandedController.demagnetizeBubbleTo(25 /* x */, 25 /* y */, 100, 100);
// Start dragging towards (20, 20).
mExpandedController.dragBubbleOut(draggedOutView, 20, 20);
// Since we just demagnetized, the bubble shouldn't be at (20, 20), it should be animating
// towards it.
assertNotEquals(20, draggedOutView.getTranslationX());
assertNotEquals(20, draggedOutView.getTranslationY());
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
// Waiting for the animations should result in the bubble ending at (20, 20) since the
// animation end value was updated.
assertEquals(20, draggedOutView.getTranslationX(), 1f);
assertEquals(20, draggedOutView.getTranslationY(), 1f);
// Drag to (30, 30).
mExpandedController.dragBubbleOut(draggedOutView, 30, 30);
// It should go there instantly since the animations finished.
assertEquals(30, draggedOutView.getTranslationX(), 1f);
assertEquals(30, draggedOutView.getTranslationY(), 1f);
}
/** Expand the stack and wait for animations to finish. */
private void expand() throws InterruptedException {
mExpandedController.expandFromStack(mExpansionPoint, Mockito.mock(Runnable.class));

View File

@@ -195,9 +195,11 @@ public class PhysicsAnimationLayoutTestCase extends SysuiTestCase {
@Override
protected void animateValueForChild(DynamicAnimation.ViewProperty property, View view,
float value, float startVel, long startDelay, Runnable[] afterCallbacks) {
float value, float startVel, long startDelay, float stiffness,
float dampingRatio, Runnable[] afterCallbacks) {
mMainThreadHandler.post(() -> super.animateValueForChild(
property, view, value, startVel, startDelay, afterCallbacks));
property, view, value, startVel, startDelay, stiffness, dampingRatio,
afterCallbacks));
}
}

View File

@@ -17,6 +17,8 @@
package com.android.systemui.bubbles.animation;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.mockito.Mockito.verify;
import android.graphics.PointF;
import android.testing.AndroidTestingRunner;
@@ -33,6 +35,7 @@ import org.junit.Before;
import org.junit.Ignore;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mockito;
import org.mockito.Spy;
@SmallTest
@@ -223,6 +226,59 @@ public class StackAnimationControllerTest extends PhysicsAnimationLayoutTestCase
assertEquals(prevStackPos, mStackController.getStackPosition());
}
@Test
public void testMagnetToDismiss_dismiss() throws InterruptedException {
final Runnable after = Mockito.mock(Runnable.class);
// Magnet to dismiss, verify the stack is at the dismiss target and the callback was
// called.
mStackController.magnetToDismiss(100 /* velX */, 100 /* velY */, 1000 /* destY */, after);
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
verify(after).run();
assertEquals(1000, mViews.get(0).getTranslationY(), .1f);
// Dismiss the stack, verify that the callback was called.
final Runnable afterImplode = Mockito.mock(Runnable.class);
mStackController.implodeStack(afterImplode);
waitForPropertyAnimations(
DynamicAnimation.ALPHA, DynamicAnimation.SCALE_X, DynamicAnimation.SCALE_Y);
verify(after).run();
}
@Test
public void testMagnetToDismiss_demagnetizeThenDrag() throws InterruptedException {
final Runnable after = Mockito.mock(Runnable.class);
// Magnet to dismiss, verify the stack is at the dismiss target and the callback was
// called.
mStackController.magnetToDismiss(100 /* velX */, 100 /* velY */, 1000 /* destY */, after);
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
verify(after).run();
assertEquals(1000, mViews.get(0).getTranslationY(), .1f);
// Demagnetize towards (25, 25) and then send a touch event.
mStackController.demagnetizeFromDismissToPoint(25, 25, 0, 0);
waitForLayoutMessageQueue();
mStackController.moveStackFromTouch(20, 20);
// Since the stack is demagnetizing, it shouldn't be at the stack position yet.
assertNotEquals(20, mStackController.getStackPosition().x, 1f);
assertNotEquals(20, mStackController.getStackPosition().y, 1f);
waitForPropertyAnimations(DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
// Once the animation is done it should end at the touch position coordinates.
assertEquals(20, mStackController.getStackPosition().x, 1f);
assertEquals(20, mStackController.getStackPosition().y, 1f);
mStackController.moveStackFromTouch(30, 30);
// Touches after the animation are done should change the stack position instantly.
assertEquals(30, mStackController.getStackPosition().x, 1f);
assertEquals(30, mStackController.getStackPosition().y, 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.
@@ -249,5 +305,13 @@ public class StackAnimationControllerTest extends PhysicsAnimationLayoutTestCase
super.flingThenSpringFirstBubbleWithStackFollowing(
property, vel, friction, spring, finalPosition));
}
@Override
protected void springFirstBubbleWithStackFollowing(DynamicAnimation.ViewProperty property,
SpringForce spring, float vel, float finalPosition) {
mMainThreadHandler.post(() ->
super.springFirstBubbleWithStackFollowing(
property, spring, vel, finalPosition));
}
}
}