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