SwipeDismissLayout.java revision 390120b925398c754b4f785fc12a8def0d09c09b
1/* 2 * Copyright (C) 2014 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 */ 16 17package com.android.internal.widget; 18 19import android.animation.Animator; 20import android.animation.TimeInterpolator; 21import android.animation.ValueAnimator; 22import android.animation.ValueAnimator.AnimatorUpdateListener; 23import android.app.Activity; 24import android.content.BroadcastReceiver; 25import android.content.Context; 26import android.content.ContextWrapper; 27import android.content.Intent; 28import android.content.IntentFilter; 29import android.content.res.TypedArray; 30import android.util.AttributeSet; 31import android.util.Log; 32import android.view.MotionEvent; 33import android.view.VelocityTracker; 34import android.view.View; 35import android.view.ViewConfiguration; 36import android.view.ViewGroup; 37import android.view.animation.DecelerateInterpolator; 38import android.widget.FrameLayout; 39 40/** 41 * Special layout that finishes its activity when swiped away. 42 */ 43public class SwipeDismissLayout extends FrameLayout { 44 private static final String TAG = "SwipeDismissLayout"; 45 46 private static final float MAX_DIST_THRESHOLD = .33f; 47 private static final float MIN_DIST_THRESHOLD = .1f; 48 49 public interface OnDismissedListener { 50 void onDismissed(SwipeDismissLayout layout); 51 } 52 53 public interface OnSwipeProgressChangedListener { 54 /** 55 * Called when the layout has been swiped and the position of the window should change. 56 * 57 * @param alpha A number in [0, 1] representing what the alpha transparency of the window 58 * should be. 59 * @param translate A number in [0, w], where w is the width of the 60 * layout. This is equivalent to progress * layout.getWidth(). 61 */ 62 void onSwipeProgressChanged(SwipeDismissLayout layout, float alpha, float translate); 63 64 void onSwipeCancelled(SwipeDismissLayout layout); 65 } 66 67 private boolean mIsWindowNativelyTranslucent; 68 69 // Cached ViewConfiguration and system-wide constant values 70 private int mSlop; 71 private int mMinFlingVelocity; 72 73 // Transient properties 74 private int mActiveTouchId; 75 private float mDownX; 76 private float mDownY; 77 private float mLastX; 78 private boolean mSwiping; 79 private boolean mDismissed; 80 private boolean mDiscardIntercept; 81 private VelocityTracker mVelocityTracker; 82 private boolean mBlockGesture = false; 83 private boolean mActivityTranslucencyConverted = false; 84 85 private final DismissAnimator mDismissAnimator = new DismissAnimator(); 86 87 private OnDismissedListener mDismissedListener; 88 private OnSwipeProgressChangedListener mProgressListener; 89 private BroadcastReceiver mScreenOffReceiver = new BroadcastReceiver() { 90 private Runnable mRunnable = new Runnable() { 91 @Override 92 public void run() { 93 if (mDismissed) { 94 dismiss(); 95 } else { 96 cancel(); 97 } 98 resetMembers(); 99 } 100 }; 101 102 @Override 103 public void onReceive(Context context, Intent intent) { 104 post(mRunnable); 105 } 106 }; 107 private IntentFilter mScreenOffFilter = new IntentFilter(Intent.ACTION_SCREEN_OFF); 108 109 110 private boolean mDismissable = true; 111 112 public SwipeDismissLayout(Context context) { 113 super(context); 114 init(context); 115 } 116 117 public SwipeDismissLayout(Context context, AttributeSet attrs) { 118 super(context, attrs); 119 init(context); 120 } 121 122 public SwipeDismissLayout(Context context, AttributeSet attrs, int defStyle) { 123 super(context, attrs, defStyle); 124 init(context); 125 } 126 127 private void init(Context context) { 128 ViewConfiguration vc = ViewConfiguration.get(context); 129 mSlop = vc.getScaledTouchSlop(); 130 mMinFlingVelocity = vc.getScaledMinimumFlingVelocity(); 131 TypedArray a = context.getTheme().obtainStyledAttributes( 132 com.android.internal.R.styleable.Theme); 133 mIsWindowNativelyTranslucent = a.getBoolean( 134 com.android.internal.R.styleable.Window_windowIsTranslucent, false); 135 a.recycle(); 136 } 137 138 public void setOnDismissedListener(OnDismissedListener listener) { 139 mDismissedListener = listener; 140 } 141 142 public void setOnSwipeProgressChangedListener(OnSwipeProgressChangedListener listener) { 143 mProgressListener = listener; 144 } 145 146 @Override 147 protected void onAttachedToWindow() { 148 super.onAttachedToWindow(); 149 getContext().registerReceiver(mScreenOffReceiver, mScreenOffFilter); 150 } 151 152 @Override 153 protected void onDetachedFromWindow() { 154 getContext().unregisterReceiver(mScreenOffReceiver); 155 super.onDetachedFromWindow(); 156 } 157 158 @Override 159 public boolean onInterceptTouchEvent(MotionEvent ev) { 160 checkGesture((ev)); 161 if (mBlockGesture) { 162 return true; 163 } 164 if (!mDismissable) { 165 return super.onInterceptTouchEvent(ev); 166 } 167 168 // Offset because the view is translated during swipe, match X with raw X. Active touch 169 // coordinates are mostly used by the velocity tracker, so offset it to match the raw 170 // coordinates which is what is primarily used elsewhere. 171 ev.offsetLocation(ev.getRawX() - ev.getX(), 0); 172 173 switch (ev.getActionMasked()) { 174 case MotionEvent.ACTION_DOWN: 175 resetMembers(); 176 mDownX = ev.getRawX(); 177 mDownY = ev.getRawY(); 178 mActiveTouchId = ev.getPointerId(0); 179 mVelocityTracker = VelocityTracker.obtain("int1"); 180 mVelocityTracker.addMovement(ev); 181 break; 182 183 case MotionEvent.ACTION_POINTER_DOWN: 184 int actionIndex = ev.getActionIndex(); 185 mActiveTouchId = ev.getPointerId(actionIndex); 186 break; 187 case MotionEvent.ACTION_POINTER_UP: 188 actionIndex = ev.getActionIndex(); 189 int pointerId = ev.getPointerId(actionIndex); 190 if (pointerId == mActiveTouchId) { 191 // This was our active pointer going up. Choose a new active pointer. 192 int newActionIndex = actionIndex == 0 ? 1 : 0; 193 mActiveTouchId = ev.getPointerId(newActionIndex); 194 } 195 break; 196 197 case MotionEvent.ACTION_CANCEL: 198 case MotionEvent.ACTION_UP: 199 resetMembers(); 200 break; 201 202 case MotionEvent.ACTION_MOVE: 203 if (mVelocityTracker == null || mDiscardIntercept) { 204 break; 205 } 206 207 int pointerIndex = ev.findPointerIndex(mActiveTouchId); 208 if (pointerIndex == -1) { 209 Log.e(TAG, "Invalid pointer index: ignoring."); 210 mDiscardIntercept = true; 211 break; 212 } 213 float dx = ev.getRawX() - mDownX; 214 float x = ev.getX(pointerIndex); 215 float y = ev.getY(pointerIndex); 216 if (dx != 0 && canScroll(this, false, dx, x, y)) { 217 mDiscardIntercept = true; 218 break; 219 } 220 updateSwiping(ev); 221 break; 222 } 223 224 return !mDiscardIntercept && mSwiping; 225 } 226 227 @Override 228 public boolean onTouchEvent(MotionEvent ev) { 229 checkGesture((ev)); 230 if (mBlockGesture) { 231 return true; 232 } 233 if (mVelocityTracker == null || !mDismissable) { 234 return super.onTouchEvent(ev); 235 } 236 237 // Offset because the view is translated during swipe, match X with raw X. Active touch 238 // coordinates are mostly used by the velocity tracker, so offset it to match the raw 239 // coordinates which is what is primarily used elsewhere. 240 ev.offsetLocation(ev.getRawX() - ev.getX(), 0); 241 242 switch (ev.getActionMasked()) { 243 case MotionEvent.ACTION_UP: 244 updateDismiss(ev); 245 if (mDismissed) { 246 mDismissAnimator.animateDismissal(ev.getRawX() - mDownX); 247 } else if (mSwiping 248 // Only trigger animation if we had a MOVE event that would shift the 249 // underlying view, otherwise the animation would be janky. 250 && mLastX != Integer.MIN_VALUE) { 251 mDismissAnimator.animateRecovery(ev.getRawX() - mDownX); 252 } 253 resetMembers(); 254 break; 255 256 case MotionEvent.ACTION_CANCEL: 257 cancel(); 258 resetMembers(); 259 break; 260 261 case MotionEvent.ACTION_MOVE: 262 mVelocityTracker.addMovement(ev); 263 mLastX = ev.getRawX(); 264 updateSwiping(ev); 265 if (mSwiping) { 266 setProgress(ev.getRawX() - mDownX); 267 break; 268 } 269 } 270 return true; 271 } 272 273 private void setProgress(float deltaX) { 274 if (mProgressListener != null && deltaX >= 0) { 275 mProgressListener.onSwipeProgressChanged( 276 this, progressToAlpha(deltaX / getWidth()), deltaX); 277 } 278 } 279 280 private void dismiss() { 281 if (mDismissedListener != null) { 282 mDismissedListener.onDismissed(this); 283 } 284 } 285 286 protected void cancel() { 287 if (!mIsWindowNativelyTranslucent) { 288 Activity activity = findActivity(); 289 if (activity != null && mActivityTranslucencyConverted) { 290 activity.convertFromTranslucent(); 291 mActivityTranslucencyConverted = false; 292 } 293 } 294 if (mProgressListener != null) { 295 mProgressListener.onSwipeCancelled(this); 296 } 297 } 298 299 /** 300 * Resets internal members when canceling. 301 */ 302 private void resetMembers() { 303 if (mVelocityTracker != null) { 304 mVelocityTracker.recycle(); 305 } 306 mVelocityTracker = null; 307 mDownX = 0; 308 mLastX = Integer.MIN_VALUE; 309 mDownY = 0; 310 mSwiping = false; 311 mDismissed = false; 312 mDiscardIntercept = false; 313 } 314 315 private void updateSwiping(MotionEvent ev) { 316 boolean oldSwiping = mSwiping; 317 if (!mSwiping) { 318 float deltaX = ev.getRawX() - mDownX; 319 float deltaY = ev.getRawY() - mDownY; 320 if ((deltaX * deltaX) + (deltaY * deltaY) > mSlop * mSlop) { 321 mSwiping = deltaX > mSlop * 2 && Math.abs(deltaY) < Math.abs(deltaX); 322 } else { 323 mSwiping = false; 324 } 325 } 326 327 if (mSwiping && !oldSwiping) { 328 // Swiping has started 329 if (!mIsWindowNativelyTranslucent) { 330 Activity activity = findActivity(); 331 if (activity != null) { 332 mActivityTranslucencyConverted = activity.convertToTranslucent(null, null); 333 } 334 } 335 } 336 } 337 338 private void updateDismiss(MotionEvent ev) { 339 float deltaX = ev.getRawX() - mDownX; 340 // Don't add the motion event as an UP event would clear the velocity tracker 341 mVelocityTracker.computeCurrentVelocity(1000); 342 float xVelocity = mVelocityTracker.getXVelocity(); 343 if (mLastX == Integer.MIN_VALUE) { 344 // If there's no changes to mLastX, we have only one point of data, and therefore no 345 // velocity. Estimate velocity from just the up and down event in that case. 346 xVelocity = deltaX / ((ev.getEventTime() - ev.getDownTime()) / 1000); 347 } 348 if (!mDismissed) { 349 // Adjust the distance threshold linearly between the min and max threshold based on the 350 // x-velocity scaled with the the fling threshold speed 351 float distanceThreshold = getWidth() * Math.max( 352 Math.min((MIN_DIST_THRESHOLD - MAX_DIST_THRESHOLD) 353 * xVelocity / mMinFlingVelocity // scale x-velocity with fling velocity 354 + MAX_DIST_THRESHOLD, // offset to start at max threshold 355 MAX_DIST_THRESHOLD), // cap at max threshold 356 MIN_DIST_THRESHOLD); // bottom out at min threshold 357 if ((deltaX > distanceThreshold && ev.getRawX() >= mLastX) 358 || xVelocity >= mMinFlingVelocity) { 359 mDismissed = true; 360 } 361 } 362 // Check if the user tried to undo this. 363 if (mDismissed && mSwiping) { 364 // Check if the user's finger is actually flinging back to left 365 if (xVelocity < -mMinFlingVelocity) { 366 mDismissed = false; 367 } 368 } 369 } 370 371 /** 372 * Tests scrollability within child views of v in the direction of dx. 373 * 374 * @param v View to test for horizontal scrollability 375 * @param checkV Whether the view v passed should itself be checked for scrollability (true), 376 * or just its children (false). 377 * @param dx Delta scrolled in pixels. Only the sign of this is used. 378 * @param x X coordinate of the active touch point 379 * @param y Y coordinate of the active touch point 380 * @return true if child views of v can be scrolled by delta of dx. 381 */ 382 protected boolean canScroll(View v, boolean checkV, float dx, float x, float y) { 383 if (v instanceof ViewGroup) { 384 final ViewGroup group = (ViewGroup) v; 385 final int scrollX = v.getScrollX(); 386 final int scrollY = v.getScrollY(); 387 final int count = group.getChildCount(); 388 for (int i = count - 1; i >= 0; i--) { 389 final View child = group.getChildAt(i); 390 if (x + scrollX >= child.getLeft() && x + scrollX < child.getRight() && 391 y + scrollY >= child.getTop() && y + scrollY < child.getBottom() && 392 canScroll(child, true, dx, x + scrollX - child.getLeft(), 393 y + scrollY - child.getTop())) { 394 return true; 395 } 396 } 397 } 398 399 return checkV && v.canScrollHorizontally((int) -dx); 400 } 401 402 public void setDismissable(boolean dismissable) { 403 if (!dismissable && mDismissable) { 404 cancel(); 405 resetMembers(); 406 } 407 408 mDismissable = dismissable; 409 } 410 411 private void checkGesture(MotionEvent ev) { 412 if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) { 413 mBlockGesture = mDismissAnimator.isAnimating(); 414 } 415 } 416 417 private float progressToAlpha(float progress) { 418 return 1 - progress * progress * progress; 419 } 420 421 private Activity findActivity() { 422 Context context = getContext(); 423 while (context instanceof ContextWrapper) { 424 if (context instanceof Activity) { 425 return (Activity) context; 426 } 427 context = ((ContextWrapper) context).getBaseContext(); 428 } 429 return null; 430 } 431 432 private class DismissAnimator implements AnimatorUpdateListener, Animator.AnimatorListener { 433 private final TimeInterpolator DISMISS_INTERPOLATOR = new DecelerateInterpolator(1.5f); 434 private final long DISMISS_DURATION = 250; 435 436 private final ValueAnimator mDismissAnimator = new ValueAnimator(); 437 private boolean mWasCanceled = false; 438 private boolean mDismissOnComplete = false; 439 440 /* package */ DismissAnimator() { 441 mDismissAnimator.addUpdateListener(this); 442 mDismissAnimator.addListener(this); 443 } 444 445 /* package */ void animateDismissal(float currentTranslation) { 446 animate( 447 currentTranslation / getWidth(), 448 1, 449 DISMISS_DURATION, 450 DISMISS_INTERPOLATOR, 451 true /* dismiss */); 452 } 453 454 /* package */ void animateRecovery(float currentTranslation) { 455 animate( 456 currentTranslation / getWidth(), 457 0, 458 DISMISS_DURATION, 459 DISMISS_INTERPOLATOR, 460 false /* don't dismiss */); 461 } 462 463 /* package */ boolean isAnimating() { 464 return mDismissAnimator.isStarted(); 465 } 466 467 private void animate(float from, float to, long duration, TimeInterpolator interpolator, 468 boolean dismissOnComplete) { 469 mDismissAnimator.cancel(); 470 mDismissOnComplete = dismissOnComplete; 471 mDismissAnimator.setFloatValues(from, to); 472 mDismissAnimator.setDuration(duration); 473 mDismissAnimator.setInterpolator(interpolator); 474 mDismissAnimator.start(); 475 } 476 477 @Override 478 public void onAnimationUpdate(ValueAnimator animation) { 479 float value = (Float) animation.getAnimatedValue(); 480 setProgress(value * getWidth()); 481 } 482 483 @Override 484 public void onAnimationStart(Animator animation) { 485 mWasCanceled = false; 486 } 487 488 @Override 489 public void onAnimationCancel(Animator animation) { 490 mWasCanceled = true; 491 } 492 493 @Override 494 public void onAnimationEnd(Animator animation) { 495 if (!mWasCanceled) { 496 if (mDismissOnComplete) { 497 dismiss(); 498 } else { 499 cancel(); 500 } 501 } 502 } 503 504 @Override 505 public void onAnimationRepeat(Animator animation) { 506 } 507 } 508} 509