From 90a769d1d3398b84a77aaf994e3ef20a50e8a809 Mon Sep 17 00:00:00 2001 From: Ivan Tkachenko Date: Wed, 26 Jul 2023 23:50:09 +0100 Subject: [PATCH] Bubble bar dismiss interaction A single bubble and the bubble stack dismiss functionality is implemented using `BubbleDragController`. It attaches a touch listener to the corresponding views and handles gesture interactions. * When the view is long clicked the dragging to dismiss interaction starts. It scales up the dragged view and presents the dismiss view in the bottom of the window. * When the bubble or the bubble stack is dragged close to the dismiss target area, it gets pulled towards it and sticks to it. The `MagnetizedObject` and `MagneticTarget` classes are used for it. * When the dragged view is released outside of the dismiss area, it moves back to the initial position with animation. * When the dragged bubble is released inside of the dismiss area, it will dismiss the bubble with animation and remove it from the stack. * When the dragged bubble bar stack is released inside the dismiss area, all the bubbles will get dismissed and the bubble bar will dissapear. Bug: 271466616 Test: manual, TBD Flag: WM_BUBBLE_BAR Change-Id: I83393898be61ec522db92688ac2e111ef7d72fe6 --- quickstep/res/layout/transient_taskbar.xml | 1 + quickstep/res/values/dimens.xml | 1 + .../taskbar/TaskbarActivityContext.java | 6 +- .../taskbar/bubbles/BubbleBarView.java | 50 ++- .../bubbles/BubbleBarViewController.java | 48 +++ .../taskbar/bubbles/BubbleControllers.java | 10 +- .../bubbles/BubbleDismissController.java | 212 +++++++++++ .../taskbar/bubbles/BubbleDragAnimator.java | 222 +++++++++++ .../taskbar/bubbles/BubbleDragController.java | 355 ++++++++++++++++++ 9 files changed, 898 insertions(+), 7 deletions(-) create mode 100644 quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleDismissController.java create mode 100644 quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleDragAnimator.java create mode 100644 quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleDragController.java diff --git a/quickstep/res/layout/transient_taskbar.xml b/quickstep/res/layout/transient_taskbar.xml index 7a6d16a083..0890a4e6b8 100644 --- a/quickstep/res/layout/transient_taskbar.xml +++ b/quickstep/res/layout/transient_taskbar.xml @@ -49,6 +49,7 @@ android:visibility="gone" android:gravity="center" android:clipChildren="false" + android:elevation="@dimen/bubblebar_elevation" /> @dimen/transient_taskbar_stashed_height @dimen/taskbar_stashed_handle_height 8dp + 1dp 50dp 24dp diff --git a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java index 42cb29046f..cb9c329c77 100644 --- a/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java +++ b/quickstep/src/com/android/launcher3/taskbar/TaskbarActivityContext.java @@ -90,6 +90,8 @@ import com.android.launcher3.taskbar.bubbles.BubbleBarController; import com.android.launcher3.taskbar.bubbles.BubbleBarView; import com.android.launcher3.taskbar.bubbles.BubbleBarViewController; import com.android.launcher3.taskbar.bubbles.BubbleControllers; +import com.android.launcher3.taskbar.bubbles.BubbleDismissController; +import com.android.launcher3.taskbar.bubbles.BubbleDragController; import com.android.launcher3.taskbar.bubbles.BubbleStashController; import com.android.launcher3.taskbar.bubbles.BubbleStashedHandleViewController; import com.android.launcher3.taskbar.overlay.TaskbarOverlayController; @@ -216,7 +218,9 @@ public class TaskbarActivityContext extends BaseTaskbarContext { new BubbleBarController(this, bubbleBarView), new BubbleBarViewController(this, bubbleBarView), new BubbleStashController(this), - new BubbleStashedHandleViewController(this, bubbleHandleView))); + new BubbleStashedHandleViewController(this, bubbleHandleView), + new BubbleDragController(this), + new BubbleDismissController(this, mDragLayer))); } // Construct controllers. diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java index a8e6849e6f..ffe077b288 100644 --- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java +++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarView.java @@ -95,6 +95,8 @@ public class BubbleBarView extends FrameLayout { private View.OnClickListener mOnClickListener; private final Rect mTempRect = new Rect(); + private float mRelativePivotX = 1f; + private float mRelativePivotY = 1f; // An animator that represents the expansion state of the bubble bar, where 0 corresponds to the // collapsed state and 1 to the fully expanded state. @@ -109,6 +111,9 @@ public class BubbleBarView extends FrameLayout { @Nullable private Consumer mUpdateSelectedBubbleAfterCollapse; + @Nullable + private BubbleView mDraggedBubbleView; + public BubbleBarView(Context context) { this(context, null); } @@ -181,9 +186,10 @@ public class BubbleBarView extends FrameLayout { mBubbleBarBounds.right = right; mBubbleBarBounds.bottom = bottom; - // The bubble bar handle is aligned to the bottom edge of the screen so scale towards that. - setPivotX(getWidth()); - setPivotY(getHeight()); + // The bubble bar handle is aligned according to the relative pivot, + // by default it's aligned to the bottom edge of the screen so scale towards that + setPivotX(mRelativePivotX * getWidth()); + setPivotY(mRelativePivotY * getHeight()); // Position the views updateChildrenRenderNodeProperties(); @@ -198,6 +204,32 @@ public class BubbleBarView extends FrameLayout { return mBubbleBarBounds; } + /** + * Set bubble bar relative pivot value for X and Y, applied as a fraction of view width/height + * respectively. If the value is not in range of 0 to 1 it will be normalized. + * @param x relative X pivot value in range 0..1 + * @param y relative Y pivot value in range 0..1 + */ + public void setRelativePivot(float x, float y) { + mRelativePivotX = Float.max(Float.min(x, 1), 0); + mRelativePivotY = Float.max(Float.min(y, 1), 0); + requestLayout(); + } + + /** + * Get current relative pivot for X axis + */ + public float getRelativePivotX() { + return mRelativePivotX; + } + + /** + * Get current relative pivot for Y axis + */ + public float getRelativePivotY() { + return mRelativePivotY; + } + // TODO: (b/280605790) animate it @Override public void addView(View child, int index, ViewGroup.LayoutParams params) { @@ -254,9 +286,9 @@ public class BubbleBarView extends FrameLayout { // where the bubble will end up when the animation ends final float targetX = currentWidth - expandedWidth + expandedX; bv.setTranslationX(widthState * (targetX - collapsedX) + collapsedX); - // if we're fully expanded, set the z level to 0 + // if we're fully expanded, set the z level to 0 or to bubble elevation if dragged if (widthState == 1f) { - bv.setZ(0); + bv.setZ(bv == mDraggedBubbleView ? mBubbleElevation : 0); } // When we're expanded, we're not stacked so we're not behind the stack bv.setBehindStack(false, animate); @@ -331,6 +363,14 @@ public class BubbleBarView extends FrameLayout { updateArrowForSelected(/* shouldAnimate= */ true); } + /** + * Sets the dragged bubble view to correctly apply Z order. Dragged view should appear on top + */ + public void setDraggedBubble(@Nullable BubbleView view) { + mDraggedBubbleView = view; + requestLayout(); + } + /** * Update the arrow position to match the selected bubble. * diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarViewController.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarViewController.java index f5e2ddc6cf..7044bf301d 100644 --- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarViewController.java +++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleBarViewController.java @@ -24,6 +24,8 @@ import android.view.MotionEvent; import android.view.View; import android.widget.FrameLayout; +import androidx.annotation.NonNull; + import com.android.launcher3.R; import com.android.launcher3.anim.AnimatedFloat; import com.android.launcher3.taskbar.TaskbarActivityContext; @@ -54,6 +56,7 @@ public class BubbleBarViewController { // Initialized in init. private BubbleStashController mBubbleStashController; private BubbleBarController mBubbleBarController; + private BubbleDragController mBubbleDragController; private TaskbarStashController mTaskbarStashController; private TaskbarInsetsController mTaskbarInsetsController; private View.OnClickListener mBubbleClickListener; @@ -85,6 +88,7 @@ public class BubbleBarViewController { public void init(TaskbarControllers controllers, BubbleControllers bubbleControllers) { mBubbleStashController = bubbleControllers.bubbleStashController; mBubbleBarController = bubbleControllers.bubbleBarController; + mBubbleDragController = bubbleControllers.bubbleDragController; mTaskbarStashController = controllers.taskbarStashController; mTaskbarInsetsController = controllers.taskbarInsetsController; @@ -95,6 +99,7 @@ public class BubbleBarViewController { mBubbleBarScale.updateValue(1f); mBubbleClickListener = v -> onBubbleClicked(v); mBubbleBarClickListener = v -> setExpanded(true); + mBubbleDragController.setupBubbleBarView(mBarView); mBarView.setOnClickListener(mBubbleBarClickListener); mBarView.addOnLayoutChangeListener((view, i, i1, i2, i3, i4, i5, i6, i7) -> mTaskbarInsetsController.onTaskbarOrBubblebarWindowHeightOrInsetsChanged() @@ -263,6 +268,7 @@ public class BubbleBarViewController { if (b != null) { mBarView.addView(b.getView(), 0, new FrameLayout.LayoutParams(mIconSize, mIconSize)); b.getView().setOnClickListener(mBubbleClickListener); + mBubbleDragController.setupBubbleView(b.getView()); } else { Log.w(TAG, "addBubble, bubble was null!"); } @@ -314,4 +320,46 @@ public class BubbleBarViewController { mBubbleStashController.showBubbleBar(true /* expand the bubbles */); } } + + /** + * Updates the dragged bubble view in the bubble bar view, and notifies SystemUI + * that a bubble is being dragged to dismiss. + * @param bubbleView dragged bubble view + */ + public void onDragStart(@NonNull BubbleView bubbleView) { + if (bubbleView.getBubble() == null) return; + mSystemUiProxy.onBubbleDrag(bubbleView.getBubble().getKey(), /* isBeingDragged = */ true); + mBarView.setDraggedBubble(bubbleView); + } + + /** + * Notifies SystemUI to expand the selected bubble when the bubble is released. + * @param bubbleView dragged bubble view + */ + public void onDragRelease(@NonNull BubbleView bubbleView) { + if (bubbleView.getBubble() == null) return; + mSystemUiProxy.onBubbleDrag(bubbleView.getBubble().getKey(), /* isBeingDragged = */ false); + } + + /** + * Removes the dragged bubble view in the bubble bar view + */ + public void onDragEnd() { + mBarView.setDraggedBubble(null); + } + + /** + * Called when bubble was dragged into the dismiss target. Notifies System + * @param bubble dismissed bubble item + */ + public void onDismissBubbleWhileDragging(@NonNull BubbleBarItem bubble) { + mSystemUiProxy.removeBubble(bubble.getKey()); + } + + /** + * Called when bubble stack was dragged into the dismiss target + */ + public void onDismissAllBubblesWhileDragging() { + mSystemUiProxy.removeAllBubbles(); + } } diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleControllers.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleControllers.java index 6417f3c585..c47427d4fb 100644 --- a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleControllers.java +++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleControllers.java @@ -27,6 +27,8 @@ public class BubbleControllers { public final BubbleBarViewController bubbleBarViewController; public final BubbleStashController bubbleStashController; public final BubbleStashedHandleViewController bubbleStashedHandleViewController; + public final BubbleDragController bubbleDragController; + public final BubbleDismissController bubbleDismissController; private final RunnableList mPostInitRunnables = new RunnableList(); @@ -39,11 +41,15 @@ public class BubbleControllers { BubbleBarController bubbleBarController, BubbleBarViewController bubbleBarViewController, BubbleStashController bubbleStashController, - BubbleStashedHandleViewController bubbleStashedHandleViewController) { + BubbleStashedHandleViewController bubbleStashedHandleViewController, + BubbleDragController bubbleDragController, + BubbleDismissController bubbleDismissController) { this.bubbleBarController = bubbleBarController; this.bubbleBarViewController = bubbleBarViewController; this.bubbleStashController = bubbleStashController; this.bubbleStashedHandleViewController = bubbleStashedHandleViewController; + this.bubbleDragController = bubbleDragController; + this.bubbleDismissController = bubbleDismissController; } /** @@ -56,6 +62,8 @@ public class BubbleControllers { bubbleBarViewController.init(taskbarControllers, this); bubbleStashedHandleViewController.init(taskbarControllers, this); bubbleStashController.init(taskbarControllers, this); + bubbleDragController.init(/* bubbleControllers = */ this); + bubbleDismissController.init(/* bubbleControllers = */ this); mPostInitRunnables.executeAllAndDestroy(); } diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleDismissController.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleDismissController.java new file mode 100644 index 0000000000..0ff04699d0 --- /dev/null +++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleDismissController.java @@ -0,0 +1,212 @@ +/* + * Copyright (C) 2023 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.launcher3.taskbar.bubbles; + +import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; + +import android.os.SystemProperties; +import android.util.Log; +import android.view.MotionEvent; +import android.view.View; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.dynamicanimation.animation.DynamicAnimation; + +import com.android.launcher3.R; +import com.android.launcher3.taskbar.TaskbarActivityContext; +import com.android.launcher3.taskbar.TaskbarDragLayer; +import com.android.wm.shell.common.bubbles.DismissView; +import com.android.wm.shell.common.magnetictarget.MagnetizedObject; + +/** + * Controls dismiss view presentation for the bubble bar dismiss functionality. + * Provides the dragged view snapping to the target dismiss area and animates it. + * When the dragged bubble/bubble stack is released inside of the target area, it gets dismissed. + * + * @see BubbleDragController + */ +public class BubbleDismissController { + private static final String TAG = BubbleDismissController.class.getSimpleName(); + private static final float FLING_TO_DISMISS_MIN_VELOCITY = 6000f; + // LINT.IfChange + private static final boolean ENABLE_FLING_TO_DISMISS_BUBBLE = + SystemProperties.getBoolean("persist.wm.debug.fling_to_dismiss_bubble", true); + // LINT.ThenChange(com/android/wm/shell/bubbles/BubbleStackView.java) + private final TaskbarActivityContext mActivity; + private final TaskbarDragLayer mDragLayer; + @Nullable + private BubbleBarViewController mBubbleBarViewController; + + // Dismiss view that's attached to drag layer. It consists of the scrim view and the circular + // dismiss view used as a dismiss target. + @Nullable + private DismissView mDismissView; + + // The currently magnetized object, which is being dragged and will be attracted to the magnetic + // dismiss target. This is either the stack itself, or an individual bubble. + @Nullable + private MagnetizedObject mMagnetizedObject; + + // The MagneticTarget instance for our circular dismiss view. This is added to the + // MagnetizedObject instances for the stack and any dragged-out bubbles. + @Nullable + private MagnetizedObject.MagneticTarget mMagneticTarget; + + // The bubble drag animator that synchronizes bubble drag and dismiss view animations + // A new instance is provided when the dismiss view is setup + @Nullable + private BubbleDragAnimator mAnimator; + + public BubbleDismissController(TaskbarActivityContext activity, TaskbarDragLayer dragLayer) { + mActivity = activity; + mDragLayer = dragLayer; + } + + /** + * Initializes dependencies when bubble controllers are created. + * Should be careful to only access things that were created in constructors for now, as some + * controllers may still be waiting for init(). + */ + public void init(@NonNull BubbleControllers bubbleControllers) { + mBubbleBarViewController = bubbleControllers.bubbleBarViewController; + } + + /** + * Setup the dismiss view and magnetized object that will be attracted to magnetic target. + * Should be called before handling events or showing/hiding dismiss view. + * + * @param magnetizedView the view to be pulled into target dismiss area + * @param animator the bubble animator to be used for the magnetized view, it syncs bubble + * dragging and dismiss animations with the dismiss view provided. + */ + public void setupDismissView(@NonNull View magnetizedView, + @NonNull BubbleDragAnimator animator) { + setupDismissView(); + setupMagnetizedObject(magnetizedView); + if (mDismissView != null) { + animator.setDismissView(mDismissView); + mAnimator = animator; + } + } + + /** + * Handle the touch event and pass it to the magnetized object. + * It should be called after {@code setupDismissView} + */ + public boolean handleTouchEvent(@NonNull MotionEvent event) { + return mMagnetizedObject != null && mMagnetizedObject.maybeConsumeMotionEvent(event); + } + + /** + * Show dismiss view with animation + * It should be called after {@code setupDismissView} + */ + public void showDismissView() { + if (mDismissView == null) return; + mDismissView.show(); + } + + /** + * Hide dismiss view with animation + * It should be called after {@code setupDismissView} + */ + public void hideDismissView() { + if (mDismissView == null) return; + mDismissView.hide(); + } + + /** + * Dismiss magnetized object when it's released in the dismiss target area + */ + private void dismissMagnetizedObject() { + if (mMagnetizedObject == null || mBubbleBarViewController == null) return; + if (mMagnetizedObject.getUnderlyingObject() instanceof BubbleView) { + BubbleView bubbleView = (BubbleView) mMagnetizedObject.getUnderlyingObject(); + if (bubbleView.getBubble() != null) { + mBubbleBarViewController.onDismissBubbleWhileDragging(bubbleView.getBubble()); + } + } else if (mMagnetizedObject.getUnderlyingObject() instanceof BubbleBarView) { + mBubbleBarViewController.onDismissAllBubblesWhileDragging(); + } + } + + private void setupDismissView() { + if (mDismissView != null) return; + mDismissView = new DismissView(mActivity.getApplicationContext()); + BubbleDismissViewUtils.setup(mDismissView); + mDragLayer.addView(mDismissView, /* index = */ 0, + new FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)); + mDismissView.setElevation(mDismissView.getResources().getDimensionPixelSize( + R.dimen.bubblebar_elevation)); + setupMagneticTarget(mDismissView.getCircle()); + } + + private void setupMagneticTarget(@NonNull View view) { + int magneticFieldRadius = mActivity.getResources().getDimensionPixelSize( + R.dimen.bubblebar_dismiss_target_size); + mMagneticTarget = new MagnetizedObject.MagneticTarget(view, magneticFieldRadius); + } + + private void setupMagnetizedObject(@NonNull View magnetizedView) { + mMagnetizedObject = new MagnetizedObject<>(mActivity.getApplicationContext(), + magnetizedView, DynamicAnimation.TRANSLATION_X, DynamicAnimation.TRANSLATION_Y) { + @Override + public float getWidth(@NonNull View underlyingObject) { + return underlyingObject.getWidth() * underlyingObject.getScaleX(); + } + + @Override + public float getHeight(@NonNull View underlyingObject) { + return underlyingObject.getHeight() * underlyingObject.getScaleY(); + } + + @Override + public void getLocationOnScreen(@NonNull View underlyingObject, @NonNull int[] loc) { + underlyingObject.getLocationOnScreen(loc); + } + }; + + mMagnetizedObject.setHapticsEnabled(true); + mMagnetizedObject.setFlingToTargetEnabled(ENABLE_FLING_TO_DISMISS_BUBBLE); + mMagnetizedObject.setFlingToTargetMinVelocity(FLING_TO_DISMISS_MIN_VELOCITY); + if (mMagneticTarget != null) { + mMagnetizedObject.addTarget(mMagneticTarget); + } else { + Log.e(TAG,"Requires MagneticTarget to add target to MagnetizedObject!"); + } + mMagnetizedObject.setMagnetListener(new MagnetizedObject.MagnetListener() { + @Override + public void onStuckToTarget(@NonNull MagnetizedObject.MagneticTarget target) { + if (mAnimator == null) return; + mAnimator.animateDismissCaptured(); + } + + @Override + public void onUnstuckFromTarget(@NonNull MagnetizedObject.MagneticTarget target, + float velX, float velY, boolean wasFlungOut) { + if (mAnimator == null) return; + mAnimator.animateDismissReleased(); + } + + @Override + public void onReleasedInTarget(@NonNull MagnetizedObject.MagneticTarget target) { + dismissMagnetizedObject(); + } + }); + } +} diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleDragAnimator.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleDragAnimator.java new file mode 100644 index 0000000000..24dca5e989 --- /dev/null +++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleDragAnimator.java @@ -0,0 +1,222 @@ +/* + * Copyright (C) 2023 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.launcher3.taskbar.bubbles; + +import static androidx.dynamicanimation.animation.SpringForce.DAMPING_RATIO_LOW_BOUNCY; +import static androidx.dynamicanimation.animation.SpringForce.STIFFNESS_LOW; +import static androidx.dynamicanimation.animation.SpringForce.STIFFNESS_MEDIUM; + +import android.content.res.Resources; +import android.graphics.PointF; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.dynamicanimation.animation.DynamicAnimation; +import androidx.dynamicanimation.animation.FloatPropertyCompat; + +import com.android.launcher3.R; +import com.android.wm.shell.animation.PhysicsAnimator; +import com.android.wm.shell.common.bubbles.DismissCircleView; +import com.android.wm.shell.common.bubbles.DismissView; + +/** + * The animator performs the bubble animations while dragging and coordinates bubble and dismiss + * view animations when it gets magnetized, released or dismissed. + */ +public class BubbleDragAnimator { + private static final float SCALE_BUBBLE_FOCUSED = 1.2f; + private static final float SCALE_BUBBLE_CAPTURED = 0.9f; + private static final float SCALE_BUBBLE_BAR_FOCUSED = 1.1f; + + private final PhysicsAnimator.SpringConfig mDefaultConfig = + new PhysicsAnimator.SpringConfig(STIFFNESS_LOW, DAMPING_RATIO_LOW_BOUNCY); + private final PhysicsAnimator.SpringConfig mTranslationConfig = + new PhysicsAnimator.SpringConfig(STIFFNESS_MEDIUM, DAMPING_RATIO_LOW_BOUNCY); + @NonNull + private final View mView; + @NonNull + private final PhysicsAnimator mBubbleAnimator; + @Nullable + private DismissView mDismissView; + @Nullable + private PhysicsAnimator mDismissAnimator; + private final float mBubbleFocusedScale; + private final float mBubbleCapturedScale; + private final float mDismissCapturedScale; + + /** + * Should be initialised for each dragged view + * + * @param view the dragged view to animate + */ + public BubbleDragAnimator(@NonNull View view) { + mView = view; + mBubbleAnimator = PhysicsAnimator.getInstance(view); + mBubbleAnimator.setDefaultSpringConfig(mDefaultConfig); + + Resources resources = view.getResources(); + final int collapsedSize = resources.getDimensionPixelSize( + R.dimen.bubblebar_dismiss_target_small_size); + final int expandedSize = resources.getDimensionPixelSize( + R.dimen.bubblebar_dismiss_target_size); + mDismissCapturedScale = (float) collapsedSize / expandedSize; + + if (view instanceof BubbleBarView) { + mBubbleFocusedScale = SCALE_BUBBLE_BAR_FOCUSED; + mBubbleCapturedScale = mDismissCapturedScale; + } else { + mBubbleFocusedScale = SCALE_BUBBLE_FOCUSED; + mBubbleCapturedScale = SCALE_BUBBLE_CAPTURED; + } + } + + /** + * Sets dismiss view to be animated alongside the dragged bubble + */ + public void setDismissView(@NonNull DismissView dismissView) { + mDismissView = dismissView; + mDismissAnimator = PhysicsAnimator.getInstance(dismissView.getCircle()); + mDismissAnimator.setDefaultSpringConfig(mDefaultConfig); + } + + /** + * Animates the focused state of the bubble when the dragging starts + */ + public void animateFocused() { + mBubbleAnimator.cancel(); + mBubbleAnimator + .spring(DynamicAnimation.SCALE_X, mBubbleFocusedScale) + .spring(DynamicAnimation.SCALE_Y, mBubbleFocusedScale) + .start(); + } + + /** + * Animates the dragged bubble movement back to the initial position. + * + * @param initialPosition the position to animate to + * @param velocity the initial velocity to use for the spring animation + * @param endActions gets called when the animation completes or gets cancelled + */ + public void animateToInitialState(@NonNull PointF initialPosition, @NonNull PointF velocity, + @Nullable Runnable endActions) { + mBubbleAnimator.cancel(); + mBubbleAnimator + .spring(DynamicAnimation.SCALE_X, 1f) + .spring(DynamicAnimation.SCALE_Y, 1f) + .spring(DynamicAnimation.TRANSLATION_X, initialPosition.x, velocity.x, + mTranslationConfig) + .spring(DynamicAnimation.TRANSLATION_Y, initialPosition.y, velocity.y, + mTranslationConfig) + .addEndListener((View target, @NonNull FloatPropertyCompat property, + boolean wasFling, boolean canceled, float finalValue, float finalVelocity, + boolean allRelevantPropertyAnimationsEnded) -> { + if (canceled || allRelevantPropertyAnimationsEnded) { + resetAnimatedViews(initialPosition); + if (endActions != null) { + endActions.run(); + } + } + }) + .start(); + } + + /** + * Animates the dragged view alongside the dismiss view when it gets captured in the dismiss + * target area. + */ + public void animateDismissCaptured() { + mBubbleAnimator.cancel(); + mBubbleAnimator + .spring(DynamicAnimation.SCALE_X, mBubbleCapturedScale) + .spring(DynamicAnimation.SCALE_Y, mBubbleCapturedScale) + .spring(DynamicAnimation.ALPHA, mDismissCapturedScale) + .start(); + + if (mDismissAnimator != null) { + mDismissAnimator.cancel(); + mDismissAnimator + .spring(DynamicAnimation.SCALE_X, mDismissCapturedScale) + .spring(DynamicAnimation.SCALE_Y, mDismissCapturedScale) + .start(); + } + } + + /** + * Animates the dragged view alongside the dismiss view when it gets released from the dismiss + * target area. + */ + public void animateDismissReleased() { + mBubbleAnimator.cancel(); + mBubbleAnimator + .spring(DynamicAnimation.SCALE_X, mBubbleFocusedScale) + .spring(DynamicAnimation.SCALE_Y, mBubbleFocusedScale) + .spring(DynamicAnimation.ALPHA, 1f) + .start(); + + if (mDismissAnimator != null) { + mDismissAnimator.cancel(); + mDismissAnimator + .spring(DynamicAnimation.SCALE_X, 1f) + .spring(DynamicAnimation.SCALE_Y, 1f) + .start(); + } + } + + /** + * Animates the dragged bubble dismiss when it's released in the dismiss target area. + * + * @param initialPosition the initial position to move the bubble too after animation finishes + * @param endActions gets called when the animation completes or gets cancelled + */ + public void animateDismiss(@NonNull PointF initialPosition, @Nullable Runnable endActions) { + float dismissHeight = mDismissView != null ? mDismissView.getHeight() : 0f; + float translationY = mView.getTranslationY() + dismissHeight; + mBubbleAnimator + .spring(DynamicAnimation.TRANSLATION_Y, translationY) + .spring(DynamicAnimation.SCALE_X, 0f) + .spring(DynamicAnimation.SCALE_Y, 0f) + .spring(DynamicAnimation.ALPHA, 0f) + .addEndListener((View target, @NonNull FloatPropertyCompat property, + boolean wasFling, boolean canceled, float finalValue, float finalVelocity, + boolean allRelevantPropertyAnimationsEnded) -> { + if (canceled || allRelevantPropertyAnimationsEnded) { + resetAnimatedViews(initialPosition); + if (endActions != null) endActions.run(); + } + }) + .start(); + } + + /** + * Reset the animated views to the initial state + * + * @param initialPosition position of the bubble + */ + private void resetAnimatedViews(@NonNull PointF initialPosition) { + mView.setScaleX(1f); + mView.setScaleY(1f); + mView.setAlpha(1f); + mView.setTranslationX(initialPosition.x); + mView.setTranslationY(initialPosition.y); + + if (mDismissView != null) { + mDismissView.getCircle().setScaleX(1f); + mDismissView.getCircle().setScaleY(1f); + } + } +} diff --git a/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleDragController.java b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleDragController.java new file mode 100644 index 0000000000..08fd681b4c --- /dev/null +++ b/quickstep/src/com/android/launcher3/taskbar/bubbles/BubbleDragController.java @@ -0,0 +1,355 @@ +/* + * Copyright (C) 2023 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.launcher3.taskbar.bubbles; + +import android.annotation.SuppressLint; +import android.graphics.PointF; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.ViewConfiguration; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.android.launcher3.taskbar.TaskbarActivityContext; + +/** + * Controls bubble bar drag to dismiss interaction. + * Interacts with {@link BubbleDismissController}, used by {@link BubbleBarViewController}. + * Supported interactions: + * - Drag a single bubble view into dismiss target to remove it. + * - Drag the bubble stack into dismiss target to remove all. + * Restores initial position of dragged view if released outside of the dismiss target. + */ +public class BubbleDragController { + private final TaskbarActivityContext mActivity; + private BubbleBarViewController mBubbleBarViewController; + private BubbleDismissController mBubbleDismissController; + + public BubbleDragController(TaskbarActivityContext activity) { + mActivity = activity; + } + + /** + * Initializes dependencies when bubble controllers are created. + * Should be careful to only access things that were created in constructors for now, as some + * controllers may still be waiting for init(). + */ + public void init(@NonNull BubbleControllers bubbleControllers) { + mBubbleBarViewController = bubbleControllers.bubbleBarViewController; + mBubbleDismissController = bubbleControllers.bubbleDismissController; + } + + /** + * Setup the bubble view for dragging and attach touch listener to it + */ + @SuppressLint("ClickableViewAccessibility") + public void setupBubbleView(@NonNull BubbleView bubbleView) { + if (!(bubbleView.getBubble() instanceof BubbleBarBubble)) { + // Don't setup dragging for overflow bubble view + return; + } + + bubbleView.setOnTouchListener(new BubbleTouchListener() { + @Override + void onDragStart() { + mBubbleBarViewController.onDragStart(bubbleView); + } + + @Override + void onDragEnd() { + mBubbleBarViewController.onDragEnd(); + } + + @Override + protected void onDragRelease() { + mBubbleBarViewController.onDragRelease(bubbleView); + } + }); + } + + /** + * Setup the bubble bar view for dragging and attach touch listener to it + */ + @SuppressLint("ClickableViewAccessibility") + public void setupBubbleBarView(@NonNull BubbleBarView bubbleBarView) { + PointF initialRelativePivot = new PointF(); + bubbleBarView.setOnTouchListener(new BubbleTouchListener() { + @Override + protected boolean onTouchDown(@NonNull View view, @NonNull MotionEvent event) { + if (bubbleBarView.isExpanded()) return false; + return super.onTouchDown(view, event); + } + + @Override + void onDragStart() { + initialRelativePivot.set(bubbleBarView.getRelativePivotX(), + bubbleBarView.getRelativePivotY()); + // By default the bubble bar view pivot is in bottom right corner, while dragging + // it should be centered in order to align it with the dismiss target view + bubbleBarView.setRelativePivot(/* x = */ 0.5f, /* y = */ 0.5f); + } + + @Override + void onDragEnd() { + // Restoring the initial pivot for the bubble bar view + bubbleBarView.setRelativePivot(initialRelativePivot.x, initialRelativePivot.y); + } + }); + } + + /** + * Bubble touch listener for handling a single bubble view or bubble bar view while dragging. + * The dragging starts after "shorter" long click (the long click duration might change): + * - When the touch gesture moves out of the {@code ACTION_DOWN} location the dragging + * interaction is cancelled. + * - When {@code ACTION_UP} happens before long click is registered and there was no significant + * movement the view will perform click. + * - When the listener registers long click it starts dragging interaction, all the subsequent + * {@code ACTION_MOVE} events will drag the view, and the interaction finishes when + * {@code ACTION_UP} or {@code ACTION_CANCEL} are received. + * Lifecycle methods can be overridden do add extra setup/clean up steps. + */ + private abstract class BubbleTouchListener implements View.OnTouchListener { + /** + * The internal state of the touch listener + */ + private enum State { + // Idle and ready for the touch events. + // Changes to: + // - TOUCHED, when the {@code ACTION_DOWN} is handled + IDLE, + + // Touch down was handled and the lister is recognising the gestures. + // Changes to: + // - IDLE, when performs the click + // - DRAGGING, when registers the long click and starts dragging interaction + // - CANCELLED, when the touch events move out of the initial location before the long + // click is recognised + + TOUCHED, + + // The long click was registered and the view is being dragged. + // Changes to: + // - IDLE, when the gesture ends with the {@code ACTION_UP} or {@code ACTION_CANCEL} + DRAGGING, + + // The dragging was cancelled. + // Changes to: + // - IDLE, when the current gesture completes + CANCELLED + } + + private final PointF mTouchDownLocation = new PointF(); + private final PointF mViewInitialPosition = new PointF(); + private final VelocityTracker mVelocityTracker = VelocityTracker.obtain(); + private final long mPressToDragTimeout = ViewConfiguration.getLongPressTimeout() / 2; + private State mState = State.IDLE; + private int mTouchSlop = -1; + private BubbleDragAnimator mAnimator; + @Nullable + private Runnable mLongClickRunnable; + + /** + * Called when the dragging interaction has started + */ + abstract void onDragStart(); + + /** + * Called when the dragging interaction has ended and all the animations have completed + */ + abstract void onDragEnd(); + + /** + * Called when the dragged bubble is released outside of the dismiss target area and will + * move back to its initial position + */ + protected void onDragRelease() { + } + + /** + * Called when the dragged bubble is released inside of the dismiss target area and will get + * dismissed with animation + */ + protected void onDragDismiss() { + } + + @Override + @SuppressLint("ClickableViewAccessibility") + public boolean onTouch(@NonNull View view, @NonNull MotionEvent event) { + updateVelocity(event); + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + return onTouchDown(view, event); + case MotionEvent.ACTION_MOVE: + onTouchMove(view, event); + break; + case MotionEvent.ACTION_UP: + onTouchUp(view, event); + break; + case MotionEvent.ACTION_CANCEL: + onTouchCancel(view, event); + break; + } + return true; + } + + /** + * The touch down starts the interaction and schedules the long click handler. + * + * @param view the view that received the event + * @param event the motion event + * @return true if the gesture should be intercepted and handled, false otherwise. Note if + * the false is returned subsequent events in the gesture won't get reported. + */ + protected boolean onTouchDown(@NonNull View view, @NonNull MotionEvent event) { + mState = State.TOUCHED; + mTouchSlop = ViewConfiguration.get(view.getContext()).getScaledTouchSlop(); + mTouchDownLocation.set(event.getRawX(), event.getRawY()); + mViewInitialPosition.set(view.getTranslationX(), view.getTranslationY()); + setupLongClickHandler(view); + return true; + } + + /** + * The move event drags the view or cancels the interaction if hasn't long clicked yet. + * + * @param view the view that received the event + * @param event the motion event + */ + protected void onTouchMove(@NonNull View view, @NonNull MotionEvent event) { + final float dx = event.getRawX() - mTouchDownLocation.x; + final float dy = event.getRawY() - mTouchDownLocation.y; + switch (mState) { + case TOUCHED: + final boolean movedOut = Math.hypot(dx, dy) > mTouchSlop; + if (movedOut) { + // Moved out of the initial location before the long click was registered + mState = State.CANCELLED; + cleanUpLongClickHandler(view); + } + break; + case DRAGGING: + drag(view, event, dx, dy); + break; + } + } + + /** + * On touch up performs click or finishes the dragging depending on the state. + * + * @param view the view that received the event + * @param event the motion event + */ + protected void onTouchUp(@NonNull View view, @NonNull MotionEvent event) { + switch (mState) { + case TOUCHED: + view.performClick(); + cleanUp(view); + break; + case DRAGGING: + stopDragging(view, event); + break; + default: + cleanUp(view); + break; + } + } + + /** + * The gesture is cancelled and the interaction should clean up and complete. + * + * @param view the view that received the event + * @param event the motion event + */ + protected void onTouchCancel(@NonNull View view, @NonNull MotionEvent event) { + if (mState == State.DRAGGING) { + stopDragging(view, event); + } else { + cleanUp(view); + } + } + + private void startDragging(@NonNull View view) { + onDragStart(); + mActivity.setTaskbarWindowFullscreen(true); + mAnimator = new BubbleDragAnimator(view); + mAnimator.animateFocused(); + mBubbleDismissController.setupDismissView(view, mAnimator); + mBubbleDismissController.showDismissView(); + } + + private void drag(@NonNull View view, @NonNull MotionEvent event, float dx, float dy) { + if (mBubbleDismissController.handleTouchEvent(event)) return; + view.setTranslationX(mViewInitialPosition.x + dx); + view.setTranslationY(mViewInitialPosition.y + dy); + } + + private void stopDragging(@NonNull View view, @NonNull MotionEvent event) { + Runnable onComplete = () -> { + mActivity.setTaskbarWindowFullscreen(false); + cleanUp(view); + onDragEnd(); + }; + + if (mBubbleDismissController.handleTouchEvent(event)) { + onDragDismiss(); + mAnimator.animateDismiss(mViewInitialPosition, onComplete); + } else { + onDragRelease(); + mAnimator.animateToInitialState(mViewInitialPosition, getCurrentVelocity(), + onComplete); + } + mBubbleDismissController.hideDismissView(); + } + + private void setupLongClickHandler(@NonNull View view) { + cleanUpLongClickHandler(view); + mLongClickRunnable = () -> { + // Register long click and start dragging interaction + mState = State.DRAGGING; + startDragging(view); + }; + view.getHandler().postDelayed(mLongClickRunnable, mPressToDragTimeout); + } + + private void cleanUpLongClickHandler(@NonNull View view) { + if (mLongClickRunnable == null || view.getHandler() == null) return; + view.getHandler().removeCallbacks(mLongClickRunnable); + mLongClickRunnable = null; + } + + private void cleanUp(@NonNull View view) { + cleanUpLongClickHandler(view); + mVelocityTracker.clear(); + mState = State.IDLE; + } + + private void updateVelocity(MotionEvent event) { + final float deltaX = event.getRawX() - event.getX(); + final float deltaY = event.getRawY() - event.getY(); + event.offsetLocation(deltaX, deltaY); + mVelocityTracker.addMovement(event); + event.offsetLocation(-deltaX, -deltaY); + } + + private PointF getCurrentVelocity() { + mVelocityTracker.computeCurrentVelocity(/* units = */ 1000); + return new PointF(mVelocityTracker.getXVelocity(), mVelocityTracker.getYVelocity()); + } + } +}