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
This commit is contained in:
Ivan Tkachenko
2023-07-26 23:50:09 +01:00
parent 18373d2a54
commit 90a769d1d3
9 changed files with 898 additions and 7 deletions
@@ -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());
}
}
}