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