SwipeHelper.java revision 3b965d78774a42358ce6bbdcc43b4c8df130a60e
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.ObjectAnimator; 23import android.animation.ValueAnimator; 24import android.animation.ValueAnimator.AnimatorUpdateListener; 25import android.content.Context; 26import android.content.res.Resources; 27import android.graphics.RectF; 28import android.view.MotionEvent; 29import android.view.VelocityTracker; 30import android.view.View; 31import android.view.animation.DecelerateInterpolator; 32 33import com.android.mail.R; 34import com.android.mail.utils.LogUtils; 35import com.android.mail.utils.Utils; 36 37public class SwipeHelper { 38 static final String TAG = "com.android.systemui.SwipeHelper"; 39 private static final boolean DEBUG_INVALIDATE = false; 40 private static final boolean CONSTRAIN_SWIPE = true; 41 private static final boolean FADE_OUT_DURING_SWIPE = true; 42 private static final boolean DISMISS_IF_SWIPED_FAR_ENOUGH = true; 43 // Turn on for debugging only during development. 44 private static final boolean LOG_SWIPE_DISMISS_VELOCITY = false; 45 46 public static final int X = 0; 47 public static final int Y = 1; 48 49 private static DecelerateInterpolator sDecelerateInterpolator = 50 new DecelerateInterpolator(1.0f); 51 52 private static int SWIPE_ESCAPE_VELOCITY = -1; 53 private static int DEFAULT_ESCAPE_ANIMATION_DURATION; 54 private static int MAX_ESCAPE_ANIMATION_DURATION; 55 private static int MAX_DISMISS_VELOCITY; 56 private static int SNAP_ANIM_LEN; 57 private static float MIN_SWIPE; 58 59 public static float ALPHA_FADE_START = 0f; // fraction of thumbnail width 60 // where fade starts 61 public static float ALPHA_TEXT_FADE_START = 0.4f; 62 static final float ALPHA_FADE_END = 0.7f; // fraction of thumbnail width 63 // beyond which alpha->0 64 private static final float FACTOR = 1.2f; 65 66 /* Dead region where swipe cannot be initiated. */ 67 private final static int DEAD_REGION_FOR_SWIPE = 56; 68 69 private float mPagingTouchSlop; 70 private final Callback mCallback; 71 private final int mSwipeDirection; 72 private final VelocityTracker mVelocityTracker; 73 74 private float mInitialTouchPosX; 75 private boolean mDragging; 76 private SwipeableItemView mCurrView; 77 private View mCurrAnimView; 78 private boolean mCanCurrViewBeDimissed; 79 private float mDensityScale; 80 private float mLastY; 81 private float mInitialTouchPosY; 82 private LeaveBehindItem mPrevView; 83 84 public SwipeHelper(Context context, int swipeDirection, Callback callback, float densityScale, 85 float pagingTouchSlop) { 86 mCallback = callback; 87 mSwipeDirection = swipeDirection; 88 mVelocityTracker = VelocityTracker.obtain(); 89 mDensityScale = densityScale; 90 mPagingTouchSlop = pagingTouchSlop; 91 if (SWIPE_ESCAPE_VELOCITY == -1) { 92 Resources res = context.getResources(); 93 SWIPE_ESCAPE_VELOCITY = res.getInteger(R.integer.swipe_escape_velocity); 94 DEFAULT_ESCAPE_ANIMATION_DURATION = res.getInteger(R.integer.escape_animation_duration); 95 MAX_ESCAPE_ANIMATION_DURATION = res.getInteger(R.integer.max_escape_animation_duration); 96 MAX_DISMISS_VELOCITY = res.getInteger(R.integer.max_dismiss_velocity); 97 SNAP_ANIM_LEN = res.getInteger(R.integer.snap_animation_duration); 98 MIN_SWIPE = res.getDimension(R.dimen.min_swipe); 99 } 100 } 101 102 public void setDensityScale(float densityScale) { 103 mDensityScale = densityScale; 104 } 105 106 public void setPagingTouchSlop(float pagingTouchSlop) { 107 mPagingTouchSlop = pagingTouchSlop; 108 } 109 110 private float getVelocity(VelocityTracker vt) { 111 return mSwipeDirection == X ? vt.getXVelocity() : 112 vt.getYVelocity(); 113 } 114 115 private ObjectAnimator createTranslationAnimation(View v, float newPos) { 116 ObjectAnimator anim = ObjectAnimator.ofFloat(v, 117 mSwipeDirection == X ? "translationX" : "translationY", newPos); 118 return anim; 119 } 120 121 private ObjectAnimator createDismissAnimation(View v, float newPos, int duration) { 122 ObjectAnimator anim = createTranslationAnimation(v, newPos); 123 anim.setInterpolator(sDecelerateInterpolator); 124 anim.setDuration(duration); 125 return anim; 126 } 127 128 private float getPerpendicularVelocity(VelocityTracker vt) { 129 return mSwipeDirection == X ? vt.getYVelocity() : 130 vt.getXVelocity(); 131 } 132 133 private void setTranslation(View v, float translate) { 134 if (mSwipeDirection == X) { 135 v.setTranslationX(translate); 136 } else { 137 v.setTranslationY(translate); 138 } 139 } 140 141 private float getSize(View v) { 142 return mSwipeDirection == X ? v.getMeasuredWidth() : 143 v.getMeasuredHeight(); 144 } 145 146 private float getAlphaForOffset(View view) { 147 float viewSize = getSize(view); 148 final float fadeSize = ALPHA_FADE_END * viewSize; 149 float result = 1.0f; 150 float pos = view.getTranslationX(); 151 if (pos >= viewSize * ALPHA_FADE_START) { 152 result = 1.0f - (pos - viewSize * ALPHA_FADE_START) / fadeSize; 153 } else if (pos < viewSize * (1.0f - ALPHA_FADE_START)) { 154 result = 1.0f + (viewSize * ALPHA_FADE_START + pos) / fadeSize; 155 } 156 float minAlpha = 0.5f; 157 return Math.max(minAlpha, result); 158 } 159 160 private float getTextAlphaForOffset(View view) { 161 float viewSize = getSize(view); 162 final float fadeSize = ALPHA_TEXT_FADE_START * viewSize; 163 float result = 1.0f; 164 float pos = view.getTranslationX(); 165 if (pos >= 0) { 166 result = 1.0f - pos / fadeSize; 167 } else if (pos < 0) { 168 result = 1.0f + pos / fadeSize; 169 } 170 return Math.max(0, result); 171 } 172 173 // invalidate the view's own bounds all the way up the view hierarchy 174 public static void invalidateGlobalRegion(View view) { 175 invalidateGlobalRegion( 176 view, 177 new RectF(view.getLeft(), view.getTop(), view.getRight(), view.getBottom())); 178 } 179 180 // invalidate a rectangle relative to the view's coordinate system all the way up the view 181 // hierarchy 182 public static void invalidateGlobalRegion(View view, RectF childBounds) { 183 //childBounds.offset(view.getTranslationX(), view.getTranslationY()); 184 if (DEBUG_INVALIDATE) 185 LogUtils.v(TAG, "-------------"); 186 while (view.getParent() != null && view.getParent() instanceof View) { 187 view = (View) view.getParent(); 188 view.getMatrix().mapRect(childBounds); 189 view.invalidate((int) Math.floor(childBounds.left), 190 (int) Math.floor(childBounds.top), 191 (int) Math.ceil(childBounds.right), 192 (int) Math.ceil(childBounds.bottom)); 193 if (DEBUG_INVALIDATE) { 194 LogUtils.v(TAG, "INVALIDATE(" + (int) Math.floor(childBounds.left) 195 + "," + (int) Math.floor(childBounds.top) 196 + "," + (int) Math.ceil(childBounds.right) 197 + "," + (int) Math.ceil(childBounds.bottom)); 198 } 199 } 200 } 201 202 public boolean onInterceptTouchEvent(MotionEvent ev) { 203 final int action = ev.getAction(); 204 switch (action) { 205 case MotionEvent.ACTION_DOWN: 206 mLastY = ev.getY(); 207 mDragging = false; 208 View view = mCallback.getChildAtPosition(ev); 209 if (view instanceof NestedFolderView) { 210 // We don't want to allow nested folders to swipe at all. This would give the 211 // false hope that they might be deleted by swiping away. Instead, treat them 212 // like a plain list view element that doesn't allow any swipe gesture. 213 return false; 214 } 215 if (view instanceof SwipeableItemView) { 216 mCurrView = (SwipeableItemView) view; 217 } 218 mVelocityTracker.clear(); 219 if (mCurrView != null) { 220 mCurrAnimView = mCurrView.getSwipeableView().getView(); 221 mCanCurrViewBeDimissed = mCallback.canChildBeDismissed(mCurrView); 222 mVelocityTracker.addMovement(ev); 223 mInitialTouchPosX = ev.getX(); 224 mInitialTouchPosY = ev.getY(); 225 } 226 mCallback.cancelDismissCounter(); 227 break; 228 case MotionEvent.ACTION_MOVE: 229 if (mCurrView != null) { 230 // Check the movement direction. 231 if (mLastY >= 0 && !mDragging) { 232 float currY = ev.getY(); 233 float currX = ev.getX(); 234 float deltaY = Math.abs(currY - mInitialTouchPosY); 235 float deltaX = Math.abs(currX - mInitialTouchPosX); 236 if (deltaY > mCurrView.getMinAllowScrollDistance() 237 && deltaY > (FACTOR * deltaX)) { 238 mLastY = ev.getY(); 239 mCallback.onScroll(); 240 return false; 241 } 242 } 243 mVelocityTracker.addMovement(ev); 244 float pos = ev.getX(); 245 float delta = pos - mInitialTouchPosX; 246 if (Math.abs(delta) > mPagingTouchSlop) { 247 mCallback.onBeginDrag(mCurrView.getSwipeableView().getView()); 248 mPrevView = mCallback.getLastSwipedItem(); 249 mDragging = true; 250 mInitialTouchPosX = ev.getX() - mCurrAnimView.getTranslationX(); 251 mInitialTouchPosY = ev.getY(); 252 } 253 } 254 mLastY = ev.getY(); 255 break; 256 case MotionEvent.ACTION_UP: 257 case MotionEvent.ACTION_CANCEL: 258 mDragging = false; 259 mCurrView = null; 260 mCurrAnimView = null; 261 mLastY = -1; 262 break; 263 } 264 return mDragging; 265 } 266 267 /** 268 * @param view The view to be dismissed 269 * @param velocity The desired pixels/second speed at which the view should 270 * move 271 */ 272 private void dismissChild(final SwipeableItemView view, float velocity) { 273 final View animView = mCurrView.getSwipeableView().getView(); 274 final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(view); 275 float newPos = determinePos(animView, velocity); 276 int duration = determineDuration(animView, newPos, velocity); 277 278 Utils.enableHardwareLayer(animView); 279 ObjectAnimator anim = createDismissAnimation(animView, newPos, duration); 280 anim.addListener(new AnimatorListenerAdapter() { 281 @Override 282 public void onAnimationEnd(Animator animation) { 283 mCallback.onChildDismissed(mCurrView); 284 animView.setLayerType(View.LAYER_TYPE_NONE, null); 285 } 286 }); 287 anim.addUpdateListener(new AnimatorUpdateListener() { 288 @Override 289 public void onAnimationUpdate(ValueAnimator animation) { 290 if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) { 291 animView.setAlpha(getAlphaForOffset(animView)); 292 } 293 invalidateGlobalRegion(animView); 294 } 295 }); 296 anim.start(); 297 } 298 299 private static int determineDuration(View animView, float newPos, float velocity) { 300 int duration = MAX_ESCAPE_ANIMATION_DURATION; 301 if (velocity != 0) { 302 duration = Math 303 .min(duration, 304 (int) (Math.abs(newPos - animView.getTranslationX()) * 1000f / Math 305 .abs(velocity))); 306 } else { 307 duration = DEFAULT_ESCAPE_ANIMATION_DURATION; 308 } 309 return duration; 310 } 311 312 private float determinePos(View animView, float velocity) { 313 final float newPos; 314 if (velocity < 0 || (velocity == 0 && animView.getTranslationX() < 0) 315 // if we use the Menu to dismiss an item in landscape, animate up 316 || (velocity == 0 && animView.getTranslationX() == 0 && mSwipeDirection == Y)) { 317 newPos = -getSize(animView); 318 } else { 319 newPos = getSize(animView); 320 } 321 return newPos; 322 } 323 324 public void snapChild(final SwipeableItemView view) { 325 final View animView = view.getSwipeableView().getView(); 326 final boolean canAnimViewBeDismissed = mCallback.canChildBeDismissed(view); 327 final ObjectAnimator anim = createTranslationAnimation(animView, 0); 328 final int duration = SNAP_ANIM_LEN; 329 anim.setDuration(duration); 330 anim.addUpdateListener(new AnimatorUpdateListener() { 331 @Override 332 public void onAnimationUpdate(ValueAnimator animation) { 333 if (FADE_OUT_DURING_SWIPE && canAnimViewBeDismissed) { 334 animView.setAlpha(getAlphaForOffset(animView)); 335 } 336 invalidateGlobalRegion(animView); 337 } 338 }); 339 anim.addListener(new Animator.AnimatorListener() { 340 @Override 341 public void onAnimationStart(Animator animation) { 342 } 343 @Override 344 public void onAnimationEnd(Animator animation) { 345 animView.setAlpha(1.0f); 346 mCallback.onDragCancelled(mCurrView); 347 } 348 @Override 349 public void onAnimationCancel(Animator animation) { 350 } 351 @Override 352 public void onAnimationRepeat(Animator animation) { 353 } 354 }); 355 anim.start(); 356 } 357 358 public boolean onTouchEvent(MotionEvent ev) { 359 if (!mDragging) { 360 return false; 361 } 362 mVelocityTracker.addMovement(ev); 363 final int action = ev.getAction(); 364 switch (action) { 365 case MotionEvent.ACTION_OUTSIDE: 366 case MotionEvent.ACTION_MOVE: 367 if (mCurrView != null) { 368 float deltaX = ev.getX() - mInitialTouchPosX; 369 // If the swipe started in the dead region, ignore it. 370 if (mInitialTouchPosX <= (DEAD_REGION_FOR_SWIPE * mDensityScale)){ 371 return true; 372 } 373 // If the user has gone vertical and not gone horizontalish AT 374 // LEAST minBeforeLock, switch to scroll. Otherwise, cancel 375 // the swipe. 376 float minDistance = MIN_SWIPE; 377 if (Math.abs(deltaX) < minDistance) { 378 // Don't start the drag until at least X distance has 379 // occurred. 380 return true; 381 } 382 // don't let items that can't be dismissed be dragged more 383 // than maxScrollDistance 384 if (CONSTRAIN_SWIPE && !mCallback.canChildBeDismissed(mCurrView)) { 385 float size = getSize(mCurrAnimView); 386 float maxScrollDistance = 0.15f * size; 387 if (Math.abs(deltaX) >= size) { 388 deltaX = deltaX > 0 ? maxScrollDistance : -maxScrollDistance; 389 } else { 390 deltaX = maxScrollDistance 391 * (float) Math.sin((deltaX / size) * (Math.PI / 2)); 392 } 393 } 394 setTranslation(mCurrAnimView, deltaX); 395 if (FADE_OUT_DURING_SWIPE && mCanCurrViewBeDimissed) { 396 mCurrAnimView.setAlpha(getAlphaForOffset(mCurrAnimView)); 397 if (mPrevView != null) { 398 // Base how much the text of the prev item is faded 399 // on how far the current item has moved. 400 mPrevView.setTextAlpha(getTextAlphaForOffset(mCurrAnimView)); 401 } 402 } 403 invalidateGlobalRegion(mCurrView.getSwipeableView().getView()); 404 } 405 break; 406 case MotionEvent.ACTION_UP: 407 case MotionEvent.ACTION_CANCEL: 408 if (mCurrView != null) { 409 float maxVelocity = MAX_DISMISS_VELOCITY * mDensityScale; 410 mVelocityTracker.computeCurrentVelocity(1000 /* px/sec */, maxVelocity); 411 float escapeVelocity = SWIPE_ESCAPE_VELOCITY * mDensityScale; 412 float velocity = getVelocity(mVelocityTracker); 413 float perpendicularVelocity = getPerpendicularVelocity(mVelocityTracker); 414 415 // Decide whether to dismiss the current view 416 // Tweak constants below as required to prevent erroneous 417 // swipe/dismiss 418 float translation = Math.abs(mCurrAnimView.getTranslationX()); 419 float currAnimViewSize = getSize(mCurrAnimView); 420 // Long swipe = translation of .4 * width 421 boolean childSwipedFarEnough = DISMISS_IF_SWIPED_FAR_ENOUGH 422 && translation > 0.4 * currAnimViewSize; 423 // Fast swipe = > escapeVelocity and translation of .1 * 424 // width 425 boolean childSwipedFastEnough = (Math.abs(velocity) > escapeVelocity) 426 && (Math.abs(velocity) > Math.abs(perpendicularVelocity)) 427 && (velocity > 0) == (mCurrAnimView.getTranslationX() > 0) 428 && translation > 0.05 * currAnimViewSize; 429 if (LOG_SWIPE_DISMISS_VELOCITY) { 430 LogUtils.v(TAG, "Swipe/Dismiss: " + velocity + "/" + escapeVelocity + "/" 431 + perpendicularVelocity + ", x: " + translation + "/" 432 + currAnimViewSize); 433 } 434 435 boolean dismissChild = mCallback.canChildBeDismissed(mCurrView) 436 && (childSwipedFastEnough || childSwipedFarEnough); 437 438 if (dismissChild) { 439 dismissChild(mCurrView, childSwipedFastEnough ? velocity : 0f); 440 } else { 441 snapChild(mCurrView); 442 } 443 } 444 break; 445 } 446 return true; 447 } 448 449 public interface Callback { 450 View getChildAtPosition(MotionEvent ev); 451 452 void cancelDismissCounter(); 453 454 void onScroll(); 455 456 boolean canChildBeDismissed(SwipeableItemView v); 457 458 void onBeginDrag(View v); 459 460 void onChildDismissed(SwipeableItemView v); 461 462 void onDragCancelled(SwipeableItemView v); 463 464 ConversationSelectionSet getSelectionSet(); 465 466 LeaveBehindItem getLastSwipedItem(); 467 } 468} 469