1 /*
2  * Copyright (C) 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.launcher3.taskbar.bubbles;
17 
18 import android.annotation.SuppressLint;
19 import android.graphics.PointF;
20 import android.view.MotionEvent;
21 import android.view.VelocityTracker;
22 import android.view.View;
23 import android.view.ViewConfiguration;
24 
25 import androidx.annotation.NonNull;
26 import androidx.annotation.Nullable;
27 import androidx.dynamicanimation.animation.FloatPropertyCompat;
28 
29 import com.android.launcher3.taskbar.TaskbarActivityContext;
30 import com.android.wm.shell.common.bubbles.BaseBubblePinController.LocationChangeListener;
31 import com.android.wm.shell.common.bubbles.BubbleBarLocation;
32 
33 /**
34  * Controls bubble bar drag interactions.
35  * Interacts with {@link BubbleDismissController}, used by {@link BubbleBarViewController}.
36  * Supported interactions:
37  * - Drag a single bubble view into dismiss target to remove it.
38  * - Drag the bubble stack into dismiss target to remove all.
39  * Restores initial position of dragged view if released outside of the dismiss target.
40  */
41 public class BubbleDragController {
42 
43     /**
44      * Property to update dragged bubble x-translation value.
45      * <p>
46      * When applied to {@link BubbleView}, will use set the translation through
47      * {@link BubbleView#getDragTranslationX()} and {@link BubbleView#setDragTranslationX(float)}
48      * methods.
49      * <p>
50      * When applied to {@link BubbleBarView}, will use {@link View#getTranslationX()} and
51      * {@link View#setTranslationX(float)}.
52      */
53     public static final FloatPropertyCompat<View> DRAG_TRANSLATION_X = new FloatPropertyCompat<>(
54             "dragTranslationX") {
55         @Override
56         public float getValue(View view) {
57             if (view instanceof BubbleView bubbleView) {
58                 return bubbleView.getDragTranslationX();
59             }
60             return view.getTranslationX();
61         }
62 
63         @Override
64         public void setValue(View view, float value) {
65             if (view instanceof BubbleView bubbleView) {
66                 bubbleView.setDragTranslationX(value);
67             } else {
68                 view.setTranslationX(value);
69             }
70         }
71     };
72 
73     private final TaskbarActivityContext mActivity;
74     private BubbleBarController mBubbleBarController;
75     private BubbleBarViewController mBubbleBarViewController;
76     private BubbleDismissController mBubbleDismissController;
77     private BubbleBarPinController mBubbleBarPinController;
78     private BubblePinController mBubblePinController;
79 
BubbleDragController(TaskbarActivityContext activity)80     public BubbleDragController(TaskbarActivityContext activity) {
81         mActivity = activity;
82     }
83 
84     /**
85      * Initializes dependencies when bubble controllers are created.
86      * Should be careful to only access things that were created in constructors for now, as some
87      * controllers may still be waiting for init().
88      */
init(@onNull BubbleControllers bubbleControllers)89     public void init(@NonNull BubbleControllers bubbleControllers) {
90         mBubbleBarController = bubbleControllers.bubbleBarController;
91         mBubbleBarViewController = bubbleControllers.bubbleBarViewController;
92         mBubbleDismissController = bubbleControllers.bubbleDismissController;
93         mBubbleBarPinController = bubbleControllers.bubbleBarPinController;
94         mBubblePinController = bubbleControllers.bubblePinController;
95         mBubbleDismissController.setListener(
96                 stuck -> {
97                     if (stuck) {
98                         mBubbleBarPinController.onStuckToDismissTarget();
99                         mBubblePinController.onStuckToDismissTarget();
100                     }
101                 });
102     }
103 
104     /**
105      * Setup the bubble view for dragging and attach touch listener to it
106      */
107     @SuppressLint("ClickableViewAccessibility")
setupBubbleView(@onNull BubbleView bubbleView)108     public void setupBubbleView(@NonNull BubbleView bubbleView) {
109         if (!(bubbleView.getBubble() instanceof BubbleBarBubble)) {
110             // Don't setup dragging for overflow bubble view
111             return;
112         }
113 
114         bubbleView.setOnTouchListener(new BubbleTouchListener() {
115 
116             private BubbleBarLocation mReleasedLocation = BubbleBarLocation.DEFAULT;
117 
118             private final LocationChangeListener mLocationChangeListener =
119                     new LocationChangeListener() {
120                         @Override
121                         public void onChange(@NonNull BubbleBarLocation location) {
122                             mBubbleBarController.animateBubbleBarLocation(location);
123                         }
124 
125                         @Override
126                         public void onRelease(@NonNull BubbleBarLocation location) {
127                             mReleasedLocation = location;
128                         }
129                     };
130 
131             @Override
132             void onDragStart() {
133                 mBubblePinController.setListener(mLocationChangeListener);
134                 mBubbleBarViewController.onBubbleDragStart(bubbleView);
135                 mBubblePinController.onDragStart(
136                         mBubbleBarViewController.getBubbleBarLocation().isOnLeft(
137                                 bubbleView.isLayoutRtl()));
138             }
139 
140             @Override
141             protected void onDragUpdate(float x, float y, float newTx, float newTy) {
142                 bubbleView.setDragTranslationX(newTx);
143                 bubbleView.setTranslationY(newTy);
144                 mBubblePinController.onDragUpdate(x, y);
145             }
146 
147             @Override
148             protected void onDragRelease() {
149                 mBubblePinController.onDragEnd();
150                 mBubbleBarViewController.onBubbleDragRelease(mReleasedLocation);
151             }
152 
153             @Override
154             protected void onDragDismiss() {
155                 mBubblePinController.onDragEnd();
156                 mBubbleBarViewController.onBubbleDragEnd();
157             }
158 
159             @Override
160             void onDragEnd() {
161                 mBubbleBarController.updateBubbleBarLocation(mReleasedLocation);
162                 mBubbleBarViewController.onBubbleDragEnd();
163                 mBubblePinController.setListener(null);
164             }
165 
166             @Override
167             protected PointF getRestingPosition() {
168                 return mBubbleBarViewController.getDraggedBubbleReleaseTranslation(
169                         getInitialPosition(), mReleasedLocation);
170             }
171         });
172     }
173 
174     /**
175      * Setup the bubble bar view for dragging and attach touch listener to it
176      */
177     @SuppressLint("ClickableViewAccessibility")
setupBubbleBarView(@onNull BubbleBarView bubbleBarView)178     public void setupBubbleBarView(@NonNull BubbleBarView bubbleBarView) {
179         PointF initialRelativePivot = new PointF();
180         bubbleBarView.setOnTouchListener(new BubbleTouchListener() {
181 
182             private BubbleBarLocation mReleasedLocation = BubbleBarLocation.DEFAULT;
183 
184             private final LocationChangeListener mLocationChangeListener =
185                     location -> mReleasedLocation = location;
186 
187             @Override
188             protected boolean onTouchDown(@NonNull View view, @NonNull MotionEvent event) {
189                 if (bubbleBarView.isExpanded()) return false;
190                 return super.onTouchDown(view, event);
191             }
192 
193             @Override
194             void onDragStart() {
195                 mBubbleBarPinController.setListener(mLocationChangeListener);
196                 initialRelativePivot.set(bubbleBarView.getRelativePivotX(),
197                         bubbleBarView.getRelativePivotY());
198                 // By default the bubble bar view pivot is in bottom right corner, while dragging
199                 // it should be centered in order to align it with the dismiss target view
200                 bubbleBarView.setRelativePivot(/* x = */ 0.5f, /* y = */ 0.5f);
201                 bubbleBarView.setIsDragging(true);
202                 mBubbleBarPinController.onDragStart(
203                         bubbleBarView.getBubbleBarLocation().isOnLeft(bubbleBarView.isLayoutRtl()));
204             }
205 
206             @Override
207             protected void onDragUpdate(float x, float y, float newTx, float newTy) {
208                 bubbleBarView.setTranslationX(newTx);
209                 bubbleBarView.setTranslationY(newTy);
210                 mBubbleBarPinController.onDragUpdate(x, y);
211             }
212 
213             @Override
214             protected void onDragRelease() {
215                 mBubbleBarPinController.onDragEnd();
216             }
217 
218             @Override
219             protected void onDragDismiss() {
220                 mBubbleBarPinController.onDragEnd();
221             }
222 
223             @Override
224             void onDragEnd() {
225                 // Make sure to update location as the first thing. Pivot update causes a relayout
226                 mBubbleBarController.updateBubbleBarLocation(mReleasedLocation);
227                 bubbleBarView.setIsDragging(false);
228                 // Restoring the initial pivot for the bubble bar view
229                 bubbleBarView.setRelativePivot(initialRelativePivot.x, initialRelativePivot.y);
230                 mBubbleBarViewController.onBubbleBarDragEnd();
231                 mBubbleBarPinController.setListener(null);
232             }
233 
234             @Override
235             protected PointF getRestingPosition() {
236                 return mBubbleBarViewController.getBubbleBarDragReleaseTranslation(
237                         getInitialPosition(), mReleasedLocation);
238             }
239         });
240     }
241 
242     /**
243      * Bubble touch listener for handling a single bubble view or bubble bar view while dragging.
244      * The dragging starts after "shorter" long click (the long click duration might change):
245      * - When the touch gesture moves out of the {@code ACTION_DOWN} location the dragging
246      * interaction is cancelled.
247      * - When {@code ACTION_UP} happens before long click is registered and there was no significant
248      * movement the view will perform click.
249      * - When the listener registers long click it starts dragging interaction, all the subsequent
250      * {@code ACTION_MOVE} events will drag the view, and the interaction finishes when
251      * {@code ACTION_UP} or {@code ACTION_CANCEL} are received.
252      * Lifecycle methods can be overridden do add extra setup/clean up steps.
253      */
254     private abstract class BubbleTouchListener implements View.OnTouchListener {
255         /**
256          * The internal state of the touch listener
257          */
258         private enum State {
259             // Idle and ready for the touch events.
260             // Changes to:
261             // - TOUCHED, when the {@code ACTION_DOWN} is handled
262             IDLE,
263 
264             // Touch down was handled and the lister is recognising the gestures.
265             // Changes to:
266             // - IDLE, when performs the click
267             // - DRAGGING, when registers the long click and starts dragging interaction
268             // - CANCELLED, when the touch events move out of the initial location before the long
269             // click is recognised
270 
271             TOUCHED,
272 
273             // The long click was registered and the view is being dragged.
274             // Changes to:
275             // - IDLE, when the gesture ends with the {@code ACTION_UP} or {@code ACTION_CANCEL}
276             DRAGGING,
277 
278             // The dragging was cancelled.
279             // Changes to:
280             // - IDLE, when the current gesture completes
281             CANCELLED
282         }
283 
284         private final PointF mTouchDownLocation = new PointF();
285         private final PointF mViewInitialPosition = new PointF();
286         private final VelocityTracker mVelocityTracker = VelocityTracker.obtain();
287         private final long mPressToDragTimeout = ViewConfiguration.getLongPressTimeout() / 2;
288         private State mState = State.IDLE;
289         private int mTouchSlop = -1;
290         private BubbleDragAnimator mAnimator;
291         @Nullable
292         private Runnable mLongClickRunnable;
293 
294         /**
295          * Called when the dragging interaction has started
296          */
onDragStart()297         abstract void onDragStart();
298 
299         /**
300          * Called when bubble is dragged to new coordinates.
301          * Not called while bubble is stuck to the dismiss target.
302          */
onDragUpdate(float x, float y, float newTx, float newTy)303         protected abstract void onDragUpdate(float x, float y, float newTx, float newTy);
304 
305         /**
306          * Called when the dragging interaction has ended and all the animations have completed
307          */
onDragEnd()308         abstract void onDragEnd();
309 
310         /**
311          * Called when the dragged bubble is released outside of the dismiss target area and will
312          * move back to its initial position
313          */
onDragRelease()314         protected void onDragRelease() {
315         }
316 
317         /**
318          * Called when the dragged bubble is released inside of the dismiss target area and will get
319          * dismissed with animation
320          */
onDragDismiss()321         protected void onDragDismiss() {
322         }
323 
324         /**
325          * Get the initial position of the view when drag started
326          */
getInitialPosition()327         protected PointF getInitialPosition() {
328             return mViewInitialPosition;
329         }
330 
331         /**
332          * Get the resting position of the view when drag is released
333          */
getRestingPosition()334         protected PointF getRestingPosition() {
335             return mViewInitialPosition;
336         }
337 
338         @Override
339         @SuppressLint("ClickableViewAccessibility")
onTouch(@onNull View view, @NonNull MotionEvent event)340         public boolean onTouch(@NonNull View view, @NonNull MotionEvent event) {
341             updateVelocity(event);
342             switch (event.getActionMasked()) {
343                 case MotionEvent.ACTION_DOWN:
344                     return onTouchDown(view, event);
345                 case MotionEvent.ACTION_MOVE:
346                     onTouchMove(view, event);
347                     break;
348                 case MotionEvent.ACTION_UP:
349                     onTouchUp(view, event);
350                     break;
351                 case MotionEvent.ACTION_CANCEL:
352                     onTouchCancel(view, event);
353                     break;
354             }
355             return true;
356         }
357 
358         /**
359          * The touch down starts the interaction and schedules the long click handler.
360          *
361          * @param view  the view that received the event
362          * @param event the motion event
363          * @return true if the gesture should be intercepted and handled, false otherwise. Note if
364          * the false is returned subsequent events in the gesture won't get reported.
365          */
onTouchDown(@onNull View view, @NonNull MotionEvent event)366         protected boolean onTouchDown(@NonNull View view, @NonNull MotionEvent event) {
367             mState = State.TOUCHED;
368             mTouchSlop = ViewConfiguration.get(view.getContext()).getScaledTouchSlop();
369             mTouchDownLocation.set(event.getRawX(), event.getRawY());
370             mViewInitialPosition.set(view.getTranslationX(), view.getTranslationY());
371             setupLongClickHandler(view);
372             return true;
373         }
374 
375         /**
376          * The move event drags the view or cancels the interaction if hasn't long clicked yet.
377          *
378          * @param view  the view that received the event
379          * @param event the motion event
380          */
onTouchMove(@onNull View view, @NonNull MotionEvent event)381         protected void onTouchMove(@NonNull View view, @NonNull MotionEvent event) {
382             float rawX = event.getRawX();
383             float rawY = event.getRawY();
384             final float dx = rawX - mTouchDownLocation.x;
385             final float dy = rawY - mTouchDownLocation.y;
386             switch (mState) {
387                 case TOUCHED:
388                     final boolean movedOut = Math.hypot(dx, dy) > mTouchSlop;
389                     if (movedOut) {
390                         // Moved out of the initial location before the long click was registered
391                         mState = State.CANCELLED;
392                         cleanUpLongClickHandler(view);
393                     }
394                     break;
395                 case DRAGGING:
396                     drag(view, event, dx, dy, rawX, rawY);
397                     break;
398             }
399         }
400 
401         /**
402          * On touch up performs click or finishes the dragging depending on the state.
403          *
404          * @param view  the view that received the event
405          * @param event the motion event
406          */
onTouchUp(@onNull View view, @NonNull MotionEvent event)407         protected void onTouchUp(@NonNull View view, @NonNull MotionEvent event) {
408             switch (mState) {
409                 case TOUCHED:
410                     view.performClick();
411                     cleanUp(view);
412                     break;
413                 case DRAGGING:
414                     stopDragging(view, event);
415                     break;
416                 default:
417                     cleanUp(view);
418                     break;
419             }
420         }
421 
422         /**
423          * The gesture is cancelled and the interaction should clean up and complete.
424          *
425          * @param view  the view that received the event
426          * @param event the motion event
427          */
onTouchCancel(@onNull View view, @NonNull MotionEvent event)428         protected void onTouchCancel(@NonNull View view, @NonNull MotionEvent event) {
429             if (mState == State.DRAGGING) {
430                 stopDragging(view, event);
431             } else {
432                 cleanUp(view);
433             }
434         }
435 
startDragging(@onNull View view)436         private void startDragging(@NonNull View view) {
437             onDragStart();
438             mActivity.setTaskbarWindowFullscreen(true);
439             mAnimator = new BubbleDragAnimator(view);
440             mAnimator.animateFocused();
441             mBubbleDismissController.setupDismissView(view, mAnimator);
442             mBubbleDismissController.showDismissView();
443         }
444 
drag(@onNull View view, @NonNull MotionEvent event, float dx, float dy, float x, float y)445         private void drag(@NonNull View view, @NonNull MotionEvent event, float dx, float dy,
446                 float x, float y) {
447             if (mBubbleDismissController.handleTouchEvent(event)) return;
448             final float newTx = mViewInitialPosition.x + dx;
449             final float newTy = mViewInitialPosition.y + dy;
450             onDragUpdate(x, y, newTx, newTy);
451         }
452 
stopDragging(@onNull View view, @NonNull MotionEvent event)453         private void stopDragging(@NonNull View view, @NonNull MotionEvent event) {
454             Runnable onComplete = () -> {
455                 mActivity.setTaskbarWindowFullscreen(false);
456                 cleanUp(view);
457                 onDragEnd();
458             };
459 
460             if (mBubbleDismissController.handleTouchEvent(event)) {
461                 onDragDismiss();
462                 mAnimator.animateDismiss(mViewInitialPosition, onComplete);
463             } else {
464                 onDragRelease();
465                 mAnimator.animateToRestingState(getRestingPosition(), getCurrentVelocity(),
466                         onComplete);
467             }
468             mBubbleDismissController.hideDismissView();
469         }
470 
setupLongClickHandler(@onNull View view)471         private void setupLongClickHandler(@NonNull View view) {
472             cleanUpLongClickHandler(view);
473             mLongClickRunnable = () -> {
474                 // Register long click and start dragging interaction
475                 mState = State.DRAGGING;
476                 startDragging(view);
477             };
478             view.getHandler().postDelayed(mLongClickRunnable, mPressToDragTimeout);
479         }
480 
cleanUpLongClickHandler(@onNull View view)481         private void cleanUpLongClickHandler(@NonNull View view) {
482             if (mLongClickRunnable == null || view.getHandler() == null) return;
483             view.getHandler().removeCallbacks(mLongClickRunnable);
484             mLongClickRunnable = null;
485         }
486 
cleanUp(@onNull View view)487         private void cleanUp(@NonNull View view) {
488             cleanUpLongClickHandler(view);
489             mVelocityTracker.clear();
490             mState = State.IDLE;
491         }
492 
updateVelocity(MotionEvent event)493         private void updateVelocity(MotionEvent event) {
494             final float deltaX = event.getRawX() - event.getX();
495             final float deltaY = event.getRawY() - event.getY();
496             event.offsetLocation(deltaX, deltaY);
497             mVelocityTracker.addMovement(event);
498             event.offsetLocation(-deltaX, -deltaY);
499         }
500 
getCurrentVelocity()501         private PointF getCurrentVelocity() {
502             mVelocityTracker.computeCurrentVelocity(/* units = */ 1000);
503             return new PointF(mVelocityTracker.getXVelocity(), mVelocityTracker.getYVelocity());
504         }
505     }
506 }
507