1 /* 2 * Copyright (C) 2012 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 17 package com.android.systemui.bubbles; 18 19 import android.content.Context; 20 import android.graphics.PointF; 21 import android.os.Handler; 22 import android.view.MotionEvent; 23 import android.view.VelocityTracker; 24 import android.view.View; 25 import android.view.ViewConfiguration; 26 27 import com.android.systemui.Dependency; 28 29 /** 30 * Handles interpreting touches on a {@link BubbleStackView}. This includes expanding, collapsing, 31 * dismissing, and flings. 32 */ 33 class BubbleTouchHandler implements View.OnTouchListener { 34 /** Velocity required to dismiss the stack without dragging it into the dismiss target. */ 35 private static final float STACK_DISMISS_MIN_VELOCITY = 4000f; 36 37 /** 38 * Velocity required to dismiss an individual bubble without dragging it into the dismiss 39 * target. 40 * 41 * This is higher than the stack dismiss velocity since unlike the stack, a downward fling could 42 * also be an attempted gesture to return the bubble to the row of expanded bubbles, which would 43 * usually be below the dragged bubble. By increasing the required velocity, it's less likely 44 * that the user is trying to drop it back into the row vs. fling it away. 45 */ 46 private static final float INDIVIDUAL_BUBBLE_DISMISS_MIN_VELOCITY = 6000f; 47 48 private static final String TAG = "BubbleTouchHandler"; 49 /** 50 * When the stack is flung towards the bottom of the screen, it'll be dismissed if it's flung 51 * towards the center of the screen (where the dismiss target is). This value is the width of 52 * the target area to be considered 'towards the target'. For example 50% means that the stack 53 * needs to be flung towards the middle 50%, and the 25% on the left and right sides won't 54 * count. 55 */ 56 private static final float DISMISS_FLING_TARGET_WIDTH_PERCENT = 0.5f; 57 58 private final PointF mTouchDown = new PointF(); 59 private final PointF mViewPositionOnTouchDown = new PointF(); 60 private final BubbleStackView mStack; 61 private final BubbleData mBubbleData; 62 63 private BubbleController mController = Dependency.get(BubbleController.class); 64 65 private boolean mMovedEnough; 66 private int mTouchSlopSquared; 67 private VelocityTracker mVelocityTracker; 68 69 private boolean mInDismissTarget; 70 private Handler mHandler = new Handler(); 71 72 /** View that was initially touched, when we received the first ACTION_DOWN event. */ 73 private View mTouchedView; 74 75 BubbleTouchHandler(BubbleStackView stackView, 76 BubbleData bubbleData, Context context) { 77 final int touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); 78 mTouchSlopSquared = touchSlop * touchSlop; 79 mBubbleData = bubbleData; 80 mStack = stackView; 81 } 82 83 @Override 84 public boolean onTouch(View v, MotionEvent event) { 85 final int action = event.getActionMasked(); 86 87 // If we aren't currently in the process of touching a view, figure out what we're touching. 88 // It'll be the stack, an individual bubble, or nothing. 89 if (mTouchedView == null) { 90 mTouchedView = mStack.getTargetView(event); 91 } 92 93 // If this is an ACTION_OUTSIDE event, or the stack reported that we aren't touching 94 // anything, collapse the stack. 95 if (action == MotionEvent.ACTION_OUTSIDE || mTouchedView == null) { 96 mBubbleData.setExpanded(false); 97 resetForNextGesture(); 98 return false; 99 } 100 101 final boolean isStack = mStack.equals(mTouchedView); 102 final boolean isFlyout = mStack.getFlyoutView().equals(mTouchedView); 103 final float rawX = event.getRawX(); 104 final float rawY = event.getRawY(); 105 106 // The coordinates of the touch event, in terms of the touched view's position. 107 final float viewX = mViewPositionOnTouchDown.x + rawX - mTouchDown.x; 108 final float viewY = mViewPositionOnTouchDown.y + rawY - mTouchDown.y; 109 switch (action) { 110 case MotionEvent.ACTION_DOWN: 111 trackMovement(event); 112 113 mTouchDown.set(rawX, rawY); 114 mStack.onGestureStart(); 115 116 if (isStack) { 117 mViewPositionOnTouchDown.set(mStack.getStackPosition()); 118 mStack.onDragStart(); 119 } else if (isFlyout) { 120 mStack.onFlyoutDragStart(); 121 } else { 122 mViewPositionOnTouchDown.set( 123 mTouchedView.getTranslationX(), mTouchedView.getTranslationY()); 124 mStack.onBubbleDragStart(mTouchedView); 125 } 126 127 break; 128 case MotionEvent.ACTION_MOVE: 129 trackMovement(event); 130 final float deltaX = rawX - mTouchDown.x; 131 final float deltaY = rawY - mTouchDown.y; 132 133 if ((deltaX * deltaX) + (deltaY * deltaY) > mTouchSlopSquared && !mMovedEnough) { 134 mMovedEnough = true; 135 } 136 137 if (mMovedEnough) { 138 if (isStack) { 139 mStack.onDragged(viewX, viewY); 140 } else if (isFlyout) { 141 mStack.onFlyoutDragged(deltaX); 142 } else { 143 mStack.onBubbleDragged(mTouchedView, viewX, viewY); 144 } 145 } 146 147 final boolean currentlyInDismissTarget = mStack.isInDismissTarget(event); 148 if (currentlyInDismissTarget != mInDismissTarget) { 149 mInDismissTarget = currentlyInDismissTarget; 150 151 mVelocityTracker.computeCurrentVelocity(/* maxVelocity */ 1000); 152 final float velX = mVelocityTracker.getXVelocity(); 153 final float velY = mVelocityTracker.getYVelocity(); 154 155 // If the touch event is within the dismiss target, magnet the stack to it. 156 if (!isFlyout) { 157 mStack.animateMagnetToDismissTarget( 158 mTouchedView, mInDismissTarget, viewX, viewY, velX, velY); 159 } 160 } 161 break; 162 163 case MotionEvent.ACTION_CANCEL: 164 resetForNextGesture(); 165 break; 166 167 case MotionEvent.ACTION_UP: 168 trackMovement(event); 169 mVelocityTracker.computeCurrentVelocity(/* maxVelocity */ 1000); 170 final float velX = mVelocityTracker.getXVelocity(); 171 final float velY = mVelocityTracker.getYVelocity(); 172 173 final boolean shouldDismiss = 174 isStack 175 ? mInDismissTarget 176 || isFastFlingTowardsDismissTarget(rawX, rawY, velX, velY) 177 : mInDismissTarget 178 || velY > INDIVIDUAL_BUBBLE_DISMISS_MIN_VELOCITY; 179 180 if (isFlyout && mMovedEnough) { 181 mStack.onFlyoutDragFinished(rawX - mTouchDown.x /* deltaX */, velX); 182 } else if (shouldDismiss) { 183 final String individualBubbleKey = 184 isStack ? null : ((BubbleView) mTouchedView).getKey(); 185 mStack.magnetToStackIfNeededThenAnimateDismissal(mTouchedView, velX, velY, 186 () -> { 187 if (isStack) { 188 mController.dismissStack(BubbleController.DISMISS_USER_GESTURE); 189 } else { 190 mController.removeBubble( 191 individualBubbleKey, 192 BubbleController.DISMISS_USER_GESTURE); 193 } 194 }); 195 } else if (isFlyout) { 196 // TODO(b/129768381): Expand if tapped, dismiss if swiped away. 197 if (!mBubbleData.isExpanded() && !mMovedEnough) { 198 mBubbleData.setExpanded(true); 199 } 200 } else if (mMovedEnough) { 201 if (isStack) { 202 mStack.onDragFinish(viewX, viewY, velX, velY); 203 } else { 204 mStack.onBubbleDragFinish(mTouchedView, viewX, viewY, velX, velY); 205 } 206 } else if (mTouchedView == mStack.getExpandedBubbleView()) { 207 mBubbleData.setExpanded(false); 208 } else if (isStack || isFlyout) { 209 // Toggle expansion 210 mBubbleData.setExpanded(!mBubbleData.isExpanded()); 211 } else { 212 final String key = ((BubbleView) mTouchedView).getKey(); 213 mBubbleData.setSelectedBubble(mBubbleData.getBubbleWithKey(key)); 214 } 215 216 resetForNextGesture(); 217 break; 218 } 219 220 return true; 221 } 222 223 /** 224 * Whether the given touch data represents a powerful fling towards the bottom-center of the 225 * screen (the dismiss target). 226 */ 227 private boolean isFastFlingTowardsDismissTarget( 228 float rawX, float rawY, float velX, float velY) { 229 // Not a fling downward towards the target if velocity is zero or negative. 230 if (velY <= 0) { 231 return false; 232 } 233 234 float bottomOfScreenInterceptX = rawX; 235 236 // Only do math if the X velocity is non-zero, otherwise X won't change. 237 if (velX != 0) { 238 // Rise over run... 239 final float slope = velY / velX; 240 // ...y = mx + b, b = y / mx... 241 final float yIntercept = rawY - slope * rawX; 242 // ...calculate the x value when y = bottom of the screen. 243 bottomOfScreenInterceptX = (mStack.getHeight() - yIntercept) / slope; 244 } 245 246 final float dismissTargetWidth = 247 mStack.getWidth() * DISMISS_FLING_TARGET_WIDTH_PERCENT; 248 return velY > STACK_DISMISS_MIN_VELOCITY 249 && bottomOfScreenInterceptX > dismissTargetWidth / 2f 250 && bottomOfScreenInterceptX < mStack.getWidth() - dismissTargetWidth / 2f; 251 } 252 253 /** Clears all touch-related state. */ 254 private void resetForNextGesture() { 255 if (mVelocityTracker != null) { 256 mVelocityTracker.recycle(); 257 mVelocityTracker = null; 258 } 259 260 mTouchedView = null; 261 mMovedEnough = false; 262 mInDismissTarget = false; 263 264 mStack.onGestureFinished(); 265 } 266 267 private void trackMovement(MotionEvent event) { 268 if (mVelocityTracker == null) { 269 mVelocityTracker = VelocityTracker.obtain(); 270 } 271 mVelocityTracker.addMovement(event); 272 } 273 } 274