OSDN Git Service

Make the flyout dismissable with a gesture.
authorJoshua Tsuji <tsuji@google.com>
Thu, 2 May 2019 17:13:16 +0000 (13:13 -0400)
committerJoshua Tsuji <tsuji@google.com>
Mon, 13 May 2019 22:47:54 +0000 (18:47 -0400)
This adds the flyout-to-dot transition, and encapulates the flyout logic in a custom view. This also adds support for the 'new' dot changing sides (previously unimplemented) and animates it changing sides when the stack changes sides.

Test: atest SystemUITests
Fixes: 129768381
Bugs: 129768381
Change-Id: I90697b437cf14e5c5a211c87ed2c131a12e697e7

12 files changed:
packages/SystemUI/res/drawable/bubble_flyout.xml [deleted file]
packages/SystemUI/res/layout/bubble_flyout.xml
packages/SystemUI/src/com/android/systemui/bubbles/BadgeRenderer.java
packages/SystemUI/src/com/android/systemui/bubbles/BadgedImageView.java
packages/SystemUI/src/com/android/systemui/bubbles/Bubble.java
packages/SystemUI/src/com/android/systemui/bubbles/BubbleFlyoutView.java [new file with mode: 0644]
packages/SystemUI/src/com/android/systemui/bubbles/BubbleStackView.java
packages/SystemUI/src/com/android/systemui/bubbles/BubbleTouchHandler.java
packages/SystemUI/src/com/android/systemui/bubbles/BubbleView.java
packages/SystemUI/src/com/android/systemui/bubbles/animation/StackAnimationController.java
packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleFlyoutViewTest.java [new file with mode: 0644]
packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleStackViewTest.java [deleted file]

diff --git a/packages/SystemUI/res/drawable/bubble_flyout.xml b/packages/SystemUI/res/drawable/bubble_flyout.xml
deleted file mode 100644 (file)
index afe5372..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-<!--
-  ~ 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
-  -->
-<layer-list xmlns:android="http://schemas.android.com/apk/res/android" >
-    <item>
-        <shape android:shape="rectangle">
-            <solid android:color="?android:attr/colorBackgroundFloating" />
-            <corners
-                android:bottomLeftRadius="?android:attr/dialogCornerRadius"
-                android:topLeftRadius="?android:attr/dialogCornerRadius"
-                android:bottomRightRadius="?android:attr/dialogCornerRadius"
-                android:topRightRadius="?android:attr/dialogCornerRadius" />
-            <padding
-                android:left="@dimen/bubble_flyout_pointer_size"
-                android:right="@dimen/bubble_flyout_pointer_size" />
-        </shape>
-    </item>
-</layer-list>
\ No newline at end of file
index 0e4d298..5f773f4 100644 (file)
   ~ See the License for the specific language governing permissions and
   ~ limitations under the License
   -->
-<FrameLayout
-    xmlns:android="http://schemas.android.com/apk/res/android"
-    android:layout_width="wrap_content"
-    android:layout_height="wrap_content"
-    android:paddingLeft="@dimen/bubble_flyout_pointer_size"
-    android:paddingRight="@dimen/bubble_flyout_pointer_size">
+<merge xmlns:android="http://schemas.android.com/apk/res/android">
 
     <FrameLayout
-        android:id="@+id/bubble_flyout"
+        android:id="@+id/bubble_flyout_text_container"
         android:layout_height="wrap_content"
         android:layout_width="wrap_content"
-        android:background="@drawable/bubble_flyout"
+        android:clipToPadding="false"
         android:paddingLeft="@dimen/bubble_flyout_padding_x"
         android:paddingRight="@dimen/bubble_flyout_padding_x"
         android:paddingTop="@dimen/bubble_flyout_padding_y"
@@ -41,4 +36,4 @@
 
     </FrameLayout>
 
-</FrameLayout>
\ No newline at end of file
+</merge>
\ No newline at end of file
index 845b084..74ad0fa 100644 (file)
@@ -18,12 +18,15 @@ package com.android.systemui.bubbles;
 import static android.graphics.Paint.ANTI_ALIAS_FLAG;
 import static android.graphics.Paint.FILTER_BITMAP_FLAG;
 
+import android.content.Context;
 import android.graphics.Canvas;
 import android.graphics.Paint;
 import android.graphics.Point;
 import android.graphics.Rect;
 import android.util.Log;
 
+import com.android.systemui.R;
+
 // XXX: Mostly opied from launcher code / can we share?
 /**
  * Contains parameters necessary to draw a badge for an icon (e.g. the size of the badge).
@@ -32,20 +35,31 @@ public class BadgeRenderer {
 
     private static final String TAG = "BadgeRenderer";
 
-    // The badge sizes are defined as percentages of the app icon size.
+    /** The badge sizes are defined as percentages of the app icon size. */
     private static final float SIZE_PERCENTAGE = 0.38f;
 
-    // Extra scale down of the dot
+    /** Extra scale down of the dot. */
     private static final float DOT_SCALE = 0.6f;
 
     private final float mDotCenterOffset;
     private final float mCircleRadius;
     private final Paint mCirclePaint = new Paint(ANTI_ALIAS_FLAG | FILTER_BITMAP_FLAG);
 
-    public BadgeRenderer(int iconSizePx) {
-        mDotCenterOffset = SIZE_PERCENTAGE * iconSizePx;
-        int size = (int) (DOT_SCALE * mDotCenterOffset);
-        mCircleRadius = size / 2f;
+    public BadgeRenderer(Context context) {
+        mDotCenterOffset = getDotCenterOffset(context);
+        mCircleRadius = getDotRadius(mDotCenterOffset);
+    }
+
+    /** Space between the center of the dot and the top or left of the bubble stack. */
+    static float getDotCenterOffset(Context context) {
+        final int iconSizePx =
+                context.getResources().getDimensionPixelSize(R.dimen.individual_bubble_size);
+        return SIZE_PERCENTAGE * iconSizePx;
+    }
+
+    static float getDotRadius(float dotCenterOffset) {
+        int size = (int) (DOT_SCALE * dotCenterOffset);
+        return size / 2f;
     }
 
     /**
index f15e8e4..783780f 100644 (file)
@@ -57,7 +57,7 @@ public class BadgedImageView extends ImageView {
             int defStyleRes) {
         super(context, attrs, defStyleAttr, defStyleRes);
         mIconSize = getResources().getDimensionPixelSize(R.dimen.individual_bubble_size);
-        mDotRenderer = new BadgeRenderer(mIconSize);
+        mDotRenderer = new BadgeRenderer(getContext());
 
         TypedArray ta = context.obtainStyledAttributes(
                 new int[] {android.R.attr.colorBackgroundFloating});
@@ -83,6 +83,10 @@ public class BadgedImageView extends ImageView {
         invalidate();
     }
 
+    public boolean getDotPosition() {
+        return mOnLeft;
+    }
+
     /**
      * Set whether the dot should show or not.
      */
index ac4a93b..8aad0f8 100644 (file)
@@ -83,7 +83,7 @@ class Bubble {
 
     public void updateDotVisibility() {
         if (iconView != null) {
-            iconView.updateDotVisibility();
+            iconView.updateDotVisibility(true /* animate */);
         }
     }
 
diff --git a/packages/SystemUI/src/com/android/systemui/bubbles/BubbleFlyoutView.java b/packages/SystemUI/src/com/android/systemui/bubbles/BubbleFlyoutView.java
new file mode 100644 (file)
index 0000000..71f68c1
--- /dev/null
@@ -0,0 +1,412 @@
+/*
+ * 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.graphics.Paint.ANTI_ALIAS_FLAG;
+import static android.graphics.Paint.FILTER_BITMAP_FLAG;
+
+import android.animation.ArgbEvaluator;
+import android.content.Context;
+import android.content.res.Resources;
+import android.content.res.TypedArray;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Matrix;
+import android.graphics.Outline;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.PointF;
+import android.graphics.RectF;
+import android.graphics.drawable.ShapeDrawable;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewOutlineProvider;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+
+import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.dynamicanimation.animation.SpringAnimation;
+
+import com.android.systemui.R;
+import com.android.systemui.recents.TriangleShape;
+
+/**
+ * Flyout view that appears as a 'chat bubble' alongside the bubble stack. The flyout can visually
+ * transform into the 'new' dot, which is used during flyout dismiss animations/gestures.
+ */
+public class BubbleFlyoutView extends FrameLayout {
+    /** Max width of the flyout, in terms of percent of the screen width. */
+    private static final float FLYOUT_MAX_WIDTH_PERCENT = .6f;
+
+    private final int mFlyoutPadding;
+    private final int mFlyoutSpaceFromBubble;
+    private final int mPointerSize;
+    private final int mBubbleSize;
+    private final int mFlyoutElevation;
+    private final int mBubbleElevation;
+    private final int mFloatingBackgroundColor;
+    private final float mCornerRadius;
+
+    private final ViewGroup mFlyoutTextContainer;
+    private final TextView mFlyoutText;
+    /** Spring animation for the flyout. */
+    private final SpringAnimation mFlyoutSpring =
+            new SpringAnimation(this, DynamicAnimation.TRANSLATION_X);
+
+    /** Values related to the 'new' dot which we use to figure out where to collapse the flyout. */
+    private final float mNewDotRadius;
+    private final float mNewDotSize;
+    private final float mNewDotOffsetFromBubbleBounds;
+
+    /**
+     * The paint used to draw the background, whose color changes as the flyout transitions to the
+     * tinted 'new' dot.
+     */
+    private final Paint mBgPaint = new Paint(ANTI_ALIAS_FLAG | FILTER_BITMAP_FLAG);
+    private final ArgbEvaluator mArgbEvaluator = new ArgbEvaluator();
+
+    /**
+     * Triangular ShapeDrawables used for the triangle that points from the flyout to the bubble
+     * stack (a chat-bubble effect).
+     */
+    private final ShapeDrawable mLeftTriangleShape;
+    private final ShapeDrawable mRightTriangleShape;
+
+    /** Whether the flyout arrow is on the left (pointing left) or right (pointing right). */
+    private boolean mArrowPointingLeft = true;
+
+    /** Color of the 'new' dot that the flyout will transform into. */
+    private int mDotColor;
+
+    /** The outline of the triangle, used for elevation shadows. */
+    private final Outline mTriangleOutline = new Outline();
+
+    /** The bounds of the flyout background, kept up to date as it transitions to the 'new' dot. */
+    private final RectF mBgRect = new RectF();
+
+    /**
+     * Percent progress in the transition from flyout to 'new' dot. These two values are the inverse
+     * of each other (if we're 40% transitioned to the dot, we're 60% flyout), but it makes the code
+     * much more readable.
+     */
+    private float mPercentTransitionedToDot = 1f;
+    private float mPercentStillFlyout = 0f;
+
+    /**
+     * The difference in values between the flyout and the dot. These differences are gradually
+     * added over the course of the animation to transform the flyout into the 'new' dot.
+     */
+    private float mFlyoutToDotWidthDelta = 0f;
+    private float mFlyoutToDotHeightDelta = 0f;
+    private float mFlyoutToDotCornerRadiusDelta;
+
+    /** The translation values when the flyout is completely transitioned into the dot. */
+    private float mTranslationXWhenDot = 0f;
+    private float mTranslationYWhenDot = 0f;
+
+    /**
+     * The current translation values applied to the flyout background as it transitions into the
+     * 'new' dot.
+     */
+    private float mBgTranslationX;
+    private float mBgTranslationY;
+
+    /** The flyout's X translation when at rest (not animating or dragging). */
+    private float mRestingTranslationX = 0f;
+
+    /** Callback to run when the flyout is hidden. */
+    private Runnable mOnHide;
+
+    public BubbleFlyoutView(Context context) {
+        super(context);
+        LayoutInflater.from(context).inflate(R.layout.bubble_flyout, this, true);
+
+        mFlyoutTextContainer = findViewById(R.id.bubble_flyout_text_container);
+        mFlyoutText = mFlyoutTextContainer.findViewById(R.id.bubble_flyout_text);
+
+        final Resources res = getResources();
+        mFlyoutPadding = res.getDimensionPixelSize(R.dimen.bubble_flyout_padding_x);
+        mFlyoutSpaceFromBubble = res.getDimensionPixelSize(R.dimen.bubble_flyout_space_from_bubble);
+        mPointerSize = res.getDimensionPixelSize(R.dimen.bubble_flyout_pointer_size);
+        mBubbleSize = res.getDimensionPixelSize(R.dimen.individual_bubble_size);
+        mBubbleElevation = res.getDimensionPixelSize(R.dimen.bubble_elevation);
+        mFlyoutElevation = res.getDimensionPixelSize(R.dimen.bubble_flyout_elevation);
+        mNewDotOffsetFromBubbleBounds = BadgeRenderer.getDotCenterOffset(context);
+        mNewDotRadius = BadgeRenderer.getDotRadius(mNewDotOffsetFromBubbleBounds);
+        mNewDotSize = mNewDotRadius * 2f;
+
+        final TypedArray ta = mContext.obtainStyledAttributes(
+                new int[] {
+                        android.R.attr.colorBackgroundFloating,
+                        android.R.attr.dialogCornerRadius});
+        mFloatingBackgroundColor = ta.getColor(0, Color.WHITE);
+        mCornerRadius = ta.getDimensionPixelSize(1, 0);
+        mFlyoutToDotCornerRadiusDelta = mNewDotRadius - mCornerRadius;
+        ta.recycle();
+
+        // Add padding for the pointer on either side, onDraw will draw it in this space.
+        setPadding(mPointerSize, 0, mPointerSize, 0);
+        setWillNotDraw(false);
+        setClipChildren(false);
+        setTranslationZ(mFlyoutElevation);
+        setOutlineProvider(new ViewOutlineProvider() {
+            @Override
+            public void getOutline(View view, Outline outline) {
+                BubbleFlyoutView.this.getOutline(outline);
+            }
+        });
+
+        mBgPaint.setColor(mFloatingBackgroundColor);
+
+        mLeftTriangleShape =
+                new ShapeDrawable(TriangleShape.createHorizontal(
+                        mPointerSize, mPointerSize, true /* isPointingLeft */));
+        mLeftTriangleShape.setBounds(0, 0, mPointerSize, mPointerSize);
+        mLeftTriangleShape.getPaint().setColor(mFloatingBackgroundColor);
+
+        mRightTriangleShape =
+                new ShapeDrawable(TriangleShape.createHorizontal(
+                        mPointerSize, mPointerSize, false /* isPointingLeft */));
+        mRightTriangleShape.setBounds(0, 0, mPointerSize, mPointerSize);
+        mRightTriangleShape.getPaint().setColor(mFloatingBackgroundColor);
+    }
+
+    @Override
+    protected void onDraw(Canvas canvas) {
+        renderBackground(canvas);
+        invalidateOutline();
+        super.onDraw(canvas);
+    }
+
+    /** Configures the flyout and animates it in. */
+    void showFlyout(
+            CharSequence updateMessage, PointF stackPos, float parentWidth,
+            boolean arrowPointingLeft, int dotColor, Runnable onHide) {
+        mArrowPointingLeft = arrowPointingLeft;
+        mDotColor = dotColor;
+        mOnHide = onHide;
+
+        setCollapsePercent(0f);
+        setAlpha(0f);
+        setVisibility(VISIBLE);
+
+        // Set the flyout TextView's max width in terms of percent, and then subtract out the
+        // padding so that the entire flyout view will be the desired width (rather than the
+        // TextView being the desired width + extra padding).
+        mFlyoutText.setMaxWidth(
+                (int) (parentWidth * FLYOUT_MAX_WIDTH_PERCENT) - mFlyoutPadding * 2);
+        mFlyoutText.setText(updateMessage);
+
+        // Wait for the TextView to lay out so we know its line count.
+        post(() -> {
+            // Multi line flyouts get top-aligned to the bubble.
+            if (mFlyoutText.getLineCount() > 1) {
+                setTranslationY(stackPos.y);
+            } else {
+                // Single line flyouts are vertically centered with respect to the bubble.
+                setTranslationY(
+                        stackPos.y + (mBubbleSize - mFlyoutTextContainer.getHeight()) / 2f);
+            }
+
+            // Calculate the translation required to position the flyout next to the bubble stack,
+            // with the desired padding.
+            mRestingTranslationX = mArrowPointingLeft
+                    ? stackPos.x + mBubbleSize + mFlyoutSpaceFromBubble
+                    : stackPos.x - getWidth() - mFlyoutSpaceFromBubble;
+
+            // Translate towards the stack slightly.
+            setTranslationX(
+                    mRestingTranslationX + (arrowPointingLeft ? -mBubbleSize : mBubbleSize));
+
+            // Fade in the entire flyout and spring it to its normal position.
+            animate().alpha(1f);
+            mFlyoutSpring.animateToFinalPosition(mRestingTranslationX);
+
+            // Calculate the difference in size between the flyout and the 'dot' so that we can
+            // transform into the dot later.
+            mFlyoutToDotWidthDelta = getWidth() - mNewDotSize;
+            mFlyoutToDotHeightDelta = getHeight() - mNewDotSize;
+
+            // Calculate the translation values needed to be in the correct 'new dot' position.
+            final float distanceFromFlyoutLeftToDotCenterX =
+                    mFlyoutSpaceFromBubble + mNewDotOffsetFromBubbleBounds / 2;
+            if (mArrowPointingLeft) {
+                mTranslationXWhenDot = -distanceFromFlyoutLeftToDotCenterX - mNewDotRadius;
+            } else {
+                mTranslationXWhenDot =
+                        getWidth() + distanceFromFlyoutLeftToDotCenterX - mNewDotRadius;
+            }
+
+            mTranslationYWhenDot =
+                    getHeight() / 2f
+                            - mNewDotRadius
+                            - mBubbleSize / 2f
+                            + mNewDotOffsetFromBubbleBounds / 2;
+        });
+    }
+
+    /**
+     * Hides the flyout and runs the optional callback passed into showFlyout. The flyout has been
+     * animated into the 'new' dot by the time we call this, so no animations are needed.
+     */
+    void hideFlyout() {
+        if (mOnHide != null) {
+            mOnHide.run();
+            mOnHide = null;
+        }
+
+        setVisibility(GONE);
+    }
+
+    /** Sets the percentage that the flyout should be collapsed into dot form. */
+    void setCollapsePercent(float percentCollapsed) {
+        mPercentTransitionedToDot = Math.max(0f, Math.min(percentCollapsed, 1f));
+        mPercentStillFlyout = (1f - mPercentTransitionedToDot);
+
+        // Move and fade out the text.
+        mFlyoutText.setTranslationX(
+                (mArrowPointingLeft ? -getWidth() : getWidth()) * mPercentTransitionedToDot);
+        mFlyoutText.setAlpha(clampPercentage(
+                (mPercentStillFlyout - (1f - BubbleStackView.FLYOUT_DRAG_PERCENT_DISMISS))
+                        / BubbleStackView.FLYOUT_DRAG_PERCENT_DISMISS));
+
+        // Reduce the elevation towards that of the topmost bubble.
+        setTranslationZ(
+                mFlyoutElevation
+                        - (mFlyoutElevation - mBubbleElevation) * mPercentTransitionedToDot);
+        invalidate();
+    }
+
+    /** Return the flyout's resting X translation (translation when not dragging or animating). */
+    float getRestingTranslationX() {
+        return mRestingTranslationX;
+    }
+
+    /** Clamps a float to between 0 and 1. */
+    private float clampPercentage(float percent) {
+        return Math.min(1f, Math.max(0f, percent));
+    }
+
+    /**
+     * Renders the background, which is either the rounded 'chat bubble' flyout, or some state
+     * between that and the 'new' dot over the bubbles.
+     */
+    private void renderBackground(Canvas canvas) {
+        // Calculate the width, height, and corner radius of the flyout given the current collapsed
+        // percentage.
+        final float width = getWidth() - (mFlyoutToDotWidthDelta * mPercentTransitionedToDot);
+        final float height = getHeight() - (mFlyoutToDotHeightDelta * mPercentTransitionedToDot);
+        final float cornerRadius = mCornerRadius
+                - (mFlyoutToDotCornerRadiusDelta * mPercentTransitionedToDot);
+
+        // Translate the flyout background towards the collapsed 'dot' state.
+        mBgTranslationX = mTranslationXWhenDot * mPercentTransitionedToDot;
+        mBgTranslationY = mTranslationYWhenDot * mPercentTransitionedToDot;
+
+        // Set the bounds of the rounded rectangle that serves as either the flyout background or
+        // the collapsed 'dot'. These bounds will also be used to provide the outline for elevation
+        // shadows. In the expanded flyout state, the left and right bounds leave space for the
+        // pointer triangle - as the flyout collapses, this space is reduced since the triangle
+        // retracts into the flyout.
+        mBgRect.set(
+                mPointerSize * mPercentStillFlyout /* left */,
+                0 /* top */,
+                width - mPointerSize * mPercentStillFlyout /* right */,
+                height /* bottom */);
+
+        mBgPaint.setColor(
+                (int) mArgbEvaluator.evaluate(
+                        mPercentTransitionedToDot, mFloatingBackgroundColor, mDotColor));
+
+        canvas.save();
+        canvas.translate(mBgTranslationX, mBgTranslationY);
+        renderPointerTriangle(canvas, width, height);
+        canvas.drawRoundRect(mBgRect, cornerRadius, cornerRadius, mBgPaint);
+        canvas.restore();
+    }
+
+    /** Renders the 'pointer' triangle that points from the flyout to the bubble stack. */
+    private void renderPointerTriangle(
+            Canvas canvas, float currentFlyoutWidth, float currentFlyoutHeight) {
+        canvas.save();
+
+        // Translation to apply for the 'retraction' effect as the flyout collapses.
+        final float retractionTranslationX =
+                (mArrowPointingLeft ? 1 : -1) * (mPercentTransitionedToDot * mPointerSize * 2f);
+
+        // Place the arrow either at the left side, or the far right, depending on whether the
+        // flyout is on the left or right side.
+        final float arrowTranslationX =
+                mArrowPointingLeft
+                        ? retractionTranslationX
+                        : currentFlyoutWidth - mPointerSize + retractionTranslationX;
+
+        // Vertically center the arrow at all times.
+        final float arrowTranslationY = currentFlyoutHeight / 2f - mPointerSize / 2f;
+
+        // Draw the appropriate direction of arrow.
+        final ShapeDrawable relevantTriangle =
+                mArrowPointingLeft ? mLeftTriangleShape : mRightTriangleShape;
+        canvas.translate(arrowTranslationX, arrowTranslationY);
+        relevantTriangle.setAlpha((int) (255f * mPercentStillFlyout));
+        relevantTriangle.draw(canvas);
+
+        // Save the triangle's outline for use in the outline provider, offsetting it to reflect its
+        // current position.
+        relevantTriangle.getOutline(mTriangleOutline);
+        mTriangleOutline.offset((int) arrowTranslationX, (int) arrowTranslationY);
+
+        canvas.restore();
+    }
+
+    /** Builds an outline that includes the transformed flyout background and triangle. */
+    private void getOutline(Outline outline) {
+        if (!mTriangleOutline.isEmpty()) {
+            // Draw the rect into the outline as a path so we can merge the triangle path into it.
+            final Path rectPath = new Path();
+            rectPath.addRoundRect(mBgRect, mCornerRadius, mCornerRadius, Path.Direction.CW);
+            outline.setConvexPath(rectPath);
+
+            // Get rid of the triangle path once it has disappeared behind the flyout.
+            if (mPercentStillFlyout > 0.5f) {
+                outline.mPath.addPath(mTriangleOutline.mPath);
+            }
+
+            // Translate the outline to match the background's position.
+            final Matrix outlineMatrix = new Matrix();
+            outlineMatrix.postTranslate(getLeft() + mBgTranslationX, getTop() + mBgTranslationY);
+
+            // At the very end, retract the outline into the bubble so the shadow will be pulled
+            // into the flyout-dot as it (visually) becomes part of the bubble. We can't do this by
+            // animating translationZ to zero since then it'll go under the bubbles, which have
+            // elevation.
+            if (mPercentTransitionedToDot > 0.98f) {
+                final float percentBetween99and100 = (mPercentTransitionedToDot - 0.98f) / .02f;
+                final float percentShadowVisible = 1f - percentBetween99and100;
+
+                // Keep it centered.
+                outlineMatrix.postTranslate(
+                        mNewDotRadius * percentBetween99and100,
+                        mNewDotRadius * percentBetween99and100);
+                outlineMatrix.preScale(percentShadowVisible, percentShadowVisible);
+            }
+
+            outline.mPath.transform(outlineMatrix);
+        }
+    }
+}
index 2b17425..4fef157 100644 (file)
@@ -25,8 +25,6 @@ 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;
@@ -35,8 +33,6 @@ import android.graphics.Point;
 import android.graphics.PointF;
 import android.graphics.Rect;
 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;
@@ -56,11 +52,11 @@ import android.view.accessibility.AccessibilityNodeInfo;
 import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
 import android.view.animation.AccelerateDecelerateInterpolator;
 import android.widget.FrameLayout;
-import android.widget.TextView;
 
 import androidx.annotation.MainThread;
 import androidx.annotation.Nullable;
 import androidx.dynamicanimation.animation.DynamicAnimation;
+import androidx.dynamicanimation.animation.FloatPropertyCompat;
 import androidx.dynamicanimation.animation.SpringAnimation;
 import androidx.dynamicanimation.animation.SpringForce;
 
@@ -70,7 +66,6 @@ import com.android.systemui.R;
 import com.android.systemui.bubbles.animation.ExpandedAnimationController;
 import com.android.systemui.bubbles.animation.PhysicsAnimationLayout;
 import com.android.systemui.bubbles.animation.StackAnimationController;
-import com.android.systemui.recents.TriangleShape;
 import com.android.systemui.statusbar.notification.collection.NotificationEntry;
 
 import java.math.BigDecimal;
@@ -86,12 +81,21 @@ public class BubbleStackView extends FrameLayout {
     private static final String TAG = "BubbleStackView";
     private static final boolean DEBUG = false;
 
+    /** How far the flyout needs to be dragged before it's dismissed regardless of velocity. */
+    static final float FLYOUT_DRAG_PERCENT_DISMISS = 0.25f;
+
+    /** Velocity required to dismiss the flyout via drag. */
+    private static final float FLYOUT_DISMISS_VELOCITY = 2000f;
+
+    /**
+     * Factor for attenuating translation when the flyout is overscrolled (8f = flyout moves 1 pixel
+     * for every 8 pixels overscrolled).
+     */
+    private static final float FLYOUT_OVERSCROLL_ATTENUATION_FACTOR = 8f;
+
     /** Duration of the flyout alpha animations. */
     private static final int FLYOUT_ALPHA_ANIMATION_DURATION = 100;
 
-    /** 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;
 
@@ -152,17 +156,9 @@ public class BubbleStackView extends FrameLayout {
 
     private FrameLayout mExpandedViewContainer;
 
-    private FrameLayout mFlyoutContainer;
-    private FrameLayout mFlyout;
-    private TextView mFlyoutText;
-    private ShapeDrawable mLeftFlyoutTriangle;
-    private ShapeDrawable mRightFlyoutTriangle;
-    /** Spring animation for the flyout. */
-    private SpringAnimation mFlyoutSpring;
+    private BubbleFlyoutView mFlyout;
     /** Runnable that fades out the flyout and then sets it to GONE. */
-    private Runnable mHideFlyout =
-            () -> mFlyoutContainer.animate().alpha(0f).withEndAction(
-                    () -> mFlyoutContainer.setVisibility(GONE));
+    private Runnable mHideFlyout = () -> animateFlyoutCollapsed(true, 0 /* velX */);
 
     /** Layout change listener that moves the stack to the nearest valid position on rotation. */
     private OnLayoutChangeListener mMoveStackToValidPositionOnLayoutListener;
@@ -176,9 +172,6 @@ public class BubbleStackView extends FrameLayout {
 
     private int mBubbleSize;
     private int mBubblePadding;
-    private int mFlyoutPadding;
-    private int mFlyoutSpaceFromBubble;
-    private int mPointerSize;
     private int mExpandedAnimateXDistance;
     private int mExpandedAnimateYDistance;
     private int mStatusBarHeight;
@@ -189,8 +182,11 @@ public class BubbleStackView extends FrameLayout {
     private boolean mIsExpanded;
     private boolean mImeVisible;
 
-    /** Whether the stack is currently being dragged. */
-    private boolean mIsDragging = false;
+    /** Whether the stack is currently on the left side of the screen, or animating there. */
+    private boolean mStackOnLeftOrWillBe = false;
+
+    /** Whether a touch gesture, such as a stack/bubble drag or flyout drag, is in progress. */
+    private boolean mIsGestureInProgress = false;
 
     private BubbleTouchHandler mTouchHandler;
     private BubbleController.BubbleExpandListener mExpandListener;
@@ -249,6 +245,40 @@ public class BubbleStackView extends FrameLayout {
         }
     };
 
+    /** Float property that 'drags' the flyout. */
+    private final FloatPropertyCompat mFlyoutCollapseProperty =
+            new FloatPropertyCompat("FlyoutCollapseSpring") {
+                @Override
+                public float getValue(Object o) {
+                    return mFlyoutDragDeltaX;
+                }
+
+                @Override
+                public void setValue(Object o, float v) {
+                    onFlyoutDragged(v);
+                }
+            };
+
+    /** SpringAnimation that springs the flyout collapsed via onFlyoutDragged. */
+    private final SpringAnimation mFlyoutTransitionSpring =
+            new SpringAnimation(this, mFlyoutCollapseProperty);
+
+    /** Distance the flyout has been dragged in the X axis. */
+    private float mFlyoutDragDeltaX = 0f;
+
+    /**
+     * End listener for the flyout spring that either posts a runnable to hide the flyout, or hides
+     * it immediately.
+     */
+    private final DynamicAnimation.OnAnimationEndListener mAfterFlyoutTransitionSpring =
+            (dynamicAnimation, b, v, v1) -> {
+                if (mFlyoutDragDeltaX == 0) {
+                    mFlyout.postDelayed(mHideFlyout, FLYOUT_HIDE_AFTER);
+                } else {
+                    mFlyout.hideFlyout();
+                }
+            };
+
     @NonNull private final SurfaceSynchronizer mSurfaceSynchronizer;
 
     private BubbleDismissView mDismissContainer;
@@ -267,9 +297,6 @@ public class BubbleStackView extends FrameLayout {
         Resources res = getResources();
         mBubbleSize = res.getDimensionPixelSize(R.dimen.individual_bubble_size);
         mBubblePadding = res.getDimensionPixelSize(R.dimen.bubble_padding);
-        mFlyoutPadding = res.getDimensionPixelSize(R.dimen.bubble_flyout_padding_x);
-        mFlyoutSpaceFromBubble = res.getDimensionPixelSize(R.dimen.bubble_flyout_space_from_bubble);
-        mPointerSize = res.getDimensionPixelSize(R.dimen.bubble_flyout_pointer_size);
         mExpandedAnimateXDistance =
                 res.getDimensionPixelSize(R.dimen.bubble_expanded_animate_x_distance);
         mExpandedAnimateYDistance =
@@ -307,17 +334,24 @@ public class BubbleStackView extends FrameLayout {
         mExpandedViewContainer.setClipChildren(false);
         addView(mExpandedViewContainer);
 
-        mFlyoutContainer = (FrameLayout) mInflater.inflate(R.layout.bubble_flyout, this, false);
-        mFlyoutContainer.setVisibility(GONE);
-        mFlyoutContainer.setClipToPadding(false);
-        mFlyoutContainer.setClipChildren(false);
-        mFlyoutContainer.animate()
+        mFlyout = new BubbleFlyoutView(context);
+        mFlyout.setVisibility(GONE);
+        mFlyout.animate()
                 .setDuration(FLYOUT_ALPHA_ANIMATION_DURATION)
                 .setInterpolator(new AccelerateDecelerateInterpolator());
+        addView(mFlyout, new FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT));
+
+        mFlyoutTransitionSpring.setSpring(new SpringForce()
+                .setStiffness(SpringForce.STIFFNESS_MEDIUM)
+                .setDampingRatio(SpringForce.DAMPING_RATIO_LOW_BOUNCY));
+        mFlyoutTransitionSpring.addEndListener(mAfterFlyoutTransitionSpring);
 
-        mFlyout = mFlyoutContainer.findViewById(R.id.bubble_flyout);
-        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);
 
         mDismissContainer = new BubbleDismissView(mContext);
         mDismissContainer.setLayoutParams(new FrameLayout.LayoutParams(
@@ -742,7 +776,7 @@ public class BubbleStackView extends FrameLayout {
             }
             // Outside parts of view we care about.
             return null;
-        } else if (mFlyoutContainer.getVisibility() == VISIBLE && isIntersecting(mFlyout, x, y)) {
+        } else if (mFlyout.getVisibility() == VISIBLE && isIntersecting(mFlyout, x, y)) {
             return mFlyout;
         }
 
@@ -931,7 +965,6 @@ public class BubbleStackView extends FrameLayout {
         mBubbleContainer.setController(mStackAnimationController);
         hideFlyoutImmediate();
 
-        mIsDragging = true;
         mDraggingInDismissTarget = false;
     }
 
@@ -948,20 +981,87 @@ public class BubbleStackView extends FrameLayout {
         if (DEBUG) {
             Log.d(TAG, "onDragFinish");
         }
-        // TODO: Add fling to bottom to dismiss.
-        mIsDragging = false;
 
         if (mIsExpanded || mIsExpansionAnimating) {
             return;
         }
 
-        mStackAnimationController.flingStackThenSpringToEdge(x, velX, velY);
+        final float newStackX = mStackAnimationController.flingStackThenSpringToEdge(x, velX, velY);
         logBubbleEvent(null /* no bubble associated with bubble stack move */,
                 StatsLog.BUBBLE_UICHANGED__ACTION__STACK_MOVED);
 
+        mStackOnLeftOrWillBe = newStackX <= 0;
+        updateBubbleShadowsAndDotPosition(true /* animate */);
         springOutDismissTargetAndHideCircle();
     }
 
+    void onFlyoutDragStart() {
+        mFlyout.removeCallbacks(mHideFlyout);
+    }
+
+    void onFlyoutDragged(float deltaX) {
+        final boolean onLeft = mStackAnimationController.isStackOnLeftSide();
+        mFlyoutDragDeltaX = deltaX;
+
+        final float collapsePercent =
+                onLeft ? -deltaX / mFlyout.getWidth() : deltaX / mFlyout.getWidth();
+        mFlyout.setCollapsePercent(Math.min(1f, Math.max(0f, collapsePercent)));
+
+        // Calculate how to translate the flyout if it has been dragged too far in etiher direction.
+        float overscrollTranslation = 0f;
+        if (collapsePercent < 0f || collapsePercent > 1f) {
+            // Whether we are more than 100% transitioned to the dot.
+            final boolean overscrollingPastDot = collapsePercent > 1f;
+
+            // Whether we are overscrolling physically to the left - this can either be pulling the
+            // flyout away from the stack (if the stack is on the right) or pushing it to the left
+            // after it has already become the dot.
+            final boolean overscrollingLeft =
+                    (onLeft && collapsePercent > 1f) || (!onLeft && collapsePercent < 0f);
+
+            overscrollTranslation =
+                    (overscrollingPastDot ? collapsePercent - 1f : collapsePercent * -1)
+                            * (overscrollingLeft ? -1 : 1)
+                            * (mFlyout.getWidth() / (FLYOUT_OVERSCROLL_ATTENUATION_FACTOR
+                                // Attenuate the smaller dot less than the larger flyout.
+                                / (overscrollingPastDot ? 2 : 1)));
+        }
+
+        mFlyout.setTranslationX(mFlyout.getRestingTranslationX() + overscrollTranslation);
+    }
+
+    /**
+     * Called when the flyout drag has finished, and returns true if the gesture successfully
+     * dismissed the flyout.
+     */
+    void onFlyoutDragFinished(float deltaX, float velX) {
+        final boolean onLeft = mStackAnimationController.isStackOnLeftSide();
+        final boolean metRequiredVelocity =
+                onLeft ? velX < -FLYOUT_DISMISS_VELOCITY : velX > FLYOUT_DISMISS_VELOCITY;
+        final boolean metRequiredDeltaX =
+                onLeft
+                        ? deltaX < -mFlyout.getWidth() * FLYOUT_DRAG_PERCENT_DISMISS
+                        : deltaX > mFlyout.getWidth() * FLYOUT_DRAG_PERCENT_DISMISS;
+        final boolean isCancelFling = onLeft ? velX > 0 : velX < 0;
+        final boolean shouldDismiss = metRequiredVelocity || (metRequiredDeltaX && !isCancelFling);
+
+        mFlyout.removeCallbacks(mHideFlyout);
+        animateFlyoutCollapsed(shouldDismiss, velX);
+    }
+
+    /**
+     * Called when the first touch event of a gesture (stack drag, bubble drag, flyout drag, etc.)
+     * is received.
+     */
+    void onGestureStart() {
+        mIsGestureInProgress = true;
+    }
+
+    /** Called when a gesture is completed or cancelled. */
+    void onGestureFinished() {
+        mIsGestureInProgress = false;
+    }
+
     /** Prepares and starts the desaturate/darken animation on the bubble stack. */
     private void animateDesaturateAndDarken(View targetView, boolean desaturateAndDarken) {
         mDesaturateAndDarkenTargetView = targetView;
@@ -1119,12 +1219,22 @@ public class BubbleStackView extends FrameLayout {
         mShowingDismiss = false;
     }
 
-
     /** Whether the location of the given MotionEvent is within the dismiss target area. */
-    public boolean isInDismissTarget(MotionEvent ev) {
+    boolean isInDismissTarget(MotionEvent ev) {
         return isIntersecting(mDismissContainer.getDismissTarget(), ev.getRawX(), ev.getRawY());
     }
 
+    /** Animates the flyout collapsed (to dot), or the reverse, starting with the given velocity. */
+    private void animateFlyoutCollapsed(boolean collapsed, float velX) {
+        final boolean onLeft = mStackAnimationController.isStackOnLeftSide();
+        mFlyoutTransitionSpring
+                .setStartValue(mFlyoutDragDeltaX)
+                .setStartVelocity(velX)
+                .animateToFinalPosition(collapsed
+                        ? (onLeft ? -mFlyout.getWidth() : mFlyout.getWidth())
+                        : 0f);
+    }
+
     /**
      * Calculates how large the expanded view of the bubble can be. This takes into account the
      * y position when the bubbles are expanded as well as the bounds of the dismiss target.
@@ -1161,55 +1271,27 @@ public class BubbleStackView extends FrameLayout {
         final CharSequence updateMessage = bubble.entry.getUpdateMessage(getContext());
 
         // Show the message if one exists, and we're not expanded or animating expansion.
-        if (updateMessage != null && !isExpanded() && !mIsExpansionAnimating && !mIsDragging) {
-            final PointF stackPos = mStackAnimationController.getStackPosition();
-
-            // Set the flyout TextView's max width in terms of percent, and then subtract out the
-            // padding so that the entire flyout view will be the desired width (rather than the
-            // TextView being the desired width + extra padding).
-            mFlyoutText.setMaxWidth(
-                    (int) (getWidth() * FLYOUT_MAX_WIDTH_PERCENT) - mFlyoutPadding * 2);
-
-            mFlyoutContainer.setAlpha(0f);
-            mFlyoutContainer.setVisibility(VISIBLE);
-
-            mFlyoutText.setText(updateMessage);
-
-            final boolean onLeft = mStackAnimationController.isStackOnLeftSide();
-
-            if (onLeft) {
-                mLeftFlyoutTriangle.setAlpha(255);
-                mRightFlyoutTriangle.setAlpha(0);
-            } else {
-                mLeftFlyoutTriangle.setAlpha(0);
-                mRightFlyoutTriangle.setAlpha(255);
+        if (updateMessage != null
+                && !isExpanded()
+                && !mIsExpansionAnimating
+                && !mIsGestureInProgress) {
+            if (bubble.iconView != null) {
+                bubble.iconView.setSuppressDot(true /* suppressDot */, false /* animate */);
+                mFlyoutDragDeltaX = 0f;
+                mFlyout.setAlpha(0f);
+
+                // Post in case layout isn't complete and getWidth returns 0.
+                post(() -> mFlyout.showFlyout(
+                        updateMessage, mStackAnimationController.getStackPosition(), getWidth(),
+                        mStackAnimationController.isStackOnLeftSide(),
+                        bubble.iconView.getBadgeColor(),
+                        () -> {
+                            bubble.iconView.setSuppressDot(
+                                    false /* suppressDot */, false /* animate */);
+                        }));
             }
-
-            mFlyoutContainer.post(() -> {
-                // Multi line flyouts get top-aligned to the bubble.
-                if (mFlyoutText.getLineCount() > 1) {
-                    mFlyoutContainer.setTranslationY(stackPos.y);
-                } else {
-                    // Single line flyouts are vertically centered with respect to the bubble.
-                    mFlyoutContainer.setTranslationY(
-                            stackPos.y + (mBubbleSize - mFlyout.getHeight()) / 2f);
-                }
-
-                final float destinationX = onLeft
-                        ? stackPos.x + mBubbleSize + mFlyoutSpaceFromBubble
-                        : stackPos.x - mFlyoutContainer.getWidth() - mFlyoutSpaceFromBubble;
-
-                // Translate towards the stack slightly, then spring out from the stack.
-                mFlyoutContainer.setTranslationX(
-                        destinationX + (onLeft ? -mBubblePadding : mBubblePadding));
-
-                mFlyoutContainer.animate().alpha(1f);
-                mFlyoutSpring.animateToFinalPosition(destinationX);
-
-                mFlyout.removeCallbacks(mHideFlyout);
-                mFlyout.postDelayed(mHideFlyout, FLYOUT_HIDE_AFTER);
-            });
-
+            mFlyout.removeCallbacks(mHideFlyout);
+            mFlyout.postDelayed(mHideFlyout, FLYOUT_HIDE_AFTER);
             logBubbleEvent(bubble, StatsLog.BUBBLE_UICHANGED__ACTION__FLYOUT);
         }
     }
@@ -1217,7 +1299,7 @@ public class BubbleStackView extends FrameLayout {
     /** Hide the flyout immediately and cancel any pending hide runnables. */
     private void hideFlyoutImmediate() {
         mFlyout.removeCallbacks(mHideFlyout);
-        mHideFlyout.run();
+        mFlyout.hideFlyout();
     }
 
     @Override
@@ -1230,7 +1312,7 @@ public class BubbleStackView extends FrameLayout {
             mBubbleContainer.getBoundsOnScreen(outRect);
         }
 
-        if (mFlyoutContainer.getVisibility() == View.VISIBLE) {
+        if (mFlyout.getVisibility() == View.VISIBLE) {
             final Rect flyoutBounds = new Rect();
             mFlyout.getBoundsOnScreen(flyoutBounds);
             outRect.union(flyoutBounds);
@@ -1287,78 +1369,11 @@ public class BubbleStackView extends FrameLayout {
         }
     }
 
-    /** Sets up the flyout views and drawables. */
-    private void setupFlyout() {
-        // Retrieve the styled floating background color.
-        TypedArray ta = mContext.obtainStyledAttributes(
-                new int[]{android.R.attr.colorBackgroundFloating});
-        final int floatingBackgroundColor = ta.getColor(0, Color.WHITE);
-        ta.recycle();
-
-        // Retrieve the flyout background, which is currently a rounded white rectangle with a
-        // shadow but no triangular arrow pointing anywhere.
-        final LayerDrawable flyoutBackground = (LayerDrawable) mFlyout.getBackground();
-
-        // Create the triangle drawables and set their color.
-        mLeftFlyoutTriangle =
-                new ShapeDrawable(TriangleShape.createHorizontal(
-                        mPointerSize, mPointerSize, true /* isPointingLeft */));
-        mRightFlyoutTriangle =
-                new ShapeDrawable(TriangleShape.createHorizontal(
-                        mPointerSize, mPointerSize, false /* isPointingLeft */));
-        mLeftFlyoutTriangle.getPaint().setColor(floatingBackgroundColor);
-        mRightFlyoutTriangle.getPaint().setColor(floatingBackgroundColor);
-
-        // Add both triangles to the drawable. We'll show and hide the appropriate ones when we show
-        // the flyout.
-        final int leftTriangleIndex = flyoutBackground.addLayer(mLeftFlyoutTriangle);
-        flyoutBackground.setLayerSize(leftTriangleIndex, mPointerSize, mPointerSize);
-        flyoutBackground.setLayerGravity(leftTriangleIndex, Gravity.LEFT | Gravity.CENTER_VERTICAL);
-        flyoutBackground.setLayerInsetLeft(leftTriangleIndex, -mPointerSize);
-
-        final int rightTriangleIndex = flyoutBackground.addLayer(mRightFlyoutTriangle);
-        flyoutBackground.setLayerSize(rightTriangleIndex, mPointerSize, mPointerSize);
-        flyoutBackground.setLayerGravity(
-                rightTriangleIndex, Gravity.RIGHT | Gravity.CENTER_VERTICAL);
-        flyoutBackground.setLayerInsetRight(rightTriangleIndex, -mPointerSize);
-
-        // Append the appropriate triangle's outline to the view's outline so that the shadows look
-        // correct.
-        mFlyout.setOutlineProvider(new ViewOutlineProvider() {
-            @Override
-            public void getOutline(View view, Outline outline) {
-                final boolean leftPointing = mStackAnimationController.isStackOnLeftSide();
-
-                // Get the outline from the appropriate triangle.
-                final Outline triangleOutline = new Outline();
-                if (leftPointing) {
-                    mLeftFlyoutTriangle.getOutline(triangleOutline);
-                } else {
-                    mRightFlyoutTriangle.getOutline(triangleOutline);
-                }
-
-                // Offset it to the correct position, since it has no intrinsic position since
-                // that is maintained by the parent LayerDrawable.
-                triangleOutline.offset(
-                        leftPointing ? -mPointerSize : mFlyout.getWidth(),
-                        mFlyout.getHeight() / 2 - mPointerSize / 2);
-
-                // Merge the outlines.
-                final Outline compoundOutline = new Outline();
-                flyoutBackground.getOutline(compoundOutline);
-                compoundOutline.mPath.addPath(triangleOutline.mPath);
-                outline.set(compoundOutline);
-            }
-        });
-
-        mFlyoutText = mFlyout.findViewById(R.id.bubble_flyout_text);
-        mFlyoutSpring = new SpringAnimation(mFlyoutContainer, DynamicAnimation.TRANSLATION_X);
-    }
-
     private void applyCurrentState() {
         if (DEBUG) {
             Log.d(TAG, "applyCurrentState: mIsExpanded=" + mIsExpanded);
         }
+
         mExpandedViewContainer.setVisibility(mIsExpanded ? VISIBLE : GONE);
         if (mIsExpanded) {
             // First update the view so that it calculates a new height (ensuring the y position
@@ -1376,10 +1391,15 @@ public class BubbleStackView extends FrameLayout {
             }
         }
 
+        mStackOnLeftOrWillBe = mStackAnimationController.isStackOnLeftSide();
+        updateBubbleShadowsAndDotPosition(false);
+    }
+
+    /** Sets the appropriate Z-order and dot position for each bubble in the stack. */
+    private void updateBubbleShadowsAndDotPosition(boolean animate) {
         int bubbsCount = mBubbleContainer.getChildCount();
         for (int i = 0; i < bubbsCount; i++) {
             BubbleView bv = (BubbleView) mBubbleContainer.getChildAt(i);
-            bv.updateDotVisibility();
             bv.setZ((BubbleController.MAX_BUBBLES
                     * getResources().getDimensionPixelSize(R.dimen.bubble_elevation)) - i);
 
@@ -1393,6 +1413,11 @@ public class BubbleStackView extends FrameLayout {
                 }
             });
             bv.setClipToOutline(false);
+
+            // If the dot is on the left, and so is the stack, we need to change the dot position.
+            if (bv.getDotPositionOnLeft() == mStackOnLeftOrWillBe) {
+                bv.setDotPosition(!mStackOnLeftOrWillBe, animate);
+            }
         }
     }
 
index f429c2c..8fe8bd3 100644 (file)
@@ -111,12 +111,13 @@ class BubbleTouchHandler implements View.OnTouchListener {
                 trackMovement(event);
 
                 mTouchDown.set(rawX, rawY);
+                mStack.onGestureStart();
 
                 if (isStack) {
                     mViewPositionOnTouchDown.set(mStack.getStackPosition());
                     mStack.onDragStart();
                 } else if (isFlyout) {
-                    // TODO(b/129768381): Make the flyout dismissable with a gesture.
+                    mStack.onFlyoutDragStart();
                 } else {
                     mViewPositionOnTouchDown.set(
                             mTouchedView.getTranslationX(), mTouchedView.getTranslationY());
@@ -137,7 +138,7 @@ class BubbleTouchHandler implements View.OnTouchListener {
                     if (isStack) {
                         mStack.onDragged(viewX, viewY);
                     } else if (isFlyout) {
-                        // TODO(b/129768381): Make the flyout dismissable with a gesture.
+                        mStack.onFlyoutDragged(deltaX);
                     } else {
                         mStack.onBubbleDragged(mTouchedView, viewX, viewY);
                     }
@@ -152,8 +153,10 @@ class BubbleTouchHandler implements View.OnTouchListener {
                     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);
+                    if (!isFlyout) {
+                        mStack.animateMagnetToDismissTarget(
+                                mTouchedView, mInDismissTarget, viewX, viewY, velX, velY);
+                    }
                 }
                 break;
 
@@ -174,7 +177,9 @@ class BubbleTouchHandler implements View.OnTouchListener {
                                 : mInDismissTarget
                                         || velY > INDIVIDUAL_BUBBLE_DISMISS_MIN_VELOCITY;
 
-                if (shouldDismiss) {
+                if (isFlyout && mMovedEnough) {
+                    mStack.onFlyoutDragFinished(rawX - mTouchDown.x /* deltaX */, velX);
+                } else if (shouldDismiss) {
                     final String individualBubbleKey =
                             isStack ? null : ((BubbleView) mTouchedView).getKey();
                     mStack.magnetToStackIfNeededThenAnimateDismissal(mTouchedView, velX, velY,
@@ -200,7 +205,7 @@ class BubbleTouchHandler implements View.OnTouchListener {
                     }
                 } else if (mTouchedView == mStack.getExpandedBubbleView()) {
                     mBubbleData.setExpanded(false);
-                } else if (isStack) {
+                } else if (isStack || isFlyout) {
                     // Toggle expansion
                     mBubbleData.setExpanded(!mBubbleData.isExpanded());
                 } else {
@@ -251,9 +256,12 @@ class BubbleTouchHandler implements View.OnTouchListener {
             mVelocityTracker.recycle();
             mVelocityTracker = null;
         }
+
         mTouchedView = null;
         mMovedEnough = false;
         mInDismissTarget = false;
+
+        mStack.onGestureFinished();
     }
 
     private void trackMovement(MotionEvent event) {
index 2681b6d..aa32b94 100644 (file)
@@ -48,9 +48,12 @@ public class BubbleView extends FrameLayout {
     private Context mContext;
 
     private BadgedImageView mBadgedImageView;
+    private int mBadgeColor;
     private int mPadding;
     private int mIconInset;
 
+    private boolean mSuppressDot = false;
+
     private NotificationEntry mEntry;
 
     public BubbleView(Context context) {
@@ -130,18 +133,54 @@ public class BubbleView extends FrameLayout {
         return (mEntry != null) ? mEntry.getRow() : null;
     }
 
+    /** Changes the dot's visibility to match the bubble view's state. */
+    void updateDotVisibility(boolean animate) {
+        updateDotVisibility(animate, null /* after */);
+    }
+
+    /**
+     * Changes the dot's visibility to match the bubble view's state, running the provided callback
+     * after animation if requested.
+     */
+    void updateDotVisibility(boolean animate, Runnable after) {
+        boolean showDot = getEntry().showInShadeWhenBubble() && !mSuppressDot;
+
+        if (animate) {
+            animateDot(showDot, after);
+        } else {
+            mBadgedImageView.setShowDot(showDot);
+        }
+    }
+
     /**
-     * Marks this bubble as "read", i.e. no badge should show.
+     * Sets whether or not to hide the dot even if we'd otherwise show it. This is used while the
+     * flyout is visible or animating, to hide the dot until the flyout visually transforms into it.
      */
-    public void updateDotVisibility() {
-        boolean showDot = getEntry().showInShadeWhenBubble();
-        animateDot(showDot);
+    void setSuppressDot(boolean suppressDot, boolean animate) {
+        mSuppressDot = suppressDot;
+        updateDotVisibility(animate);
+    }
+
+    /** Sets the position of the 'new' dot, animating it out and back in if requested. */
+    void setDotPosition(boolean onLeft, boolean animate) {
+        if (animate && onLeft != mBadgedImageView.getDotPosition() && !mSuppressDot) {
+            animateDot(false /* showDot */, () -> {
+                mBadgedImageView.setDotPosition(onLeft);
+                animateDot(true /* showDot */, null);
+            });
+        } else {
+            mBadgedImageView.setDotPosition(onLeft);
+        }
+    }
+
+    boolean getDotPositionOnLeft() {
+        return mBadgedImageView.getDotPosition();
     }
 
     /**
      * Animates the badge to show or hide.
      */
-    private void animateDot(boolean showDot) {
+    private void animateDot(boolean showDot, Runnable after) {
         if (mBadgedImageView.isShowingDot() != showDot) {
             mBadgedImageView.setShowDot(showDot);
             mBadgedImageView.clearAnimation();
@@ -152,9 +191,13 @@ public class BubbleView extends FrameLayout {
                         fraction = showDot ? fraction : 1 - fraction;
                         mBadgedImageView.setDotScale(fraction);
                     }).withEndAction(() -> {
-                if (!showDot) {
-                    mBadgedImageView.setShowDot(false);
-                }
+                        if (!showDot) {
+                            mBadgedImageView.setShowDot(false);
+                        }
+
+                        if (after != null) {
+                            after.run();
+                        }
             }).start();
         }
     }
@@ -181,8 +224,13 @@ public class BubbleView extends FrameLayout {
             mBadgedImageView.setImageDrawable(iconDrawable);
         }
         int badgeColor = determineDominateColor(iconDrawable, n.color);
+        mBadgeColor = badgeColor;
         mBadgedImageView.setDotColor(badgeColor);
-        animateDot(mEntry.showInShadeWhenBubble() /* showDot */);
+        animateDot(mEntry.showInShadeWhenBubble() /* showDot */, null /* after */);
+    }
+
+    int getBadgeColor() {
+        return mBadgeColor;
     }
 
     private Drawable buildIconWithTint(Drawable iconDrawable, int backgroundColor) {
index f937525..8529ed4 100644 (file)
@@ -225,8 +225,10 @@ public class StackAnimationController extends
     /**
      * Flings the stack starting with the given velocities, springing it to the nearest edge
      * afterward.
+     *
+     * @return The X value that the stack will end up at after the fling/spring.
      */
-    public void flingStackThenSpringToEdge(float x, float velX, float velY) {
+    public float flingStackThenSpringToEdge(float x, float velX, float velY) {
         final boolean stackOnLeftSide = x - mIndividualBubbleSize / 2 < mLayout.getWidth() / 2;
 
         final boolean stackShouldFlingLeft = stackOnLeftSide
@@ -281,6 +283,7 @@ public class StackAnimationController extends
                 DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y);
 
         mIsMovingFromFlinging = true;
+        return destinationRelativeX;
     }
 
     /**
diff --git a/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleFlyoutViewTest.java b/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleFlyoutViewTest.java
new file mode 100644 (file)
index 0000000..173237f
--- /dev/null
@@ -0,0 +1,92 @@
+/*
+ * 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 junit.framework.Assert.assertEquals;
+import static junit.framework.Assert.assertNotSame;
+import static junit.framework.Assert.assertTrue;
+
+import static org.mockito.Mockito.verify;
+
+import android.graphics.Color;
+import android.graphics.PointF;
+import android.testing.AndroidTestingRunner;
+import android.testing.TestableLooper;
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.test.filters.SmallTest;
+
+import com.android.systemui.R;
+import com.android.systemui.SysuiTestCase;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+@SmallTest
+@RunWith(AndroidTestingRunner.class)
+@TestableLooper.RunWithLooper(setAsMainLooper = true)
+public class BubbleFlyoutViewTest extends SysuiTestCase {
+    private BubbleFlyoutView mFlyout;
+    private TextView mFlyoutText;
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+        mFlyout = new BubbleFlyoutView(getContext());
+
+        mFlyoutText = mFlyout.findViewById(R.id.bubble_flyout_text);
+    }
+
+    @Test
+    public void testShowFlyout_isVisible() {
+        mFlyout.showFlyout("Hello", new PointF(100, 100), 500, true, Color.WHITE, null);
+        assertEquals("Hello", mFlyoutText.getText());
+        assertEquals(View.VISIBLE, mFlyout.getVisibility());
+        assertEquals(1f, mFlyoutText.getAlpha(), .01f);
+    }
+
+    @Test
+    public void testFlyoutHide_runsCallback() {
+        Runnable after = Mockito.mock(Runnable.class);
+        mFlyout.showFlyout("Hello", new PointF(100, 100), 500, true, Color.WHITE, after);
+        mFlyout.hideFlyout();
+
+        verify(after).run();
+    }
+
+    @Test
+    public void testSetCollapsePercent() {
+        mFlyout.showFlyout("Hello", new PointF(100, 100), 500, true, Color.WHITE, null);
+
+        float initialTranslationZ = mFlyout.getTranslationZ();
+
+        mFlyout.setCollapsePercent(1f);
+        assertEquals(0f, mFlyoutText.getAlpha(), 0.01f);
+        assertNotSame(0f, mFlyoutText.getTranslationX()); // Should have moved to collapse.
+        assertTrue(mFlyout.getTranslationZ() < initialTranslationZ); // Should be descending.
+
+        mFlyout.setCollapsePercent(0f);
+        assertEquals(1f, mFlyoutText.getAlpha(), 0.01f);
+        assertEquals(0f, mFlyoutText.getTranslationX());
+        assertEquals(initialTranslationZ, mFlyout.getTranslationZ());
+
+    }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleStackViewTest.java b/packages/SystemUI/tests/src/com/android/systemui/bubbles/BubbleStackViewTest.java
deleted file mode 100644 (file)
index bafae6c..0000000
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
- * 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 org.junit.Assert.assertEquals;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.when;
-
-import android.testing.AndroidTestingRunner;
-import android.testing.TestableLooper;
-import android.widget.TextView;
-
-import androidx.test.filters.SmallTest;
-
-import com.android.systemui.R;
-import com.android.systemui.SysuiTestCase;
-import com.android.systemui.statusbar.notification.collection.NotificationEntry;
-
-import org.junit.Before;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.Mock;
-import org.mockito.MockitoAnnotations;
-
-@SmallTest
-@RunWith(AndroidTestingRunner.class)
-@TestableLooper.RunWithLooper(setAsMainLooper = true)
-public class BubbleStackViewTest extends SysuiTestCase {
-    private BubbleStackView mStackView;
-    @Mock private Bubble mBubble;
-    @Mock private NotificationEntry mNotifEntry;
-
-    @Before
-    public void setUp() throws Exception {
-        MockitoAnnotations.initMocks(this);
-        mStackView = new BubbleStackView(mContext, new BubbleData(getContext()), null);
-        mBubble.entry = mNotifEntry;
-    }
-
-    @Test
-    public void testAnimateInFlyoutForBubble() {
-        when(mNotifEntry.getUpdateMessage(any())).thenReturn("Test Flyout Message.");
-        mStackView.animateInFlyoutForBubble(mBubble);
-
-        assertEquals("Test Flyout Message.",
-                ((TextView) mStackView.findViewById(R.id.bubble_flyout_text)).getText());
-    }
-}