SwipeDismissLayout.java revision ac5fe7c617c66850fff75a9fce9979c6e5674b0f
1/* 2 * Copyright (C) 2017 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 androidx.wear.widget; 18 19import android.content.Context; 20import android.content.res.Resources; 21import androidx.annotation.Nullable; 22import androidx.annotation.RestrictTo; 23import androidx.annotation.RestrictTo.Scope; 24import androidx.annotation.UiThread; 25import android.util.AttributeSet; 26import android.util.Log; 27import android.view.MotionEvent; 28import android.view.VelocityTracker; 29import android.view.View; 30import android.view.ViewConfiguration; 31import android.view.ViewGroup; 32import android.widget.FrameLayout; 33 34/** 35 * Special layout that finishes its activity when swiped away. 36 * 37 * <p>This is a modified copy of the internal framework class 38 * com.android.internal.widget.SwipeDismissLayout. 39 * 40 * @hide 41 */ 42@RestrictTo(Scope.LIBRARY) 43@UiThread 44class SwipeDismissLayout extends FrameLayout { 45 private static final String TAG = "SwipeDismissLayout"; 46 47 public static final float DEFAULT_DISMISS_DRAG_WIDTH_RATIO = .33f; 48 // A value between 0.0 and 1.0 determining the percentage of the screen on the left-hand-side 49 // where edge swipe gestures are permitted to begin. 50 private static final float EDGE_SWIPE_THRESHOLD = 0.1f; 51 52 /** Called when the layout is about to consider a swipe. */ 53 @UiThread 54 interface OnPreSwipeListener { 55 /** 56 * Notifies listeners that the view is now considering to start a dismiss gesture from a 57 * particular point on the screen. The default implementation returns true for all 58 * coordinates so that is is possible to start a swipe-to-dismiss gesture from any location. 59 * If any one instance of this Callback returns false for a given set of coordinates, 60 * swipe-to-dismiss will not be allowed to start in that point. 61 * 62 * @param xDown the x coordinate of the initial {@link android.view.MotionEvent#ACTION_DOWN} 63 * event for this motion 64 * @param yDown the y coordinate of the initial {@link android.view.MotionEvent#ACTION_DOWN} 65 * event for this motion 66 * @return {@code true} if these coordinates should be considered as a start of a swipe 67 * gesture, {@code false} otherwise 68 */ 69 boolean onPreSwipe(SwipeDismissLayout swipeDismissLayout, float xDown, float yDown); 70 } 71 72 /** 73 * Interface enabling listeners to react to when the swipe gesture is done and the view should 74 * probably be dismissed from the UI. 75 */ 76 @UiThread 77 interface OnDismissedListener { 78 void onDismissed(SwipeDismissLayout layout); 79 } 80 81 /** 82 * Interface enabling listeners to react to changes in the progress of the swipe-to-dismiss 83 * gesture. 84 */ 85 @UiThread 86 interface OnSwipeProgressChangedListener { 87 /** 88 * Called when the layout has been swiped and the position of the window should change. 89 * 90 * @param layout the layout associated with this listener. 91 * @param progress a number in [0, 1] representing how far to the right the window has 92 * been swiped 93 * @param translate a number in [0, w], where w is the width of the layout. This is 94 * equivalent to progress * layout.getWidth() 95 */ 96 void onSwipeProgressChanged(SwipeDismissLayout layout, float progress, float translate); 97 98 /** 99 * Called when the layout started to be swiped away but then the gesture was canceled. 100 * 101 * @param layout the layout associated with this listener 102 */ 103 void onSwipeCanceled(SwipeDismissLayout layout); 104 } 105 106 // Cached ViewConfiguration and system-wide constant values 107 private int mSlop; 108 private int mMinFlingVelocity; 109 private float mGestureThresholdPx; 110 111 // Transient properties 112 private int mActiveTouchId; 113 private float mDownX; 114 private float mDownY; 115 private boolean mSwipeable; 116 private boolean mSwiping; 117 // This variable holds information about whether the initial move of a longer swipe 118 // (consisting of multiple move events) has conformed to the definition of a horizontal 119 // swipe-to-dismiss. A swipe gesture is only ever allowed to be recognized if this variable is 120 // set to true. Otherwise, the motion events will be allowed to propagate to the children. 121 private boolean mCanStartSwipe = true; 122 private boolean mDismissed; 123 private boolean mDiscardIntercept; 124 private VelocityTracker mVelocityTracker; 125 private float mTranslationX; 126 private boolean mDisallowIntercept; 127 128 @Nullable 129 private OnPreSwipeListener mOnPreSwipeListener; 130 private OnDismissedListener mDismissedListener; 131 private OnSwipeProgressChangedListener mProgressListener; 132 133 private float mLastX; 134 private float mDismissMinDragWidthRatio = DEFAULT_DISMISS_DRAG_WIDTH_RATIO; 135 136 SwipeDismissLayout(Context context) { 137 this(context, null); 138 } 139 140 SwipeDismissLayout(Context context, AttributeSet attrs) { 141 this(context, attrs, 0); 142 } 143 144 SwipeDismissLayout(Context context, AttributeSet attrs, int defStyle) { 145 this(context, attrs, defStyle, 0); 146 } 147 148 SwipeDismissLayout(Context context, AttributeSet attrs, int defStyle, int defStyleRes) { 149 super(context, attrs, defStyle, defStyleRes); 150 ViewConfiguration vc = ViewConfiguration.get(context); 151 mSlop = vc.getScaledTouchSlop(); 152 mMinFlingVelocity = vc.getScaledMinimumFlingVelocity(); 153 mGestureThresholdPx = 154 Resources.getSystem().getDisplayMetrics().widthPixels * EDGE_SWIPE_THRESHOLD; 155 156 // By default, the view is swipeable. 157 setSwipeable(true); 158 } 159 160 /** 161 * Sets the minimum ratio of the screen after which the swipe gesture is treated as swipe-to- 162 * dismiss. 163 * 164 * @param ratio the ratio of the screen at which the swipe gesture is treated as 165 * swipe-to-dismiss. should be provided as a fraction of the screen 166 */ 167 public void setDismissMinDragWidthRatio(float ratio) { 168 mDismissMinDragWidthRatio = ratio; 169 } 170 171 /** 172 * Returns the current ratio of te screen at which the swipe gesture is treated as 173 * swipe-to-dismiss. 174 * 175 * @return the current ratio of te screen at which the swipe gesture is treated as 176 * swipe-to-dismiss 177 */ 178 public float getDismissMinDragWidthRatio() { 179 return mDismissMinDragWidthRatio; 180 } 181 182 /** 183 * Sets the layout to swipeable or not. This effectively turns the functionality of this layout 184 * on or off. 185 * 186 * @param swipeable whether the layout should react to the swipe gesture 187 */ 188 public void setSwipeable(boolean swipeable) { 189 mSwipeable = swipeable; 190 } 191 192 /** Returns true if the layout reacts to swipe gestures. */ 193 public boolean isSwipeable() { 194 return mSwipeable; 195 } 196 197 void setOnPreSwipeListener(@Nullable OnPreSwipeListener listener) { 198 mOnPreSwipeListener = listener; 199 } 200 201 void setOnDismissedListener(@Nullable OnDismissedListener listener) { 202 mDismissedListener = listener; 203 } 204 205 void setOnSwipeProgressChangedListener(@Nullable OnSwipeProgressChangedListener listener) { 206 mProgressListener = listener; 207 } 208 209 @Override 210 public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) { 211 mDisallowIntercept = disallowIntercept; 212 if (getParent() != null) { 213 getParent().requestDisallowInterceptTouchEvent(disallowIntercept); 214 } 215 } 216 217 @Override 218 public boolean onInterceptTouchEvent(MotionEvent ev) { 219 if (!mSwipeable) { 220 return super.onInterceptTouchEvent(ev); 221 } 222 223 // offset because the view is translated during swipe 224 ev.offsetLocation(mTranslationX, 0); 225 226 switch (ev.getActionMasked()) { 227 case MotionEvent.ACTION_DOWN: 228 resetMembers(); 229 mDownX = ev.getRawX(); 230 mDownY = ev.getRawY(); 231 mActiveTouchId = ev.getPointerId(0); 232 mVelocityTracker = VelocityTracker.obtain(); 233 mVelocityTracker.addMovement(ev); 234 break; 235 236 case MotionEvent.ACTION_POINTER_DOWN: 237 int actionIndex = ev.getActionIndex(); 238 mActiveTouchId = ev.getPointerId(actionIndex); 239 break; 240 case MotionEvent.ACTION_POINTER_UP: 241 actionIndex = ev.getActionIndex(); 242 int pointerId = ev.getPointerId(actionIndex); 243 if (pointerId == mActiveTouchId) { 244 // This was our active pointer going up. Choose a new active pointer. 245 int newActionIndex = actionIndex == 0 ? 1 : 0; 246 mActiveTouchId = ev.getPointerId(newActionIndex); 247 } 248 break; 249 250 case MotionEvent.ACTION_CANCEL: 251 case MotionEvent.ACTION_UP: 252 resetMembers(); 253 break; 254 255 case MotionEvent.ACTION_MOVE: 256 if (mVelocityTracker == null || mDiscardIntercept) { 257 break; 258 } 259 260 int pointerIndex = ev.findPointerIndex(mActiveTouchId); 261 if (pointerIndex == -1) { 262 Log.e(TAG, "Invalid pointer index: ignoring."); 263 mDiscardIntercept = true; 264 break; 265 } 266 float dx = ev.getRawX() - mDownX; 267 float x = ev.getX(pointerIndex); 268 float y = ev.getY(pointerIndex); 269 270 if (dx != 0 && mDownX >= mGestureThresholdPx && canScroll(this, false, dx, x, y)) { 271 mDiscardIntercept = true; 272 break; 273 } 274 updateSwiping(ev); 275 break; 276 } 277 278 if ((mOnPreSwipeListener == null && !mDisallowIntercept) 279 || mOnPreSwipeListener.onPreSwipe(this, mDownX, mDownY)) { 280 return (!mDiscardIntercept && mSwiping); 281 } 282 return false; 283 } 284 285 @Override 286 public boolean canScrollHorizontally(int direction) { 287 // This view can only be swiped horizontally from left to right - this means a negative 288 // SCROLLING direction. We return false if the view is not visible to avoid capturing swipe 289 // gestures when the view is hidden. 290 return direction < 0 && isSwipeable() && getVisibility() == View.VISIBLE; 291 } 292 293 /** 294 * Helper function determining if a particular move gesture was verbose enough to qualify as a 295 * beginning of a swipe. 296 * 297 * @param dx distance traveled in the x direction, from the initial touch down 298 * @param dy distance traveled in the y direction, from the initial touch down 299 * @return {@code true} if the gesture was long enough to be considered a potential swipe 300 */ 301 private boolean isPotentialSwipe(float dx, float dy) { 302 return (dx * dx) + (dy * dy) > mSlop * mSlop; 303 } 304 305 @Override 306 public boolean onTouchEvent(MotionEvent ev) { 307 if (!mSwipeable) { 308 return super.onTouchEvent(ev); 309 } 310 311 if (mVelocityTracker == null) { 312 return super.onTouchEvent(ev); 313 } 314 315 if (mOnPreSwipeListener != null && !mOnPreSwipeListener.onPreSwipe(this, mDownX, mDownY)) { 316 return super.onTouchEvent(ev); 317 } 318 319 // offset because the view is translated during swipe 320 ev.offsetLocation(mTranslationX, 0); 321 switch (ev.getActionMasked()) { 322 case MotionEvent.ACTION_UP: 323 updateDismiss(ev); 324 if (mDismissed) { 325 dismiss(); 326 } else if (mSwiping) { 327 cancel(); 328 } 329 resetMembers(); 330 break; 331 332 case MotionEvent.ACTION_CANCEL: 333 cancel(); 334 resetMembers(); 335 break; 336 337 case MotionEvent.ACTION_MOVE: 338 mVelocityTracker.addMovement(ev); 339 mLastX = ev.getRawX(); 340 updateSwiping(ev); 341 if (mSwiping) { 342 setProgress(ev.getRawX() - mDownX); 343 break; 344 } 345 } 346 return true; 347 } 348 349 private void setProgress(float deltaX) { 350 mTranslationX = deltaX; 351 if (mProgressListener != null && deltaX >= 0) { 352 mProgressListener.onSwipeProgressChanged(this, deltaX / getWidth(), deltaX); 353 } 354 } 355 356 private void dismiss() { 357 if (mDismissedListener != null) { 358 mDismissedListener.onDismissed(this); 359 } 360 } 361 362 private void cancel() { 363 if (mProgressListener != null) { 364 mProgressListener.onSwipeCanceled(this); 365 } 366 } 367 368 /** Resets internal members when canceling or finishing a given gesture. */ 369 private void resetMembers() { 370 if (mVelocityTracker != null) { 371 mVelocityTracker.recycle(); 372 } 373 mVelocityTracker = null; 374 mTranslationX = 0; 375 mDownX = 0; 376 mDownY = 0; 377 mSwiping = false; 378 mDismissed = false; 379 mDiscardIntercept = false; 380 mCanStartSwipe = true; 381 mDisallowIntercept = false; 382 } 383 384 private void updateSwiping(MotionEvent ev) { 385 if (!mSwiping) { 386 float deltaX = ev.getRawX() - mDownX; 387 float deltaY = ev.getRawY() - mDownY; 388 if (isPotentialSwipe(deltaX, deltaY)) { 389 // There are three conditions on which we want want to start swiping: 390 // 1. The swipe is from left to right AND 391 // 2. It is horizontal AND 392 // 3. We actually can start swiping 393 mSwiping = mCanStartSwipe && Math.abs(deltaY) < Math.abs(deltaX) && deltaX > 0; 394 mCanStartSwipe = mSwiping; 395 } 396 } 397 } 398 399 private void updateDismiss(MotionEvent ev) { 400 float deltaX = ev.getRawX() - mDownX; 401 mVelocityTracker.addMovement(ev); 402 mVelocityTracker.computeCurrentVelocity(1000); 403 if (!mDismissed) { 404 if ((deltaX > (getWidth() * mDismissMinDragWidthRatio) && ev.getRawX() >= mLastX) 405 || mVelocityTracker.getXVelocity() >= mMinFlingVelocity) { 406 mDismissed = true; 407 } 408 } 409 // Check if the user tried to undo this. 410 if (mDismissed && mSwiping) { 411 // Check if the user's finger is actually flinging back to left 412 if (mVelocityTracker.getXVelocity() < -mMinFlingVelocity) { 413 mDismissed = false; 414 } 415 } 416 } 417 418 /** 419 * Tests scrollability within child views of v in the direction of dx. 420 * 421 * @param v view to test for horizontal scrollability 422 * @param checkV whether the view v passed should itself be checked for scrollability 423 * ({@code true}), or just its children ({@code false}) 424 * @param dx delta scrolled in pixels. Only the sign of this is used 425 * @param x x coordinate of the active touch point 426 * @param y y coordinate of the active touch point 427 * @return {@code true} if child views of v can be scrolled by delta of dx 428 */ 429 protected boolean canScroll(View v, boolean checkV, float dx, float x, float y) { 430 if (v instanceof ViewGroup) { 431 final ViewGroup group = (ViewGroup) v; 432 final int scrollX = v.getScrollX(); 433 final int scrollY = v.getScrollY(); 434 final int count = group.getChildCount(); 435 for (int i = count - 1; i >= 0; i--) { 436 final View child = group.getChildAt(i); 437 if (x + scrollX >= child.getLeft() 438 && x + scrollX < child.getRight() 439 && y + scrollY >= child.getTop() 440 && y + scrollY < child.getBottom() 441 && canScroll( 442 child, true, dx, x + scrollX - child.getLeft(), 443 y + scrollY - child.getTop())) { 444 return true; 445 } 446 } 447 } 448 449 return checkV && v.canScrollHorizontally((int) -dx); 450 } 451} 452