SwipeHelper.java revision cff4a2b2e92258c6c85ed4af15bc13101aa71170
1/* 2 * Copyright (C) 2012 Google Inc. 3 * Licensed to The Android Open Source Project. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18package com.android.mail.ui; 19 20import android.animation.Animator; 21import android.animation.AnimatorListenerAdapter; 22import android.animation.AnimatorSet; 23import android.animation.ObjectAnimator; 24import android.animation.ValueAnimator; 25import android.animation.ValueAnimator.AnimatorUpdateListener; 26import android.graphics.RectF; 27import android.util.Log; 28import android.view.MotionEvent; 29import android.view.VelocityTracker; 30import android.view.View; 31import android.view.animation.LinearInterpolator; 32 33import com.android.mail.browse.ConversationItemView; 34 35import java.util.ArrayList; 36import java.util.Collection; 37 38public class SwipeHelper { 39 static final String TAG = "com.android.systemui.SwipeHelper"; 40 private static final boolean DEBUG_INVALIDATE = false; 41 private static final boolean SLOW_ANIMATIONS = false; // DEBUG; 42 private static final boolean CONSTRAIN_SWIPE = true; 43 private static final boolean FADE_OUT_DURING_SWIPE = true; 44 private static final boolean DISMISS_IF_SWIPED_FAR_ENOUGH = true; 45 private static final boolean LOG_SWIPE_DISMISS_VELOCITY = false; // STOPSHIP - DEBUG ONLY 46 47 public static final int X = 0; 48 public static final int Y = 1; 49 50 private static LinearInterpolator sLinearInterpolator = new LinearInterpolator(); 51 52 private float SWIPE_ESCAPE_VELOCITY = 100f; // dp/sec 53 private int DEFAULT_ESCAPE_ANIMATION_DURATION = 200; // ms 54 private int MAX_ESCAPE_ANIMATION_DURATION = 400; // ms 55 private int MAX_DISMISS_VELOCITY = 2000; // dp/sec 56 private static final int SNAP_ANIM_LEN = SLOW_ANIMATIONS ? 1000 : 1; // ms 57 58 public static float ALPHA_FADE_START = 0f; // fraction of thumbnail width 59 // where fade starts 60 static final float ALPHA_FADE_END = 0.5f; // fraction of thumbnail width 61 // beyond which alpha->0 62 private float mMinAlpha = 0f; 63 64 private float mPagingTouchSlop; 65 private Callback mCallback; 66 private int mSwipeDirection; 67 private VelocityTracker mVelocityTracker; 68 69 private float mInitialTouchPosX; 70 private boolean mDragging; 71 private ConversationItemView mCurrView; 72 private View mCurrAnimView; 73 private boolean mCanCurrViewBeDimissed; 74 private float mDensityScale; 75 private float mLastY; 76 private Collection<ConversationItemView> mAssociatedViews; 77 private final float mScrollSlop; 78 private float mInitialTouchPosY; 79 private float mMinSwipe; 80 private float mMinVert; 81 private float mMinLock; 82 83 public SwipeHelper(int swipeDirection, Callback callback, float densityScale, 84 float pagingTouchSlop, float scrollSlop, float minSwipe, float minVert, float minLock) { 85 mCallback = callback; 86 mSwipeDirection = swipeDirection; 87 mVelocityTracker = VelocityTracker.obtain(); 88 mDensityScale = densityScale; 89 mPagingTouchSlop = pagingTouchSlop; 90 mScrollSlop = scrollSlop; 91 mMinSwipe = minSwipe; 92 mMinVert = minVert; 93 mMinLock = minLock; 94 } 95 96 public void setDensityScale(float densityScale) { 97 mDensityScale = densityScale; 98 } 99 100 public void setPagingTouchSlop(float pagingTouchSlop) { 101 mPagingTouchSlop = pagingTouchSlop; 102 } 103 104 private float getPos(MotionEvent ev) { 105 return mSwipeDirection == X ? ev.getX() : ev.getY(); 106 } 107 108 private float getTranslation(View v) { 109 return mSwipeDirection == X ? v.getTranslationX() : v.getTranslationY(); 110 } 111 112 private float getVelocity(VelocityTracker vt) { 113 return mSwipeDirection == X ? vt.getXVelocity() : 114 vt.getYVelocity(); 115 } 116 117 private ObjectAnimator createTranslationAnimation(View v, float newPos) { 118 ObjectAnimator anim = ObjectAnimator.ofFloat(v, 119 mSwipeDirection == X ? "translationX" : "translationY", newPos); 120 return anim; 121 } 122 123 private ObjectAnimator createDismissAnimation(View v, float newPos, int duration) { 124 ObjectAnimator anim = createTranslationAnimation(v, newPos); 125 anim.setInterpolator(sLinearInterpolator); 126 anim.setDuration(duration); 127 return anim; 128 } 129 130 private float getPerpendicularVelocity(VelocityTracker vt) { 131 return mSwipeDirection == X ? vt.getYVelocity() : 132 vt.getXVelocity(); 133 } 134 135 private void setTranslation(View v, float translate) { 136 if (mSwipeDirection == X) { 137 v.setTranslationX(translate); 138 } else { 139 v.setTranslationY(translate); 140 } 141 } 142 143 private float getSize(View v) { 144 return mSwipeDirection == X ? v.getMeasuredWidth() : 145 v.getMeasuredHeight(); 146 } 147 148 public void setMinAlpha(float minAlpha) { 149 mMinAlpha = minAlpha; 150 } 151 152 private float getAlphaForOffset(View view) { 153 float viewSize = getSize(view); 154 final float fadeSize = ALPHA_FADE_END * viewSize; 155 float result = 1.0f; 156 float pos = getTranslation(view); 157 if (pos >= viewSize * ALPHA_FADE_START) { 158 result = 1.0f - (pos - viewSize * ALPHA_FADE_START) / fadeSize; 159 } else if (pos < viewSize * (1.0f - ALPHA_FADE_START)) { 160 result = 1.0f + (viewSize * ALPHA_FADE_START + pos) / fadeSize; 161 } 162 return Math.max(mMinAlpha, result); 163 } 164 165 // invalidate the view's own bounds all the way up the view hierarchy 166 public static void invalidateGlobalRegion(View view) { 167 invalidateGlobalRegion( 168 view, 169 new RectF(view.getLeft(), view.getTop(), view.getRight(), view.getBottom())); 170 } 171 172 // invalidate a rectangle relative to the view's coordinate system all the way up the view 173 // hierarchy 174 public static void invalidateGlobalRegion(View view, RectF childBounds) { 175 //childBounds.offset(view.getTranslationX(), view.getTranslationY()); 176 if (DEBUG_INVALIDATE) 177 Log.v(TAG, "-------------"); 178 while (view.getParent() != null && view.getParent() instanceof View) { 179 view = (View) view.getParent(); 180 view.getMatrix().mapRect(childBounds); 181 view.invalidate((int) Math.floor(childBounds.left), 182 (int) Math.floor(childBounds.top), 183 (int) Math.ceil(childBounds.right), 184 (int) Math.ceil(childBounds.bottom)); 185 if (DEBUG_INVALIDATE) { 186 Log.v(TAG, "INVALIDATE(" + (int) Math.floor(childBounds.left) 187 + "," + (int) Math.floor(childBounds.top) 188 + "," + (int) Math.ceil(childBounds.right) 189 + "," + (int) Math.ceil(childBounds.bottom)); 190 } 191 } 192 } 193 194 public boolean onInterceptTouchEvent(MotionEvent ev) { 195 final int action = ev.getAction(); 196 switch (action) { 197 case MotionEvent.ACTION_DOWN: 198 mLastY = ev.getY(); 199 mDragging = false; 200 View view = mCallback.getChildAtPosition(ev); 201 if (view instanceof ConversationItemView) { 202 mCurrView = (ConversationItemView) view; 203 } 204 mVelocityTracker.clear(); 205 if (mCurrView != null) { 206 mCurrAnimView = mCallback.getChildContentView(mCurrView); 207 mCanCurrViewBeDimissed = mCallback.canChildBeDismissed(mCurrView); 208 mVelocityTracker.addMovement(ev); 209 mInitialTouchPosX = getPos(ev); 210 mInitialTouchPosY = ev.getY(); 211 } 212 break; 213 case MotionEvent.ACTION_MOVE: 214 if (mCurrView != null) { 215 // Check the movement direction. 216 if (mLastY >= 0) { 217 float currY = ev.getY(); 218 if (Math.abs(currY - mLastY) > mScrollSlop) { 219 mLastY = ev.getY(); 220 mCurrView.cancelTap(); 221 return false; 222 } 223 } 224 mVelocityTracker.addMovement(ev); 225 float pos = getPos(ev); 226 float delta = pos - mInitialTouchPosX; 227 if (Math.abs(delta) > mPagingTouchSlop) { 228 if (mCallback.getSelectionSet().isEmpty() 229 || (!mCallback.getSelectionSet().isEmpty() 230 && mCurrView.isChecked())) { 231 mCallback.onBeginDrag(mCurrView); 232 mDragging = true; 233 mInitialTouchPosX = getPos(ev) - getTranslation(mCurrAnimView); 234 mInitialTouchPosY = ev.getY(); 235 } 236 } 237 } 238 mLastY = ev.getY(); 239 break; 240 case MotionEvent.ACTION_UP: 241 case MotionEvent.ACTION_CANCEL: 242 mDragging = false; 243 mCurrView = null; 244 mCurrAnimView = null; 245 mLastY = -1; 246 break; 247 } 248 return mDragging; 249 } 250 251 public void setAssociatedViews(Collection<ConversationItemView> associated) { 252 mAssociatedViews = associated; 253 } 254 255 public void clearAssociatedViews() { 256 mAssociatedViews = null; 257 } 258 259 /** 260 * @param view The view to be dismissed 261 * @param velocity The desired pixels/second speed at which the view should 262 * move 263 */ 264 private void dismissChild(final View view, float velocity) { 265 final View animView = mCurrView; 266 final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(animView); 267 float newPos = determinePos(animView, velocity); 268 int duration = determineDuration(animView, newPos, velocity); 269 270 animView.setLayerType(View.LAYER_TYPE_HARDWARE, null); 271 ObjectAnimator anim = createDismissAnimation(animView, newPos, duration); 272 anim.addListener(new AnimatorListenerAdapter() { 273 @Override 274 public void onAnimationEnd(Animator animation) { 275 mCallback.onChildDismissed(view); 276 mCurrView.setLayerType(View.LAYER_TYPE_NONE, null); 277 } 278 }); 279 anim.addUpdateListener(new AnimatorUpdateListener() { 280 @Override 281 public void onAnimationUpdate(ValueAnimator animation) { 282 if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) { 283 animView.setAlpha(getAlphaForOffset(animView)); 284 } 285 invalidateGlobalRegion(animView); 286 } 287 }); 288 anim.start(); 289 } 290 291 private void dismissChildren(final Collection<ConversationItemView> views, float velocity) { 292 AnimatorListenerAdapter listener = new AnimatorListenerAdapter() { 293 @Override 294 public void onAnimationEnd(Animator animation) { 295 mCallback.onChildrenDismissed(views); 296 mCurrView.setLayerType(View.LAYER_TYPE_NONE, null); 297 } 298 }; 299 final View animView = mCurrView; 300 final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(animView); 301 float newPos = determinePos(animView, velocity); 302 int duration = determineDuration(animView, newPos, velocity); 303 ArrayList<Animator> animations = new ArrayList<Animator>(); 304 ObjectAnimator anim; 305 for (final ConversationItemView view : views) { 306 view.setLayerType(View.LAYER_TYPE_HARDWARE, null); 307 anim = createDismissAnimation(view, newPos, duration); 308 anim.addUpdateListener(new AnimatorUpdateListener() { 309 @Override 310 public void onAnimationUpdate(ValueAnimator animation) { 311 if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) { 312 view.setAlpha(getAlphaForOffset(view)); 313 } 314 invalidateGlobalRegion(view); 315 } 316 }); 317 animations.add(anim); 318 } 319 AnimatorSet transitionSet = new AnimatorSet(); 320 transitionSet.playTogether(animations); 321 transitionSet.addListener(listener); 322 transitionSet.start(); 323 } 324 325 private int determineDuration(View animView, float newPos, float velocity) { 326 int duration = MAX_ESCAPE_ANIMATION_DURATION; 327 if (velocity != 0) { 328 duration = Math 329 .min(duration, 330 (int) (Math.abs(newPos - getTranslation(animView)) * 1000f / Math 331 .abs(velocity))); 332 } else { 333 duration = DEFAULT_ESCAPE_ANIMATION_DURATION; 334 } 335 return duration; 336 } 337 338 private float determinePos(View animView, float velocity) { 339 float newPos = 0; 340 if (velocity < 0 || (velocity == 0 && getTranslation(animView) < 0) 341 // if we use the Menu to dismiss an item in landscape, animate up 342 || (velocity == 0 && getTranslation(animView) == 0 && mSwipeDirection == Y)) { 343 newPos = -getSize(animView); 344 } else { 345 newPos = getSize(animView); 346 } 347 return newPos; 348 } 349 350 public void snapChild(final View view, float velocity) { 351 final View animView = mCallback.getChildContentView(view); 352 final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(animView); 353 ObjectAnimator anim = createTranslationAnimation(animView, 0); 354 int duration = SNAP_ANIM_LEN; 355 anim.setDuration(duration); 356 anim.addUpdateListener(new AnimatorUpdateListener() { 357 @Override 358 public void onAnimationUpdate(ValueAnimator animation) { 359 if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) { 360 animView.setAlpha(getAlphaForOffset(animView)); 361 } 362 invalidateGlobalRegion(animView); 363 } 364 }); 365 anim.start(); 366 } 367 368 public boolean onTouchEvent(MotionEvent ev) { 369 if (!mDragging) { 370 return false; 371 } 372 // If this item is being dragged, cancel any tap handlers/ events/ 373 // actions for this item. 374 if (mCurrView != null) { 375 mCurrView.cancelTap(); 376 } 377 mVelocityTracker.addMovement(ev); 378 final int action = ev.getAction(); 379 switch (action) { 380 case MotionEvent.ACTION_OUTSIDE: 381 case MotionEvent.ACTION_MOVE: 382 if (mCurrView != null) { 383 float deltaX = getPos(ev) - mInitialTouchPosX; 384 float deltaY = Math.abs(ev.getY() - mInitialTouchPosY); 385 // If the user has gone vertical and not gone horizontal AT 386 // LEAST minBeforeLock, switch to scroll. Otherwise, cancel 387 // the swipe. 388 if (deltaY > mMinVert && (Math.abs(deltaX)) < mMinLock) { 389 return false; 390 } 391 float minDistance = mMinSwipe; 392 if (Math.abs(deltaX) < minDistance) { 393 // Don't start the drag until at least X distance has 394 // occurred. 395 return true; 396 } 397 // don't let items that can't be dismissed be dragged more 398 // than maxScrollDistance 399 if (CONSTRAIN_SWIPE && !mCallback.canChildBeDismissed(mCurrView)) { 400 float size = getSize(mCurrAnimView); 401 float maxScrollDistance = 0.15f * size; 402 if (Math.abs(deltaX) >= size) { 403 deltaX = deltaX > 0 ? maxScrollDistance : -maxScrollDistance; 404 } else { 405 deltaX = maxScrollDistance 406 * (float) Math.sin((deltaX / size) * (Math.PI / 2)); 407 } 408 } 409 if (mAssociatedViews != null && mAssociatedViews.size() > 1) { 410 for (View v : mAssociatedViews) { 411 setTranslation(v, deltaX); 412 } 413 } else { 414 setTranslation(mCurrAnimView, deltaX); 415 } 416 if (FADE_OUT_DURING_SWIPE && mCanCurrViewBeDimissed) { 417 if (mAssociatedViews != null && mAssociatedViews.size() > 1) { 418 for (View v : mAssociatedViews) { 419 v.setAlpha(getAlphaForOffset(mCurrAnimView)); 420 } 421 } else { 422 mCurrAnimView.setAlpha(getAlphaForOffset(mCurrAnimView)); 423 } 424 } 425 invalidateGlobalRegion(mCurrView); 426 } 427 break; 428 case MotionEvent.ACTION_UP: 429 case MotionEvent.ACTION_CANCEL: 430 if (mCurrView != null) { 431 float maxVelocity = MAX_DISMISS_VELOCITY * mDensityScale; 432 mVelocityTracker.computeCurrentVelocity(1000 /* px/sec */, maxVelocity); 433 float escapeVelocity = SWIPE_ESCAPE_VELOCITY * mDensityScale; 434 float velocity = getVelocity(mVelocityTracker); 435 float perpendicularVelocity = getPerpendicularVelocity(mVelocityTracker); 436 437 // Decide whether to dismiss the current view 438 // Tweak constants below as required to prevent erroneous 439 // swipe/dismiss 440 float translation = Math.abs(getTranslation(mCurrAnimView)); 441 float currAnimViewSize = getSize(mCurrAnimView); 442 // Long swipe = translation of .4 * width 443 boolean childSwipedFarEnough = DISMISS_IF_SWIPED_FAR_ENOUGH 444 && translation > 0.4 * currAnimViewSize; 445 // Fast swipe = > escapeVelocity and translation of .1 * 446 // width 447 boolean childSwipedFastEnough = (Math.abs(velocity) > escapeVelocity) 448 && (Math.abs(velocity) > Math.abs(perpendicularVelocity)) 449 && (velocity > 0) == (getTranslation(mCurrAnimView) > 0) 450 && translation > 0.05 * currAnimViewSize; 451 if (LOG_SWIPE_DISMISS_VELOCITY) { 452 Log.v(TAG, "Swipe/Dismiss: " + velocity + "/" + escapeVelocity + "/" 453 + perpendicularVelocity + ", x: " + translation + "/" 454 + currAnimViewSize); 455 } 456 457 boolean dismissChild = mCallback.canChildBeDismissed(mCurrView) 458 && (childSwipedFastEnough || childSwipedFarEnough); 459 460 if (dismissChild) { 461 if (mAssociatedViews != null && mAssociatedViews.size() > 1) { 462 dismissChildren(mAssociatedViews, childSwipedFastEnough ? 463 velocity : 0f); 464 } else { 465 dismissChild(mCurrView, childSwipedFastEnough ? velocity : 0f); 466 } 467 } else { 468 // snappity 469 mCallback.onDragCancelled(mCurrView); 470 471 if (mAssociatedViews != null && mAssociatedViews.size() > 1) { 472 for (View v : mAssociatedViews) { 473 snapChild(v, velocity); 474 } 475 } else { 476 snapChild(mCurrView, velocity); 477 } 478 } 479 } 480 break; 481 } 482 return true; 483 } 484 485 public interface Callback { 486 View getChildAtPosition(MotionEvent ev); 487 488 View getChildContentView(View v); 489 490 boolean canChildBeDismissed(View v); 491 492 void onBeginDrag(View v); 493 494 void onChildDismissed(View v); 495 496 void onChildrenDismissed(Collection<ConversationItemView> v); 497 498 void onDragCancelled(View v); 499 500 ConversationSelectionSet getSelectionSet(); 501 } 502}