1/* 2 * Copyright (C) 2015 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.messaging.ui.conversationlist; 17 18import android.animation.Animator; 19import android.animation.AnimatorListenerAdapter; 20import android.animation.ObjectAnimator; 21import android.animation.TimeInterpolator; 22import android.content.Context; 23import android.content.res.Resources; 24import android.support.v4.view.ViewCompat; 25import android.support.v7.widget.RecyclerView; 26import android.support.v7.widget.RecyclerView.OnItemTouchListener; 27import android.view.MotionEvent; 28import android.view.VelocityTracker; 29import android.view.View; 30import android.view.ViewConfiguration; 31 32import com.android.messaging.R; 33import com.android.messaging.util.Assert; 34import com.android.messaging.util.UiUtils; 35 36/** 37 * Animation and touch helper class for Conversation List swipe. 38 */ 39public class ConversationListSwipeHelper implements OnItemTouchListener { 40 private static final int UNIT_SECONDS = 1000; 41 private static final boolean ANIMATING = true; 42 43 private static final float ERROR_FACTOR_MULTIPLIER = 1.2f; 44 private static final float PERCENTAGE_OF_WIDTH_TO_DISMISS = 0.4f; 45 private static final float FLING_PERCENTAGE_OF_WIDTH_TO_DISMISS = 0.05f; 46 47 private static final int SWIPE_DIRECTION_NONE = 0; 48 private static final int SWIPE_DIRECTION_LEFT = 1; 49 private static final int SWIPE_DIRECTION_RIGHT = 2; 50 51 private final RecyclerView mRecyclerView; 52 private final long mDefaultRestoreAnimationDuration; 53 private final long mDefaultDismissAnimationDuration; 54 private final long mMaxTranslationAnimationDuration; 55 private final int mTouchSlop; 56 private final int mMinimumFlingVelocity; 57 private final int mMaximumFlingVelocity; 58 59 /* Valid throughout a single gesture. */ 60 private VelocityTracker mVelocityTracker; 61 private float mInitialX; 62 private float mInitialY; 63 private boolean mIsSwiping; 64 private ConversationListItemView mListItemView; 65 66 public ConversationListSwipeHelper(final RecyclerView recyclerView) { 67 mRecyclerView = recyclerView; 68 69 final Context context = mRecyclerView.getContext(); 70 final Resources res = context.getResources(); 71 mDefaultRestoreAnimationDuration = res.getInteger(R.integer.swipe_duration_ms); 72 mDefaultDismissAnimationDuration = res.getInteger(R.integer.swipe_duration_ms); 73 mMaxTranslationAnimationDuration = res.getInteger(R.integer.swipe_duration_ms); 74 75 final ViewConfiguration viewConfiguration = ViewConfiguration.get(context); 76 mTouchSlop = viewConfiguration.getScaledPagingTouchSlop(); 77 mMaximumFlingVelocity = Math.min( 78 viewConfiguration.getScaledMaximumFlingVelocity(), 79 res.getInteger(R.integer.swipe_max_fling_velocity_px_per_s)); 80 mMinimumFlingVelocity = viewConfiguration.getScaledMinimumFlingVelocity(); 81 } 82 83 @Override 84 public boolean onInterceptTouchEvent(final RecyclerView recyclerView, final MotionEvent event) { 85 if (event.getPointerCount() > 1) { 86 // Ignore subsequent pointers. 87 return false; 88 } 89 90 // We are not yet tracking a swipe gesture. Begin detection by spying on 91 // touch events bubbling down to our children. 92 final int action = event.getActionMasked(); 93 switch (action) { 94 case MotionEvent.ACTION_DOWN: 95 if (!hasGestureSwipeTarget()) { 96 onGestureStart(); 97 98 mVelocityTracker.addMovement(event); 99 mInitialX = event.getX(); 100 mInitialY = event.getY(); 101 102 final View viewAtPoint = mRecyclerView.findChildViewUnder(mInitialX, mInitialY); 103 final ConversationListItemView child = (ConversationListItemView) viewAtPoint; 104 if (viewAtPoint instanceof ConversationListItemView && 105 child != null && child.isSwipeAnimatable()) { 106 // Begin detecting swipe on the target for the rest of the gesture. 107 mListItemView = child; 108 if (mListItemView.isAnimating()) { 109 mListItemView = null; 110 } 111 } else { 112 mListItemView = null; 113 } 114 } 115 break; 116 case MotionEvent.ACTION_MOVE: 117 if (hasValidGestureSwipeTarget()) { 118 mVelocityTracker.addMovement(event); 119 120 final int historicalCount = event.getHistorySize(); 121 // First consume the historical events, then consume the current ones. 122 for (int i = 0; i < historicalCount + 1; i++) { 123 float currX; 124 float currY; 125 if (i < historicalCount) { 126 currX = event.getHistoricalX(i); 127 currY = event.getHistoricalY(i); 128 } else { 129 currX = event.getX(); 130 currY = event.getY(); 131 } 132 final float deltaX = currX - mInitialX; 133 final float deltaY = currY - mInitialY; 134 final float absDeltaX = Math.abs(deltaX); 135 final float absDeltaY = Math.abs(deltaY); 136 137 if (!mIsSwiping && absDeltaY > mTouchSlop 138 && absDeltaY > (ERROR_FACTOR_MULTIPLIER * absDeltaX)) { 139 // Stop detecting swipe for the remainder of this gesture. 140 onGestureEnd(); 141 return false; 142 } 143 144 if (absDeltaX > mTouchSlop) { 145 // Swipe detected. Return true so we can handle the gesture in 146 // onTouchEvent. 147 mIsSwiping = true; 148 149 // We don't want to suddenly jump the slop distance. 150 mInitialX = event.getX(); 151 mInitialY = event.getY(); 152 153 onSwipeGestureStart(mListItemView); 154 return true; 155 } 156 } 157 } 158 break; 159 case MotionEvent.ACTION_UP: 160 case MotionEvent.ACTION_CANCEL: 161 if (hasGestureSwipeTarget()) { 162 onGestureEnd(); 163 } 164 break; 165 } 166 167 // Start intercepting touch events from children if we detect a swipe. 168 return mIsSwiping; 169 } 170 171 @Override 172 public void onTouchEvent(final RecyclerView recyclerView, final MotionEvent event) { 173 // We should only be here if we intercepted the touch due to swipe. 174 Assert.isTrue(mIsSwiping); 175 176 // We are now tracking a swipe gesture. 177 mVelocityTracker.addMovement(event); 178 179 final int action = event.getActionMasked(); 180 switch (action) { 181 case MotionEvent.ACTION_OUTSIDE: 182 case MotionEvent.ACTION_MOVE: 183 if (hasValidGestureSwipeTarget()) { 184 mListItemView.setSwipeTranslationX(event.getX() - mInitialX); 185 } 186 break; 187 case MotionEvent.ACTION_UP: 188 if (hasValidGestureSwipeTarget()) { 189 final float maxVelocity = mMaximumFlingVelocity; 190 mVelocityTracker.computeCurrentVelocity(UNIT_SECONDS, maxVelocity); 191 final float velocityX = getLastComputedXVelocity(); 192 193 final float translationX = mListItemView.getSwipeTranslationX(); 194 195 int swipeDirection = SWIPE_DIRECTION_NONE; 196 if (translationX != 0) { 197 swipeDirection = 198 translationX > 0 ? SWIPE_DIRECTION_RIGHT : SWIPE_DIRECTION_LEFT; 199 } else if (velocityX != 0) { 200 swipeDirection = 201 velocityX > 0 ? SWIPE_DIRECTION_RIGHT : SWIPE_DIRECTION_LEFT; 202 } 203 204 final boolean fastEnough = isTargetSwipedFastEnough(); 205 final boolean farEnough = isTargetSwipedFarEnough(); 206 207 final boolean shouldDismiss = (fastEnough || farEnough); 208 209 if (shouldDismiss) { 210 if (fastEnough) { 211 animateDismiss(mListItemView, velocityX); 212 } else { 213 animateDismiss(mListItemView, swipeDirection); 214 } 215 } else { 216 animateRestore(mListItemView, velocityX); 217 } 218 219 onSwipeGestureEnd(mListItemView, 220 shouldDismiss ? swipeDirection : SWIPE_DIRECTION_NONE); 221 } else { 222 onGestureEnd(); 223 } 224 break; 225 case MotionEvent.ACTION_CANCEL: 226 if (hasValidGestureSwipeTarget()) { 227 animateRestore(mListItemView, 0f); 228 onSwipeGestureEnd(mListItemView, SWIPE_DIRECTION_NONE); 229 } else { 230 onGestureEnd(); 231 } 232 break; 233 } 234 } 235 236 237 @Override 238 public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { 239 } 240 241 /** 242 * We have started to intercept a series of touch events. 243 */ 244 private void onGestureStart() { 245 mIsSwiping = false; 246 // Work around bug in RecyclerView that sends two identical ACTION_DOWN 247 // events to #onInterceptTouchEvent. 248 if (mVelocityTracker == null) { 249 mVelocityTracker = VelocityTracker.obtain(); 250 } 251 mVelocityTracker.clear(); 252 } 253 254 /** 255 * The series of touch events has been detected as a swipe. 256 * 257 * Now that the gesture is a swipe, we will begin translating the view of the 258 * given viewHolder. 259 */ 260 private void onSwipeGestureStart(final ConversationListItemView itemView) { 261 mRecyclerView.getParent().requestDisallowInterceptTouchEvent(true); 262 setHardwareAnimatingLayerType(itemView, ANIMATING); 263 itemView.setAnimating(true); 264 } 265 266 /** 267 * The current swipe gesture is complete. 268 */ 269 private void onSwipeGestureEnd(final ConversationListItemView itemView, 270 final int swipeDirection) { 271 if (swipeDirection == SWIPE_DIRECTION_RIGHT || swipeDirection == SWIPE_DIRECTION_LEFT) { 272 itemView.onSwipeComplete(); 273 } 274 275 // Balances out onSwipeGestureStart. 276 itemView.setAnimating(false); 277 278 onGestureEnd(); 279 } 280 281 /** 282 * The series of touch events has ended in an {@link MotionEvent#ACTION_UP} 283 * or {@link MotionEvent#ACTION_CANCEL}. 284 */ 285 private void onGestureEnd() { 286 mVelocityTracker.recycle(); 287 mVelocityTracker = null; 288 mIsSwiping = false; 289 mListItemView = null; 290 } 291 292 /** 293 * A swipe animation has started. 294 */ 295 private void onSwipeAnimationStart(final ConversationListItemView itemView) { 296 // Disallow interactions. 297 itemView.setAnimating(true); 298 ViewCompat.setHasTransientState(itemView, true); 299 setHardwareAnimatingLayerType(itemView, ANIMATING); 300 } 301 302 /** 303 * The swipe animation has ended. 304 */ 305 private void onSwipeAnimationEnd(final ConversationListItemView itemView) { 306 // Restore interactions. 307 itemView.setAnimating(false); 308 ViewCompat.setHasTransientState(itemView, false); 309 setHardwareAnimatingLayerType(itemView, !ANIMATING); 310 } 311 312 /** 313 * Animate the dismissal of the given item. The given velocityX is taken into consideration for 314 * the animation duration. Whether the item is dismissed to the left or right is dependent on 315 * the given velocityX. 316 */ 317 private void animateDismiss(final ConversationListItemView itemView, final float velocityX) { 318 Assert.isTrue(velocityX != 0); 319 final int direction = velocityX > 0 ? SWIPE_DIRECTION_RIGHT : SWIPE_DIRECTION_LEFT; 320 animateDismiss(itemView, direction, velocityX); 321 } 322 323 /** 324 * Animate the dismissal of the given item. The velocityX is assumed to be 0. 325 */ 326 private void animateDismiss(final ConversationListItemView itemView, final int swipeDirection) { 327 animateDismiss(itemView, swipeDirection, 0f); 328 } 329 330 /** 331 * Animate the dismissal of the given item. 332 */ 333 private void animateDismiss(final ConversationListItemView itemView, 334 final int swipeDirection, final float velocityX) { 335 Assert.isTrue(swipeDirection != SWIPE_DIRECTION_NONE); 336 337 onSwipeAnimationStart(itemView); 338 339 final float animateTo = (swipeDirection == SWIPE_DIRECTION_RIGHT) ? 340 mRecyclerView.getWidth() : -mRecyclerView.getWidth(); 341 final long duration; 342 if (velocityX != 0) { 343 final float deltaX = animateTo - itemView.getSwipeTranslationX(); 344 duration = calculateTranslationDuration(deltaX, velocityX); 345 } else { 346 duration = mDefaultDismissAnimationDuration; 347 } 348 349 final ObjectAnimator animator = getSwipeTranslationXAnimator( 350 itemView, animateTo, duration, UiUtils.DEFAULT_INTERPOLATOR); 351 animator.addListener(new AnimatorListenerAdapter() { 352 @Override 353 public void onAnimationEnd(final Animator animation) { 354 onSwipeAnimationEnd(itemView); 355 } 356 }); 357 animator.start(); 358 } 359 360 /** 361 * Animate the bounce back of the given item. 362 */ 363 private void animateRestore(final ConversationListItemView itemView, 364 final float velocityX) { 365 onSwipeAnimationStart(itemView); 366 367 final float translationX = itemView.getSwipeTranslationX(); 368 final long duration; 369 if (velocityX != 0 // Has velocity. 370 && velocityX > 0 != translationX > 0) { // Right direction. 371 duration = calculateTranslationDuration(translationX, velocityX); 372 } else { 373 duration = mDefaultRestoreAnimationDuration; 374 } 375 final ObjectAnimator animator = getSwipeTranslationXAnimator( 376 itemView, 0f, duration, UiUtils.DEFAULT_INTERPOLATOR); 377 animator.addListener(new AnimatorListenerAdapter() { 378 @Override 379 public void onAnimationEnd(final Animator animation) { 380 onSwipeAnimationEnd(itemView); 381 } 382 }); 383 animator.start(); 384 } 385 386 /** 387 * Create and start an animator that animates the given view's translationX 388 * from its current value to the value given by animateTo. 389 */ 390 private ObjectAnimator getSwipeTranslationXAnimator(final ConversationListItemView itemView, 391 final float animateTo, final long duration, final TimeInterpolator interpolator) { 392 final ObjectAnimator animator = 393 ObjectAnimator.ofFloat(itemView, "swipeTranslationX", animateTo); 394 animator.setDuration(duration); 395 animator.setInterpolator(interpolator); 396 return animator; 397 } 398 399 /** 400 * Determine if the swipe has enough velocity to be dismissed. 401 */ 402 private boolean isTargetSwipedFastEnough() { 403 final float velocityX = getLastComputedXVelocity(); 404 final float velocityY = mVelocityTracker.getYVelocity(); 405 final float minVelocity = mMinimumFlingVelocity; 406 final float translationX = mListItemView.getSwipeTranslationX(); 407 final float width = mListItemView.getWidth(); 408 return (Math.abs(velocityX) > minVelocity) // Fast enough. 409 && (Math.abs(velocityX) > Math.abs(velocityY)) // Not unintentional. 410 && (velocityX > 0) == (translationX > 0) // Right direction. 411 && Math.abs(translationX) > 412 FLING_PERCENTAGE_OF_WIDTH_TO_DISMISS * width; // Enough movement. 413 } 414 415 /** 416 * Only used during a swipe gesture. Determine if the swipe has enough distance to be 417 * dismissed. 418 */ 419 private boolean isTargetSwipedFarEnough() { 420 final float velocityX = getLastComputedXVelocity(); 421 422 final float translationX = mListItemView.getSwipeTranslationX(); 423 final float width = mListItemView.getWidth(); 424 425 return (velocityX >= 0) == (translationX > 0) // Right direction. 426 && Math.abs(translationX) > 427 PERCENTAGE_OF_WIDTH_TO_DISMISS * width; // Enough movement. 428 } 429 430 private long calculateTranslationDuration(final float deltaPosition, final float velocity) { 431 Assert.isTrue(velocity != 0); 432 final float durationInSeconds = Math.abs(deltaPosition / velocity); 433 return Math.min((int) (durationInSeconds * UNIT_SECONDS), mMaxTranslationAnimationDuration); 434 } 435 436 private boolean hasGestureSwipeTarget() { 437 return mListItemView != null; 438 } 439 440 private boolean hasValidGestureSwipeTarget() { 441 return hasGestureSwipeTarget() && mListItemView.getParent() == mRecyclerView; 442 } 443 444 /** 445 * Enable a hardware layer for the it view and build that layer. 446 */ 447 private void setHardwareAnimatingLayerType(final ConversationListItemView itemView, 448 final boolean animating) { 449 if (animating) { 450 itemView.setLayerType(View.LAYER_TYPE_HARDWARE, null); 451 if (itemView.getWindowToken() != null) { 452 itemView.buildLayer(); 453 } 454 } else { 455 itemView.setLayerType(View.LAYER_TYPE_NONE, null); 456 } 457 } 458 459 private float getLastComputedXVelocity() { 460 return mVelocityTracker.getXVelocity(); 461 } 462}