/*
* 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 androidx.dynamicanimation.animation.FloatPropertyCompat;
import com.android.launcher3.taskbar.TaskbarActivityContext;
import com.android.wm.shell.common.bubbles.BaseBubblePinController.LocationChangeListener;
import com.android.wm.shell.common.bubbles.BubbleBarLocation;
/**
* Controls bubble bar drag interactions.
* 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 {
/**
* Property to update dragged bubble x-translation value.
*
* When applied to {@link BubbleView}, will use set the translation through
* {@link BubbleView#getDragTranslationX()} and {@link BubbleView#setDragTranslationX(float)}
* methods.
*
* When applied to {@link BubbleBarView}, will use {@link View#getTranslationX()} and
* {@link View#setTranslationX(float)}.
*/
public static final FloatPropertyCompat DRAG_TRANSLATION_X = new FloatPropertyCompat<>(
"dragTranslationX") {
@Override
public float getValue(View view) {
if (view instanceof BubbleView bubbleView) {
return bubbleView.getDragTranslationX();
}
return view.getTranslationX();
}
@Override
public void setValue(View view, float value) {
if (view instanceof BubbleView bubbleView) {
bubbleView.setDragTranslationX(value);
} else {
view.setTranslationX(value);
}
}
};
private final TaskbarActivityContext mActivity;
private BubbleBarController mBubbleBarController;
private BubbleBarViewController mBubbleBarViewController;
private BubbleDismissController mBubbleDismissController;
private BubbleBarPinController mBubbleBarPinController;
private BubblePinController mBubblePinController;
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) {
mBubbleBarController = bubbleControllers.bubbleBarController;
mBubbleBarViewController = bubbleControllers.bubbleBarViewController;
mBubbleDismissController = bubbleControllers.bubbleDismissController;
mBubbleBarPinController = bubbleControllers.bubbleBarPinController;
mBubblePinController = bubbleControllers.bubblePinController;
mBubbleDismissController.setListener(
stuck -> {
if (stuck) {
mBubbleBarPinController.onStuckToDismissTarget();
mBubblePinController.onStuckToDismissTarget();
}
});
}
/**
* 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() {
private BubbleBarLocation mReleasedLocation = BubbleBarLocation.DEFAULT;
private final LocationChangeListener mLocationChangeListener =
new LocationChangeListener() {
@Override
public void onChange(@NonNull BubbleBarLocation location) {
mBubbleBarController.animateBubbleBarLocation(location);
}
@Override
public void onRelease(@NonNull BubbleBarLocation location) {
mReleasedLocation = location;
}
};
@Override
void onDragStart() {
mBubblePinController.setListener(mLocationChangeListener);
mBubbleBarViewController.onBubbleDragStart(bubbleView);
mBubblePinController.onDragStart(
mBubbleBarViewController.getBubbleBarLocation().isOnLeft(
bubbleView.isLayoutRtl()));
}
@Override
protected void onDragUpdate(float x, float y, float newTx, float newTy) {
bubbleView.setDragTranslationX(newTx);
bubbleView.setTranslationY(newTy);
mBubblePinController.onDragUpdate(x, y);
}
@Override
protected void onDragRelease() {
mBubblePinController.onDragEnd();
mBubbleBarViewController.onBubbleDragRelease(mReleasedLocation);
}
@Override
protected void onDragDismiss() {
mBubblePinController.onDragEnd();
mBubbleBarViewController.onBubbleDragEnd();
}
@Override
void onDragEnd() {
mBubbleBarController.updateBubbleBarLocation(mReleasedLocation);
mBubbleBarViewController.onBubbleDragEnd();
mBubblePinController.setListener(null);
}
@Override
protected PointF getRestingPosition() {
return mBubbleBarViewController.getDraggedBubbleReleaseTranslation(
getInitialPosition(), mReleasedLocation);
}
});
}
/**
* 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() {
private BubbleBarLocation mReleasedLocation = BubbleBarLocation.DEFAULT;
private final LocationChangeListener mLocationChangeListener =
location -> mReleasedLocation = location;
@Override
protected boolean onTouchDown(@NonNull View view, @NonNull MotionEvent event) {
if (bubbleBarView.isExpanded()) return false;
return super.onTouchDown(view, event);
}
@Override
void onDragStart() {
mBubbleBarPinController.setListener(mLocationChangeListener);
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);
bubbleBarView.setIsDragging(true);
mBubbleBarPinController.onDragStart(
bubbleBarView.getBubbleBarLocation().isOnLeft(bubbleBarView.isLayoutRtl()));
}
@Override
protected void onDragUpdate(float x, float y, float newTx, float newTy) {
bubbleBarView.setTranslationX(newTx);
bubbleBarView.setTranslationY(newTy);
mBubbleBarPinController.onDragUpdate(x, y);
}
@Override
protected void onDragRelease() {
mBubbleBarPinController.onDragEnd();
}
@Override
protected void onDragDismiss() {
mBubbleBarPinController.onDragEnd();
}
@Override
void onDragEnd() {
// Make sure to update location as the first thing. Pivot update causes a relayout
mBubbleBarController.updateBubbleBarLocation(mReleasedLocation);
bubbleBarView.setIsDragging(false);
// Restoring the initial pivot for the bubble bar view
bubbleBarView.setRelativePivot(initialRelativePivot.x, initialRelativePivot.y);
mBubbleBarViewController.onBubbleBarDragEnd();
mBubbleBarPinController.setListener(null);
}
@Override
protected PointF getRestingPosition() {
return mBubbleBarViewController.getBubbleBarDragReleaseTranslation(
getInitialPosition(), mReleasedLocation);
}
});
}
/**
* 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 bubble is dragged to new coordinates.
* Not called while bubble is stuck to the dismiss target.
*/
protected abstract void onDragUpdate(float x, float y, float newTx, float newTy);
/**
* 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() {
}
/**
* Get the initial position of the view when drag started
*/
protected PointF getInitialPosition() {
return mViewInitialPosition;
}
/**
* Get the resting position of the view when drag is released
*/
protected PointF getRestingPosition() {
return mViewInitialPosition;
}
@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) {
float rawX = event.getRawX();
float rawY = event.getRawY();
final float dx = rawX - mTouchDownLocation.x;
final float dy = rawY - 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, rawX, rawY);
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,
float x, float y) {
if (mBubbleDismissController.handleTouchEvent(event)) return;
final float newTx = mViewInitialPosition.x + dx;
final float newTy = mViewInitialPosition.y + dy;
onDragUpdate(x, y, newTx, newTy);
}
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.animateToRestingState(getRestingPosition(), 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());
}
}
}