SwipeHelper.java revision bc7173dc4aeba17ee7586db7cb6fa2cc79016d49
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 SwipeableItemView 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 getVelocity(VelocityTracker vt) { 105 return mSwipeDirection == X ? vt.getXVelocity() : 106 vt.getYVelocity(); 107 } 108 109 private ObjectAnimator createTranslationAnimation(View v, float newPos) { 110 ObjectAnimator anim = ObjectAnimator.ofFloat(v, 111 mSwipeDirection == X ? "translationX" : "translationY", newPos); 112 return anim; 113 } 114 115 private ObjectAnimator createDismissAnimation(View v, float newPos, int duration) { 116 ObjectAnimator anim = createTranslationAnimation(v, newPos); 117 anim.setInterpolator(sLinearInterpolator); 118 anim.setDuration(duration); 119 return anim; 120 } 121 122 private float getPerpendicularVelocity(VelocityTracker vt) { 123 return mSwipeDirection == X ? vt.getYVelocity() : 124 vt.getXVelocity(); 125 } 126 127 private void setTranslation(View v, float translate) { 128 if (mSwipeDirection == X) { 129 v.setTranslationX(translate); 130 } else { 131 v.setTranslationY(translate); 132 } 133 } 134 135 private float getSize(View v) { 136 return mSwipeDirection == X ? v.getMeasuredWidth() : 137 v.getMeasuredHeight(); 138 } 139 140 public void setMinAlpha(float minAlpha) { 141 mMinAlpha = minAlpha; 142 } 143 144 private float getAlphaForOffset(View view) { 145 float viewSize = getSize(view); 146 final float fadeSize = ALPHA_FADE_END * viewSize; 147 float result = 1.0f; 148 float pos = view.getTranslationX(); 149 if (pos >= viewSize * ALPHA_FADE_START) { 150 result = 1.0f - (pos - viewSize * ALPHA_FADE_START) / fadeSize; 151 } else if (pos < viewSize * (1.0f - ALPHA_FADE_START)) { 152 result = 1.0f + (viewSize * ALPHA_FADE_START + pos) / fadeSize; 153 } 154 return Math.max(mMinAlpha, result); 155 } 156 157 // invalidate the view's own bounds all the way up the view hierarchy 158 public static void invalidateGlobalRegion(View view) { 159 invalidateGlobalRegion( 160 view, 161 new RectF(view.getLeft(), view.getTop(), view.getRight(), view.getBottom())); 162 } 163 164 // invalidate a rectangle relative to the view's coordinate system all the way up the view 165 // hierarchy 166 public static void invalidateGlobalRegion(View view, RectF childBounds) { 167 //childBounds.offset(view.getTranslationX(), view.getTranslationY()); 168 if (DEBUG_INVALIDATE) 169 Log.v(TAG, "-------------"); 170 while (view.getParent() != null && view.getParent() instanceof View) { 171 view = (View) view.getParent(); 172 view.getMatrix().mapRect(childBounds); 173 view.invalidate((int) Math.floor(childBounds.left), 174 (int) Math.floor(childBounds.top), 175 (int) Math.ceil(childBounds.right), 176 (int) Math.ceil(childBounds.bottom)); 177 if (DEBUG_INVALIDATE) { 178 Log.v(TAG, "INVALIDATE(" + (int) Math.floor(childBounds.left) 179 + "," + (int) Math.floor(childBounds.top) 180 + "," + (int) Math.ceil(childBounds.right) 181 + "," + (int) Math.ceil(childBounds.bottom)); 182 } 183 } 184 } 185 186 public boolean onInterceptTouchEvent(MotionEvent ev) { 187 final int action = ev.getAction(); 188 switch (action) { 189 case MotionEvent.ACTION_DOWN: 190 mLastY = ev.getY(); 191 mDragging = false; 192 View view = mCallback.getChildAtPosition(ev); 193 if (view instanceof SwipeableItemView) { 194 mCurrView = (SwipeableItemView) view; 195 } 196 mVelocityTracker.clear(); 197 if (mCurrView != null) { 198 mCurrAnimView = mCurrView.getView(); 199 mCanCurrViewBeDimissed = mCallback.canChildBeDismissed(mCurrView); 200 mVelocityTracker.addMovement(ev); 201 mInitialTouchPosX = ev.getX(); 202 mInitialTouchPosY = ev.getY(); 203 } 204 break; 205 case MotionEvent.ACTION_MOVE: 206 if (mCurrView != null) { 207 // Check the movement direction. 208 if (mLastY >= 0) { 209 float currY = ev.getY(); 210 if (Math.abs(currY - mLastY) > mScrollSlop) { 211 mLastY = ev.getY(); 212 mCurrView.cancelTap(); 213 return false; 214 } 215 } 216 mVelocityTracker.addMovement(ev); 217 float pos = ev.getX(); 218 float delta = pos - mInitialTouchPosX; 219 if (Math.abs(delta) > mPagingTouchSlop) { 220 if (mCurrView.canSwipe()) { 221 mCallback.onBeginDrag(mCurrView.getView()); 222 mDragging = true; 223 mInitialTouchPosX = ev.getX() - mCurrAnimView.getTranslationX(); 224 mInitialTouchPosY = ev.getY(); 225 mCurrView.cancelTap(); 226 } 227 } 228 } 229 mLastY = ev.getY(); 230 break; 231 case MotionEvent.ACTION_UP: 232 case MotionEvent.ACTION_CANCEL: 233 mDragging = false; 234 mCurrView = null; 235 mCurrAnimView = null; 236 mLastY = -1; 237 break; 238 } 239 return mDragging; 240 } 241 242 public void setAssociatedViews(Collection<ConversationItemView> associated) { 243 mAssociatedViews = associated; 244 } 245 246 public void clearAssociatedViews() { 247 mAssociatedViews = null; 248 } 249 250 /** 251 * @param view The view to be dismissed 252 * @param velocity The desired pixels/second speed at which the view should 253 * move 254 */ 255 private void dismissChild(final SwipeableItemView view, float velocity) { 256 final View animView = mCurrView.getView(); 257 final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(view); 258 float newPos = determinePos(animView, velocity); 259 int duration = determineDuration(animView, newPos, velocity); 260 261 animView.setLayerType(View.LAYER_TYPE_HARDWARE, null); 262 ObjectAnimator anim = createDismissAnimation(animView, newPos, duration); 263 anim.addListener(new AnimatorListenerAdapter() { 264 @Override 265 public void onAnimationEnd(Animator animation) { 266 mCallback.onChildDismissed(mCurrView); 267 animView.setLayerType(View.LAYER_TYPE_NONE, null); 268 } 269 }); 270 anim.addUpdateListener(new AnimatorUpdateListener() { 271 @Override 272 public void onAnimationUpdate(ValueAnimator animation) { 273 if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) { 274 animView.setAlpha(getAlphaForOffset(animView)); 275 } 276 invalidateGlobalRegion(animView); 277 } 278 }); 279 anim.start(); 280 } 281 282 private void dismissChildren(final Collection<ConversationItemView> views, float velocity) { 283 AnimatorListenerAdapter listener = new AnimatorListenerAdapter() { 284 @Override 285 public void onAnimationEnd(Animator animation) { 286 mCallback.onChildrenDismissed(mCurrView, views); 287 mCurrView.getView().setLayerType(View.LAYER_TYPE_NONE, null); 288 } 289 }; 290 dismissChildren(views, velocity, listener); 291 } 292 293 private void dismissChildren(final Collection<ConversationItemView> views, float velocity, 294 AnimatorListenerAdapter listener) { 295 final View animView = mCurrView.getView(); 296 final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(mCurrView); 297 float newPos = determinePos(animView, velocity); 298 int duration = determineDuration(animView, newPos, velocity); 299 ArrayList<Animator> animations = new ArrayList<Animator>(); 300 ObjectAnimator anim; 301 for (final ConversationItemView view : views) { 302 view.setLayerType(View.LAYER_TYPE_HARDWARE, null); 303 anim = createDismissAnimation(view, newPos, duration); 304 anim.addUpdateListener(new AnimatorUpdateListener() { 305 @Override 306 public void onAnimationUpdate(ValueAnimator animation) { 307 if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) { 308 view.setAlpha(getAlphaForOffset(view)); 309 } 310 invalidateGlobalRegion(view); 311 } 312 }); 313 animations.add(anim); 314 } 315 AnimatorSet transitionSet = new AnimatorSet(); 316 transitionSet.playTogether(animations); 317 transitionSet.addListener(listener); 318 transitionSet.start(); 319 } 320 321 public void dismissChildren(ConversationItemView first, 322 final Collection<ConversationItemView> views, AnimatorListenerAdapter listener) { 323 mCurrView = first; 324 dismissChildren(views, 0f, listener); 325 } 326 327 private int determineDuration(View animView, float newPos, float velocity) { 328 int duration = MAX_ESCAPE_ANIMATION_DURATION; 329 if (velocity != 0) { 330 duration = Math 331 .min(duration, 332 (int) (Math.abs(newPos - animView.getTranslationX()) * 1000f / Math 333 .abs(velocity))); 334 } else { 335 duration = DEFAULT_ESCAPE_ANIMATION_DURATION; 336 } 337 return duration; 338 } 339 340 private float determinePos(View animView, float velocity) { 341 float newPos = 0; 342 if (velocity < 0 || (velocity == 0 && animView.getTranslationX() < 0) 343 // if we use the Menu to dismiss an item in landscape, animate up 344 || (velocity == 0 && animView.getTranslationX() == 0 && mSwipeDirection == Y)) { 345 newPos = -getSize(animView); 346 } else { 347 newPos = getSize(animView); 348 } 349 return newPos; 350 } 351 352 public void snapChild(final SwipeableItemView view, float velocity) { 353 final View animView = view.getView(); 354 final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(view); 355 ObjectAnimator anim = createTranslationAnimation(animView, 0); 356 int duration = SNAP_ANIM_LEN; 357 anim.setDuration(duration); 358 anim.addUpdateListener(new AnimatorUpdateListener() { 359 @Override 360 public void onAnimationUpdate(ValueAnimator animation) { 361 if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) { 362 animView.setAlpha(getAlphaForOffset(animView)); 363 } 364 invalidateGlobalRegion(animView); 365 } 366 }); 367 anim.addListener(new Animator.AnimatorListener() { 368 @Override 369 public void onAnimationStart(Animator animation) { 370 } 371 @Override 372 public void onAnimationEnd(Animator animation) { 373 animView.setAlpha(1.0f); 374 } 375 @Override 376 public void onAnimationCancel(Animator animation) { 377 } 378 @Override 379 public void onAnimationRepeat(Animator animation) { 380 } 381 }); 382 anim.start(); 383 } 384 385 public boolean onTouchEvent(MotionEvent ev) { 386 if (!mDragging) { 387 return false; 388 } 389 // If this item is being dragged, cancel any tap handlers/ events/ 390 // actions for this item. 391 if (mCurrView != null) { 392 mCurrView.cancelTap(); 393 } 394 mVelocityTracker.addMovement(ev); 395 final int action = ev.getAction(); 396 switch (action) { 397 case MotionEvent.ACTION_OUTSIDE: 398 case MotionEvent.ACTION_MOVE: 399 if (mCurrView != null) { 400 float deltaX = ev.getX() - mInitialTouchPosX; 401 float deltaY = Math.abs(ev.getY() - mInitialTouchPosY); 402 // If the user has gone vertical and not gone horizontal AT 403 // LEAST minBeforeLock, switch to scroll. Otherwise, cancel 404 // the swipe. 405 if (deltaY > mMinVert && (Math.abs(deltaX)) < mMinLock) { 406 return false; 407 } 408 float minDistance = mMinSwipe; 409 if (Math.abs(deltaX) < minDistance) { 410 // Don't start the drag until at least X distance has 411 // occurred. 412 return true; 413 } 414 // don't let items that can't be dismissed be dragged more 415 // than maxScrollDistance 416 if (CONSTRAIN_SWIPE && !mCallback.canChildBeDismissed(mCurrView)) { 417 float size = getSize(mCurrAnimView); 418 float maxScrollDistance = 0.15f * size; 419 if (Math.abs(deltaX) >= size) { 420 deltaX = deltaX > 0 ? maxScrollDistance : -maxScrollDistance; 421 } else { 422 deltaX = maxScrollDistance 423 * (float) Math.sin((deltaX / size) * (Math.PI / 2)); 424 } 425 } 426 if (mAssociatedViews != null && mAssociatedViews.size() > 1) { 427 for (View v : mAssociatedViews) { 428 setTranslation(v, deltaX); 429 } 430 } else { 431 setTranslation(mCurrAnimView, deltaX); 432 } 433 if (FADE_OUT_DURING_SWIPE && mCanCurrViewBeDimissed) { 434 if (mAssociatedViews != null && mAssociatedViews.size() > 1) { 435 for (View v : mAssociatedViews) { 436 v.setAlpha(getAlphaForOffset(mCurrAnimView)); 437 } 438 } else { 439 mCurrAnimView.setAlpha(getAlphaForOffset(mCurrAnimView)); 440 } 441 } 442 invalidateGlobalRegion(mCurrView.getView()); 443 } 444 break; 445 case MotionEvent.ACTION_UP: 446 case MotionEvent.ACTION_CANCEL: 447 if (mCurrView != null) { 448 float maxVelocity = MAX_DISMISS_VELOCITY * mDensityScale; 449 mVelocityTracker.computeCurrentVelocity(1000 /* px/sec */, maxVelocity); 450 float escapeVelocity = SWIPE_ESCAPE_VELOCITY * mDensityScale; 451 float velocity = getVelocity(mVelocityTracker); 452 float perpendicularVelocity = getPerpendicularVelocity(mVelocityTracker); 453 454 // Decide whether to dismiss the current view 455 // Tweak constants below as required to prevent erroneous 456 // swipe/dismiss 457 float translation = Math.abs(mCurrAnimView.getTranslationX()); 458 float currAnimViewSize = getSize(mCurrAnimView); 459 // Long swipe = translation of .4 * width 460 boolean childSwipedFarEnough = DISMISS_IF_SWIPED_FAR_ENOUGH 461 && translation > 0.4 * currAnimViewSize; 462 // Fast swipe = > escapeVelocity and translation of .1 * 463 // width 464 boolean childSwipedFastEnough = (Math.abs(velocity) > escapeVelocity) 465 && (Math.abs(velocity) > Math.abs(perpendicularVelocity)) 466 && (velocity > 0) == (mCurrAnimView.getTranslationX() > 0) 467 && translation > 0.05 * currAnimViewSize; 468 if (LOG_SWIPE_DISMISS_VELOCITY) { 469 Log.v(TAG, "Swipe/Dismiss: " + velocity + "/" + escapeVelocity + "/" 470 + perpendicularVelocity + ", x: " + translation + "/" 471 + currAnimViewSize); 472 } 473 474 boolean dismissChild = mCallback.canChildBeDismissed(mCurrView) 475 && (childSwipedFastEnough || childSwipedFarEnough); 476 477 if (dismissChild) { 478 if (mAssociatedViews != null && mAssociatedViews.size() > 1) { 479 dismissChildren(mAssociatedViews, childSwipedFastEnough ? 480 velocity : 0f); 481 } else { 482 dismissChild(mCurrView, childSwipedFastEnough ? velocity : 0f); 483 } 484 } else { 485 if (mAssociatedViews != null && mAssociatedViews.size() > 1) { 486 for (SwipeableItemView v : mAssociatedViews) { 487 snapChild(v, velocity); 488 } 489 } else { 490 snapChild(mCurrView, velocity); 491 } 492 // snappity 493 mCallback.onDragCancelled(mCurrView); 494 } 495 } 496 break; 497 } 498 return true; 499 } 500 501 public interface Callback { 502 View getChildAtPosition(MotionEvent ev); 503 504 boolean canChildBeDismissed(SwipeableItemView v); 505 506 void onBeginDrag(View v); 507 508 void onChildDismissed(SwipeableItemView v); 509 510 void onChildrenDismissed(SwipeableItemView target, Collection<ConversationItemView> v); 511 512 void onDragCancelled(SwipeableItemView v); 513 514 ConversationSelectionSet getSelectionSet(); 515 } 516} 517