1 /* 2 * Copyright (C) 2017 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.touch; 17 18 import static android.view.MotionEvent.INVALID_POINTER_ID; 19 20 import android.content.Context; 21 import android.graphics.PointF; 22 import android.util.Log; 23 import android.view.MotionEvent; 24 import android.view.VelocityTracker; 25 import android.view.ViewConfiguration; 26 27 import com.android.launcher3.Utilities; 28 import com.android.launcher3.testing.TestProtocol; 29 30 import androidx.annotation.NonNull; 31 import androidx.annotation.VisibleForTesting; 32 33 /** 34 * One dimensional scroll/drag/swipe gesture detector. 35 * 36 * Definition of swipe is different from android system in that this detector handles 37 * 'swipe to dismiss', 'swiping up/down a container' but also keeps scrolling state before 38 * swipe action happens 39 */ 40 public class SwipeDetector { 41 42 private static final boolean DBG = false; 43 private static final String TAG = "SwipeDetector"; 44 45 private int mScrollConditions; 46 public static final int DIRECTION_POSITIVE = 1 << 0; 47 public static final int DIRECTION_NEGATIVE = 1 << 1; 48 public static final int DIRECTION_BOTH = DIRECTION_NEGATIVE | DIRECTION_POSITIVE; 49 50 private static final float ANIMATION_DURATION = 1200; 51 52 protected int mActivePointerId = INVALID_POINTER_ID; 53 54 /** 55 * The minimum release velocity in pixels per millisecond that triggers fling.. 56 */ 57 public static final float RELEASE_VELOCITY_PX_MS = 1.0f; 58 59 /* Scroll state, this is set to true during dragging and animation. */ 60 private ScrollState mState = ScrollState.IDLE; 61 62 enum ScrollState { 63 IDLE, 64 DRAGGING, // onDragStart, onDrag 65 SETTLING // onDragEnd 66 } 67 68 public static abstract class Direction { 69 getDisplacement(MotionEvent ev, int pointerIndex, PointF refPoint, boolean isRtl)70 abstract float getDisplacement(MotionEvent ev, int pointerIndex, PointF refPoint, 71 boolean isRtl); 72 73 /** 74 * Distance in pixels a touch can wander before we think the user is scrolling. 75 */ getActiveTouchSlop(MotionEvent ev, int pointerIndex, PointF downPos)76 abstract float getActiveTouchSlop(MotionEvent ev, int pointerIndex, PointF downPos); 77 getVelocity(VelocityTracker tracker, boolean isRtl)78 abstract float getVelocity(VelocityTracker tracker, boolean isRtl); 79 isPositive(float displacement)80 abstract boolean isPositive(float displacement); 81 isNegative(float displacement)82 abstract boolean isNegative(float displacement); 83 } 84 85 public static final Direction VERTICAL = new Direction() { 86 87 @Override 88 float getDisplacement(MotionEvent ev, int pointerIndex, PointF refPoint, boolean isRtl) { 89 return ev.getY(pointerIndex) - refPoint.y; 90 } 91 92 @Override 93 float getActiveTouchSlop(MotionEvent ev, int pointerIndex, PointF downPos) { 94 return Math.abs(ev.getX(pointerIndex) - downPos.x); 95 } 96 97 @Override 98 float getVelocity(VelocityTracker tracker, boolean isRtl) { 99 return tracker.getYVelocity(); 100 } 101 102 @Override 103 boolean isPositive(float displacement) { 104 // Up 105 return displacement < 0; 106 } 107 108 @Override 109 boolean isNegative(float displacement) { 110 // Down 111 return displacement > 0; 112 } 113 }; 114 115 public static final Direction HORIZONTAL = new Direction() { 116 117 @Override 118 float getDisplacement(MotionEvent ev, int pointerIndex, PointF refPoint, boolean isRtl) { 119 float displacement = ev.getX(pointerIndex) - refPoint.x; 120 if (isRtl) { 121 displacement = -displacement; 122 } 123 return displacement; 124 } 125 126 @Override 127 float getActiveTouchSlop(MotionEvent ev, int pointerIndex, PointF downPos) { 128 return Math.abs(ev.getY(pointerIndex) - downPos.y); 129 } 130 131 @Override 132 float getVelocity(VelocityTracker tracker, boolean isRtl) { 133 float velocity = tracker.getXVelocity(); 134 if (isRtl) { 135 velocity = -velocity; 136 } 137 return velocity; 138 } 139 140 @Override 141 boolean isPositive(float displacement) { 142 // Right 143 return displacement > 0; 144 } 145 146 @Override 147 boolean isNegative(float displacement) { 148 // Left 149 return displacement < 0; 150 } 151 }; 152 153 //------------------- ScrollState transition diagram ----------------------------------- 154 // 155 // IDLE -> (mDisplacement > mTouchSlop) -> DRAGGING 156 // DRAGGING -> (MotionEvent#ACTION_UP, MotionEvent#ACTION_CANCEL) -> SETTLING 157 // SETTLING -> (MotionEvent#ACTION_DOWN) -> DRAGGING 158 // SETTLING -> (View settled) -> IDLE 159 setState(ScrollState newState)160 private void setState(ScrollState newState) { 161 if (TestProtocol.sDebugTracing) { 162 Log.d(TestProtocol.NO_ALLAPPS_EVENT_TAG, "setState -- start: " + newState); 163 } 164 if (DBG) { 165 Log.d(TAG, "setState:" + mState + "->" + newState); 166 } 167 // onDragStart and onDragEnd is reported ONLY on state transition 168 if (newState == ScrollState.DRAGGING) { 169 initializeDragging(); 170 if (mState == ScrollState.IDLE) { 171 if (TestProtocol.sDebugTracing) { 172 Log.d(TestProtocol.NO_ALLAPPS_EVENT_TAG, "setState -- 1: " + newState); 173 } 174 reportDragStart(false /* recatch */); 175 } else if (mState == ScrollState.SETTLING) { 176 reportDragStart(true /* recatch */); 177 } 178 } 179 if (newState == ScrollState.SETTLING) { 180 reportDragEnd(); 181 } 182 183 mState = newState; 184 if (com.android.launcher3.testing.TestProtocol.sDebugTracing) { 185 android.util.Log.e(TestProtocol.NO_ALLAPPS_EVENT_TAG, 186 "setState: " + newState + " @ " + android.util.Log.getStackTraceString( 187 new Throwable())); 188 } 189 } 190 isDraggingOrSettling()191 public boolean isDraggingOrSettling() { 192 return mState == ScrollState.DRAGGING || mState == ScrollState.SETTLING; 193 } 194 getDownX()195 public int getDownX() { 196 return (int) mDownPos.x; 197 } 198 getDownY()199 public int getDownY() { 200 return (int) mDownPos.y; 201 } 202 /** 203 * There's no touch and there's no animation. 204 */ isIdleState()205 public boolean isIdleState() { 206 return mState == ScrollState.IDLE; 207 } 208 isSettlingState()209 public boolean isSettlingState() { 210 return mState == ScrollState.SETTLING; 211 } 212 isDraggingState()213 public boolean isDraggingState() { 214 return mState == ScrollState.DRAGGING; 215 } 216 217 private final PointF mDownPos = new PointF(); 218 private final PointF mLastPos = new PointF(); 219 private final Direction mDir; 220 private final boolean mIsRtl; 221 222 private final float mTouchSlop; 223 private final float mMaxVelocity; 224 225 /* Client of this gesture detector can register a callback. */ 226 private final Listener mListener; 227 228 private VelocityTracker mVelocityTracker; 229 230 private float mLastDisplacement; 231 private float mDisplacement; 232 233 private float mSubtractDisplacement; 234 private boolean mIgnoreSlopWhenSettling; 235 236 public interface Listener { onDragStart(boolean start)237 void onDragStart(boolean start); 238 onDrag(float displacement)239 boolean onDrag(float displacement); 240 onDrag(float displacement, MotionEvent event)241 default boolean onDrag(float displacement, MotionEvent event) { 242 return onDrag(displacement); 243 } 244 onDragEnd(float velocity, boolean fling)245 void onDragEnd(float velocity, boolean fling); 246 } 247 SwipeDetector(@onNull Context context, @NonNull Listener l, @NonNull Direction dir)248 public SwipeDetector(@NonNull Context context, @NonNull Listener l, @NonNull Direction dir) { 249 this(ViewConfiguration.get(context), l, dir, Utilities.isRtl(context.getResources())); 250 } 251 252 @VisibleForTesting SwipeDetector(@onNull ViewConfiguration config, @NonNull Listener l, @NonNull Direction dir, boolean isRtl)253 protected SwipeDetector(@NonNull ViewConfiguration config, @NonNull Listener l, 254 @NonNull Direction dir, boolean isRtl) { 255 mListener = l; 256 mDir = dir; 257 mIsRtl = isRtl; 258 mTouchSlop = config.getScaledTouchSlop(); 259 mMaxVelocity = config.getScaledMaximumFlingVelocity(); 260 } 261 setDetectableScrollConditions(int scrollDirectionFlags, boolean ignoreSlop)262 public void setDetectableScrollConditions(int scrollDirectionFlags, boolean ignoreSlop) { 263 mScrollConditions = scrollDirectionFlags; 264 mIgnoreSlopWhenSettling = ignoreSlop; 265 } 266 getScrollDirections()267 public int getScrollDirections() { 268 return mScrollConditions; 269 } 270 shouldScrollStart(MotionEvent ev, int pointerIndex)271 private boolean shouldScrollStart(MotionEvent ev, int pointerIndex) { 272 // reject cases where the angle or slop condition is not met. 273 if (Math.max(mDir.getActiveTouchSlop(ev, pointerIndex, mDownPos), mTouchSlop) 274 > Math.abs(mDisplacement)) { 275 return false; 276 } 277 278 // Check if the client is interested in scroll in current direction. 279 if (((mScrollConditions & DIRECTION_NEGATIVE) > 0 && mDir.isNegative(mDisplacement)) || 280 ((mScrollConditions & DIRECTION_POSITIVE) > 0 && mDir.isPositive(mDisplacement))) { 281 return true; 282 } 283 return false; 284 } 285 onTouchEvent(MotionEvent ev)286 public boolean onTouchEvent(MotionEvent ev) { 287 int actionMasked = ev.getActionMasked(); 288 if (actionMasked == MotionEvent.ACTION_DOWN && mVelocityTracker != null) { 289 mVelocityTracker.clear(); 290 } 291 if (mVelocityTracker == null) { 292 mVelocityTracker = VelocityTracker.obtain(); 293 } 294 mVelocityTracker.addMovement(ev); 295 296 switch (actionMasked) { 297 case MotionEvent.ACTION_DOWN: 298 mActivePointerId = ev.getPointerId(0); 299 mDownPos.set(ev.getX(), ev.getY()); 300 mLastPos.set(mDownPos); 301 mLastDisplacement = 0; 302 mDisplacement = 0; 303 304 if (mState == ScrollState.SETTLING && mIgnoreSlopWhenSettling) { 305 setState(ScrollState.DRAGGING); 306 } 307 break; 308 //case MotionEvent.ACTION_POINTER_DOWN: 309 case MotionEvent.ACTION_POINTER_UP: 310 int ptrIdx = ev.getActionIndex(); 311 int ptrId = ev.getPointerId(ptrIdx); 312 if (ptrId == mActivePointerId) { 313 final int newPointerIdx = ptrIdx == 0 ? 1 : 0; 314 mDownPos.set( 315 ev.getX(newPointerIdx) - (mLastPos.x - mDownPos.x), 316 ev.getY(newPointerIdx) - (mLastPos.y - mDownPos.y)); 317 mLastPos.set(ev.getX(newPointerIdx), ev.getY(newPointerIdx)); 318 mActivePointerId = ev.getPointerId(newPointerIdx); 319 } 320 break; 321 case MotionEvent.ACTION_MOVE: 322 int pointerIndex = ev.findPointerIndex(mActivePointerId); 323 if (pointerIndex == INVALID_POINTER_ID) { 324 break; 325 } 326 mDisplacement = mDir.getDisplacement(ev, pointerIndex, mDownPos, mIsRtl); 327 if (TestProtocol.sDebugTracing) { 328 Log.d(TestProtocol.NO_ALLAPPS_EVENT_TAG, "onTouchEvent 1"); 329 } 330 331 // handle state and listener calls. 332 if (mState != ScrollState.DRAGGING && shouldScrollStart(ev, pointerIndex)) { 333 if (TestProtocol.sDebugTracing) { 334 Log.d(TestProtocol.NO_ALLAPPS_EVENT_TAG, "onTouchEvent 2"); 335 } 336 setState(ScrollState.DRAGGING); 337 } 338 if (mState == ScrollState.DRAGGING) { 339 reportDragging(ev); 340 } 341 mLastPos.set(ev.getX(pointerIndex), ev.getY(pointerIndex)); 342 break; 343 case MotionEvent.ACTION_CANCEL: 344 case MotionEvent.ACTION_UP: 345 // These are synthetic events and there is no need to update internal values. 346 if (mState == ScrollState.DRAGGING) { 347 setState(ScrollState.SETTLING); 348 } 349 mVelocityTracker.recycle(); 350 mVelocityTracker = null; 351 break; 352 default: 353 break; 354 } 355 return true; 356 } 357 finishedScrolling()358 public void finishedScrolling() { 359 setState(ScrollState.IDLE); 360 } 361 reportDragStart(boolean recatch)362 private boolean reportDragStart(boolean recatch) { 363 mListener.onDragStart(!recatch); 364 if (DBG) { 365 Log.d(TAG, "onDragStart recatch:" + recatch); 366 } 367 return true; 368 } 369 initializeDragging()370 private void initializeDragging() { 371 if (mState == ScrollState.SETTLING && mIgnoreSlopWhenSettling) { 372 mSubtractDisplacement = 0; 373 } 374 if (mDisplacement > 0) { 375 mSubtractDisplacement = mTouchSlop; 376 } else { 377 mSubtractDisplacement = -mTouchSlop; 378 } 379 } 380 381 /** 382 * Returns if the start drag was towards the positive direction or negative. 383 * 384 * @see #setDetectableScrollConditions(int, boolean) 385 * @see #DIRECTION_BOTH 386 */ wasInitialTouchPositive()387 public boolean wasInitialTouchPositive() { 388 return mDir.isPositive(mSubtractDisplacement); 389 } 390 reportDragging(MotionEvent event)391 private boolean reportDragging(MotionEvent event) { 392 if (mDisplacement != mLastDisplacement) { 393 if (DBG) { 394 Log.d(TAG, String.format("onDrag disp=%.1f", mDisplacement)); 395 } 396 397 mLastDisplacement = mDisplacement; 398 return mListener.onDrag(mDisplacement - mSubtractDisplacement, event); 399 } 400 return true; 401 } 402 reportDragEnd()403 private void reportDragEnd() { 404 mVelocityTracker.computeCurrentVelocity(1000, mMaxVelocity); 405 float velocity = mDir.getVelocity(mVelocityTracker, mIsRtl) / 1000; 406 if (DBG) { 407 Log.d(TAG, String.format("onScrollEnd disp=%.1f, velocity=%.1f", 408 mDisplacement, velocity)); 409 } 410 411 mListener.onDragEnd(velocity, Math.abs(velocity) > RELEASE_VELOCITY_PX_MS); 412 } 413 calculateDuration(float velocity, float progressNeeded)414 public static long calculateDuration(float velocity, float progressNeeded) { 415 // TODO: make these values constants after tuning. 416 float velocityDivisor = Math.max(2f, Math.abs(0.5f * velocity)); 417 float travelDistance = Math.max(0.2f, progressNeeded); 418 long duration = (long) Math.max(100, ANIMATION_DURATION / velocityDivisor * travelDistance); 419 if (DBG) { 420 Log.d(TAG, String.format("calculateDuration=%d, v=%f, d=%f", duration, velocity, progressNeeded)); 421 } 422 return duration; 423 } 424 } 425