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