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