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 28 import com.android.launcher3.taskbar.TaskbarActivityContext; 29 30 /** 31 * Controls bubble bar drag to dismiss interaction. 32 * Interacts with {@link BubbleDismissController}, used by {@link BubbleBarViewController}. 33 * Supported interactions: 34 * - Drag a single bubble view into dismiss target to remove it. 35 * - Drag the bubble stack into dismiss target to remove all. 36 * Restores initial position of dragged view if released outside of the dismiss target. 37 */ 38 public class BubbleDragController { 39 private final TaskbarActivityContext mActivity; 40 private BubbleBarViewController mBubbleBarViewController; 41 private BubbleDismissController mBubbleDismissController; 42 BubbleDragController(TaskbarActivityContext activity)43 public BubbleDragController(TaskbarActivityContext activity) { 44 mActivity = activity; 45 } 46 47 /** 48 * Initializes dependencies when bubble controllers are created. 49 * Should be careful to only access things that were created in constructors for now, as some 50 * controllers may still be waiting for init(). 51 */ init(@onNull BubbleControllers bubbleControllers)52 public void init(@NonNull BubbleControllers bubbleControllers) { 53 mBubbleBarViewController = bubbleControllers.bubbleBarViewController; 54 mBubbleDismissController = bubbleControllers.bubbleDismissController; 55 } 56 57 /** 58 * Setup the bubble view for dragging and attach touch listener to it 59 */ 60 @SuppressLint("ClickableViewAccessibility") setupBubbleView(@onNull BubbleView bubbleView)61 public void setupBubbleView(@NonNull BubbleView bubbleView) { 62 if (!(bubbleView.getBubble() instanceof BubbleBarBubble)) { 63 // Don't setup dragging for overflow bubble view 64 return; 65 } 66 67 bubbleView.setOnTouchListener(new BubbleTouchListener() { 68 @Override 69 void onDragStart() { 70 mBubbleBarViewController.onDragStart(bubbleView); 71 } 72 73 @Override 74 void onDragEnd() { 75 mBubbleBarViewController.onDragEnd(); 76 } 77 78 @Override 79 protected void onDragRelease() { 80 mBubbleBarViewController.onDragRelease(bubbleView); 81 } 82 }); 83 } 84 85 /** 86 * Setup the bubble bar view for dragging and attach touch listener to it 87 */ 88 @SuppressLint("ClickableViewAccessibility") setupBubbleBarView(@onNull BubbleBarView bubbleBarView)89 public void setupBubbleBarView(@NonNull BubbleBarView bubbleBarView) { 90 PointF initialRelativePivot = new PointF(); 91 bubbleBarView.setOnTouchListener(new BubbleTouchListener() { 92 @Override 93 protected boolean onTouchDown(@NonNull View view, @NonNull MotionEvent event) { 94 if (bubbleBarView.isExpanded()) return false; 95 return super.onTouchDown(view, event); 96 } 97 98 @Override 99 void onDragStart() { 100 initialRelativePivot.set(bubbleBarView.getRelativePivotX(), 101 bubbleBarView.getRelativePivotY()); 102 // By default the bubble bar view pivot is in bottom right corner, while dragging 103 // it should be centered in order to align it with the dismiss target view 104 bubbleBarView.setRelativePivot(/* x = */ 0.5f, /* y = */ 0.5f); 105 } 106 107 @Override 108 void onDragEnd() { 109 // Restoring the initial pivot for the bubble bar view 110 bubbleBarView.setRelativePivot(initialRelativePivot.x, initialRelativePivot.y); 111 } 112 }); 113 } 114 115 /** 116 * Bubble touch listener for handling a single bubble view or bubble bar view while dragging. 117 * The dragging starts after "shorter" long click (the long click duration might change): 118 * - When the touch gesture moves out of the {@code ACTION_DOWN} location the dragging 119 * interaction is cancelled. 120 * - When {@code ACTION_UP} happens before long click is registered and there was no significant 121 * movement the view will perform click. 122 * - When the listener registers long click it starts dragging interaction, all the subsequent 123 * {@code ACTION_MOVE} events will drag the view, and the interaction finishes when 124 * {@code ACTION_UP} or {@code ACTION_CANCEL} are received. 125 * Lifecycle methods can be overridden do add extra setup/clean up steps. 126 */ 127 private abstract class BubbleTouchListener implements View.OnTouchListener { 128 /** 129 * The internal state of the touch listener 130 */ 131 private enum State { 132 // Idle and ready for the touch events. 133 // Changes to: 134 // - TOUCHED, when the {@code ACTION_DOWN} is handled 135 IDLE, 136 137 // Touch down was handled and the lister is recognising the gestures. 138 // Changes to: 139 // - IDLE, when performs the click 140 // - DRAGGING, when registers the long click and starts dragging interaction 141 // - CANCELLED, when the touch events move out of the initial location before the long 142 // click is recognised 143 144 TOUCHED, 145 146 // The long click was registered and the view is being dragged. 147 // Changes to: 148 // - IDLE, when the gesture ends with the {@code ACTION_UP} or {@code ACTION_CANCEL} 149 DRAGGING, 150 151 // The dragging was cancelled. 152 // Changes to: 153 // - IDLE, when the current gesture completes 154 CANCELLED 155 } 156 157 private final PointF mTouchDownLocation = new PointF(); 158 private final PointF mViewInitialPosition = new PointF(); 159 private final VelocityTracker mVelocityTracker = VelocityTracker.obtain(); 160 private final long mPressToDragTimeout = ViewConfiguration.getLongPressTimeout() / 2; 161 private State mState = State.IDLE; 162 private int mTouchSlop = -1; 163 private BubbleDragAnimator mAnimator; 164 @Nullable 165 private Runnable mLongClickRunnable; 166 167 /** 168 * Called when the dragging interaction has started 169 */ onDragStart()170 abstract void onDragStart(); 171 172 /** 173 * Called when the dragging interaction has ended and all the animations have completed 174 */ onDragEnd()175 abstract void onDragEnd(); 176 177 /** 178 * Called when the dragged bubble is released outside of the dismiss target area and will 179 * move back to its initial position 180 */ onDragRelease()181 protected void onDragRelease() { 182 } 183 184 /** 185 * Called when the dragged bubble is released inside of the dismiss target area and will get 186 * dismissed with animation 187 */ onDragDismiss()188 protected void onDragDismiss() { 189 } 190 191 @Override 192 @SuppressLint("ClickableViewAccessibility") onTouch(@onNull View view, @NonNull MotionEvent event)193 public boolean onTouch(@NonNull View view, @NonNull MotionEvent event) { 194 updateVelocity(event); 195 switch (event.getActionMasked()) { 196 case MotionEvent.ACTION_DOWN: 197 return onTouchDown(view, event); 198 case MotionEvent.ACTION_MOVE: 199 onTouchMove(view, event); 200 break; 201 case MotionEvent.ACTION_UP: 202 onTouchUp(view, event); 203 break; 204 case MotionEvent.ACTION_CANCEL: 205 onTouchCancel(view, event); 206 break; 207 } 208 return true; 209 } 210 211 /** 212 * The touch down starts the interaction and schedules the long click handler. 213 * 214 * @param view the view that received the event 215 * @param event the motion event 216 * @return true if the gesture should be intercepted and handled, false otherwise. Note if 217 * the false is returned subsequent events in the gesture won't get reported. 218 */ onTouchDown(@onNull View view, @NonNull MotionEvent event)219 protected boolean onTouchDown(@NonNull View view, @NonNull MotionEvent event) { 220 mState = State.TOUCHED; 221 mTouchSlop = ViewConfiguration.get(view.getContext()).getScaledTouchSlop(); 222 mTouchDownLocation.set(event.getRawX(), event.getRawY()); 223 mViewInitialPosition.set(view.getTranslationX(), view.getTranslationY()); 224 setupLongClickHandler(view); 225 return true; 226 } 227 228 /** 229 * The move event drags the view or cancels the interaction if hasn't long clicked yet. 230 * 231 * @param view the view that received the event 232 * @param event the motion event 233 */ onTouchMove(@onNull View view, @NonNull MotionEvent event)234 protected void onTouchMove(@NonNull View view, @NonNull MotionEvent event) { 235 final float dx = event.getRawX() - mTouchDownLocation.x; 236 final float dy = event.getRawY() - mTouchDownLocation.y; 237 switch (mState) { 238 case TOUCHED: 239 final boolean movedOut = Math.hypot(dx, dy) > mTouchSlop; 240 if (movedOut) { 241 // Moved out of the initial location before the long click was registered 242 mState = State.CANCELLED; 243 cleanUpLongClickHandler(view); 244 } 245 break; 246 case DRAGGING: 247 drag(view, event, dx, dy); 248 break; 249 } 250 } 251 252 /** 253 * On touch up performs click or finishes the dragging depending on the state. 254 * 255 * @param view the view that received the event 256 * @param event the motion event 257 */ onTouchUp(@onNull View view, @NonNull MotionEvent event)258 protected void onTouchUp(@NonNull View view, @NonNull MotionEvent event) { 259 switch (mState) { 260 case TOUCHED: 261 view.performClick(); 262 cleanUp(view); 263 break; 264 case DRAGGING: 265 stopDragging(view, event); 266 break; 267 default: 268 cleanUp(view); 269 break; 270 } 271 } 272 273 /** 274 * The gesture is cancelled and the interaction should clean up and complete. 275 * 276 * @param view the view that received the event 277 * @param event the motion event 278 */ onTouchCancel(@onNull View view, @NonNull MotionEvent event)279 protected void onTouchCancel(@NonNull View view, @NonNull MotionEvent event) { 280 if (mState == State.DRAGGING) { 281 stopDragging(view, event); 282 } else { 283 cleanUp(view); 284 } 285 } 286 startDragging(@onNull View view)287 private void startDragging(@NonNull View view) { 288 onDragStart(); 289 mActivity.setTaskbarWindowFullscreen(true); 290 mAnimator = new BubbleDragAnimator(view); 291 mAnimator.animateFocused(); 292 mBubbleDismissController.setupDismissView(view, mAnimator); 293 mBubbleDismissController.showDismissView(); 294 } 295 drag(@onNull View view, @NonNull MotionEvent event, float dx, float dy)296 private void drag(@NonNull View view, @NonNull MotionEvent event, float dx, float dy) { 297 if (mBubbleDismissController.handleTouchEvent(event)) return; 298 view.setTranslationX(mViewInitialPosition.x + dx); 299 view.setTranslationY(mViewInitialPosition.y + dy); 300 } 301 stopDragging(@onNull View view, @NonNull MotionEvent event)302 private void stopDragging(@NonNull View view, @NonNull MotionEvent event) { 303 Runnable onComplete = () -> { 304 mActivity.setTaskbarWindowFullscreen(false); 305 cleanUp(view); 306 onDragEnd(); 307 }; 308 309 if (mBubbleDismissController.handleTouchEvent(event)) { 310 onDragDismiss(); 311 mAnimator.animateDismiss(mViewInitialPosition, onComplete); 312 } else { 313 onDragRelease(); 314 mAnimator.animateToInitialState(mViewInitialPosition, getCurrentVelocity(), 315 onComplete); 316 } 317 mBubbleDismissController.hideDismissView(); 318 } 319 setupLongClickHandler(@onNull View view)320 private void setupLongClickHandler(@NonNull View view) { 321 cleanUpLongClickHandler(view); 322 mLongClickRunnable = () -> { 323 // Register long click and start dragging interaction 324 mState = State.DRAGGING; 325 startDragging(view); 326 }; 327 view.getHandler().postDelayed(mLongClickRunnable, mPressToDragTimeout); 328 } 329 cleanUpLongClickHandler(@onNull View view)330 private void cleanUpLongClickHandler(@NonNull View view) { 331 if (mLongClickRunnable == null || view.getHandler() == null) return; 332 view.getHandler().removeCallbacks(mLongClickRunnable); 333 mLongClickRunnable = null; 334 } 335 cleanUp(@onNull View view)336 private void cleanUp(@NonNull View view) { 337 cleanUpLongClickHandler(view); 338 mVelocityTracker.clear(); 339 mState = State.IDLE; 340 } 341 updateVelocity(MotionEvent event)342 private void updateVelocity(MotionEvent event) { 343 final float deltaX = event.getRawX() - event.getX(); 344 final float deltaY = event.getRawY() - event.getY(); 345 event.offsetLocation(deltaX, deltaY); 346 mVelocityTracker.addMovement(event); 347 event.offsetLocation(-deltaX, -deltaY); 348 } 349 getCurrentVelocity()350 private PointF getCurrentVelocity() { 351 mVelocityTracker.computeCurrentVelocity(/* units = */ 1000); 352 return new PointF(mVelocityTracker.getXVelocity(), mVelocityTracker.getYVelocity()); 353 } 354 } 355 } 356