diff options
15 files changed, 1121 insertions, 122 deletions
diff --git a/packages/SystemUI/res/drawable/bubble_dismiss_circle.xml b/packages/SystemUI/res/drawable/bubble_dismiss_circle.xml new file mode 100644 index 000000000000..1661bb22d148 --- /dev/null +++ b/packages/SystemUI/res/drawable/bubble_dismiss_circle.xml @@ -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>
\ No newline at end of file diff --git a/packages/SystemUI/res/drawable/bubble_dismiss_icon.xml b/packages/SystemUI/res/drawable/bubble_dismiss_icon.xml new file mode 100644 index 000000000000..5c8de581f8d1 --- /dev/null +++ b/packages/SystemUI/res/drawable/bubble_dismiss_icon.xml @@ -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> diff --git a/packages/SystemUI/res/layout/bubble_dismiss_target.xml b/packages/SystemUI/res/layout/bubble_dismiss_target.xml new file mode 100644 index 000000000000..245177c8461b --- /dev/null +++ b/packages/SystemUI/res/layout/bubble_dismiss_target.xml @@ -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>
\ No newline at end of file diff --git a/packages/SystemUI/res/values/dimens.xml b/packages/SystemUI/res/values/dimens.xml index 0fa542c498bc..e53798d083fc 100644 --- a/packages/SystemUI/res/values/dimens.xml +++ b/packages/SystemUI/res/values/dimens.xml @@ -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> diff --git a/packages/SystemUI/res/values/strings.xml b/packages/SystemUI/res/values/strings.xml index 6ba72b6b85ad..13a20bf9bb7c 100644 --- a/packages/SystemUI/res/values/strings.xml +++ b/packages/SystemUI/res/values/strings.xml @@ -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> diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java index 2d0944ad246f..48edf67a3ed4 100644 --- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java +++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleController.java @@ -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; diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleDismissView.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleDismissView.java new file mode 100644 index 000000000000..4db1e276f431 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleDismissView.java @@ -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; + } +} diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java index 35dc1775cb7f..2b1742592fba 100644 --- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java +++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java @@ -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(); diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java index 82e6279772f4..f429c2c124b3 100644 --- a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java +++ b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java @@ -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(); 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 95fbfe33ee71..a9ad464867a2 100644 --- a/packages/SystemUI/src/com/android/systemui/bubbles/animation/ExpandedAnimationController.java +++ b/packages/SystemUI/src/com/android/systemui/bubbles/animation/ExpandedAnimationController.java @@ -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); } diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayout.java b/packages/SystemUI/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayout.java index 460652612593..997d2c4627d8 100644 --- a/packages/SystemUI/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayout.java +++ b/packages/SystemUI/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayout.java @@ -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<>(); @@ -687,6 +712,24 @@ public class PhysicsAnimationLayout extends FrameLayout { } /** + * 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); } diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/animation/StackAnimationController.java b/packages/SystemUI/src/com/android/systemui/bubbles/animation/StackAnimationController.java index bc249aedc605..f937525cf417 100644 --- a/packages/SystemUI/src/com/android/systemui/bubbles/animation/StackAnimationController.java +++ b/packages/SystemUI/src/com/android/systemui/bubbles/animation/StackAnimationController.java @@ -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 @@ -594,32 +731,6 @@ public class StackAnimationController extends } /** - * 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 * cancels animations started from {@link #springFirstBubbleWithStackFollowing} and diff --git a/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/ExpandedAnimationControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/ExpandedAnimationControllerTest.java index cd8480505c04..567d192073b2 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/ExpandedAnimationControllerTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/ExpandedAnimationControllerTest.java @@ -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)); diff --git a/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayoutTestCase.java b/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayoutTestCase.java index 9fce092ef7ce..a398fba008bb 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayoutTestCase.java +++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/PhysicsAnimationLayoutTestCase.java @@ -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)); } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/StackAnimationControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/StackAnimationControllerTest.java index 910cee3574dd..b83276bc93da 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/StackAnimationControllerTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/bubbles/animation/StackAnimationControllerTest.java @@ -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)); + } } } |