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 */ 16 17 package android.support.design.widget; 18 19 import static android.support.annotation.RestrictTo.Scope.LIBRARY_GROUP; 20 21 import android.support.annotation.IntDef; 22 import android.support.annotation.NonNull; 23 import android.support.annotation.RestrictTo; 24 import android.support.v4.view.ViewCompat; 25 import android.support.v4.widget.ViewDragHelper; 26 import android.view.MotionEvent; 27 import android.view.View; 28 import android.view.ViewGroup; 29 import android.view.ViewParent; 30 31 import java.lang.annotation.Retention; 32 import java.lang.annotation.RetentionPolicy; 33 34 /** 35 * An interaction behavior plugin for child views of {@link CoordinatorLayout} to provide support 36 * for the 'swipe-to-dismiss' gesture. 37 */ 38 public class SwipeDismissBehavior<V extends View> extends CoordinatorLayout.Behavior<V> { 39 40 /** 41 * A view is not currently being dragged or animating as a result of a fling/snap. 42 */ 43 public static final int STATE_IDLE = ViewDragHelper.STATE_IDLE; 44 45 /** 46 * A view is currently being dragged. The position is currently changing as a result 47 * of user input or simulated user input. 48 */ 49 public static final int STATE_DRAGGING = ViewDragHelper.STATE_DRAGGING; 50 51 /** 52 * A view is currently settling into place as a result of a fling or 53 * predefined non-interactive motion. 54 */ 55 public static final int STATE_SETTLING = ViewDragHelper.STATE_SETTLING; 56 57 /** @hide */ 58 @RestrictTo(LIBRARY_GROUP) 59 @IntDef({SWIPE_DIRECTION_START_TO_END, SWIPE_DIRECTION_END_TO_START, SWIPE_DIRECTION_ANY}) 60 @Retention(RetentionPolicy.SOURCE) 61 private @interface SwipeDirection {} 62 63 /** 64 * Swipe direction that only allows swiping in the direction of start-to-end. That is 65 * left-to-right in LTR, or right-to-left in RTL. 66 */ 67 public static final int SWIPE_DIRECTION_START_TO_END = 0; 68 69 /** 70 * Swipe direction that only allows swiping in the direction of end-to-start. That is 71 * right-to-left in LTR or left-to-right in RTL. 72 */ 73 public static final int SWIPE_DIRECTION_END_TO_START = 1; 74 75 /** 76 * Swipe direction which allows swiping in either direction. 77 */ 78 public static final int SWIPE_DIRECTION_ANY = 2; 79 80 private static final float DEFAULT_DRAG_DISMISS_THRESHOLD = 0.5f; 81 private static final float DEFAULT_ALPHA_START_DISTANCE = 0f; 82 private static final float DEFAULT_ALPHA_END_DISTANCE = DEFAULT_DRAG_DISMISS_THRESHOLD; 83 84 ViewDragHelper mViewDragHelper; 85 OnDismissListener mListener; 86 private boolean mInterceptingEvents; 87 88 private float mSensitivity = 0f; 89 private boolean mSensitivitySet; 90 91 int mSwipeDirection = SWIPE_DIRECTION_ANY; 92 float mDragDismissThreshold = DEFAULT_DRAG_DISMISS_THRESHOLD; 93 float mAlphaStartSwipeDistance = DEFAULT_ALPHA_START_DISTANCE; 94 float mAlphaEndSwipeDistance = DEFAULT_ALPHA_END_DISTANCE; 95 96 /** 97 * Callback interface used to notify the application that the view has been dismissed. 98 */ 99 public interface OnDismissListener { 100 /** 101 * Called when {@code view} has been dismissed via swiping. 102 */ onDismiss(View view)103 public void onDismiss(View view); 104 105 /** 106 * Called when the drag state has changed. 107 * 108 * @param state the new state. One of 109 * {@link #STATE_IDLE}, {@link #STATE_DRAGGING} or {@link #STATE_SETTLING}. 110 */ onDragStateChanged(int state)111 public void onDragStateChanged(int state); 112 } 113 114 /** 115 * Set the listener to be used when a dismiss event occurs. 116 * 117 * @param listener the listener to use. 118 */ setListener(OnDismissListener listener)119 public void setListener(OnDismissListener listener) { 120 mListener = listener; 121 } 122 123 /** 124 * Sets the swipe direction for this behavior. 125 * 126 * @param direction one of the {@link #SWIPE_DIRECTION_START_TO_END}, 127 * {@link #SWIPE_DIRECTION_END_TO_START} or {@link #SWIPE_DIRECTION_ANY} 128 */ setSwipeDirection(@wipeDirection int direction)129 public void setSwipeDirection(@SwipeDirection int direction) { 130 mSwipeDirection = direction; 131 } 132 133 /** 134 * Set the threshold for telling if a view has been dragged enough to be dismissed. 135 * 136 * @param distance a ratio of a view's width, values are clamped to 0 >= x <= 1f; 137 */ setDragDismissDistance(float distance)138 public void setDragDismissDistance(float distance) { 139 mDragDismissThreshold = clamp(0f, distance, 1f); 140 } 141 142 /** 143 * The minimum swipe distance before the view's alpha is modified. 144 * 145 * @param fraction the distance as a fraction of the view's width. 146 */ setStartAlphaSwipeDistance(float fraction)147 public void setStartAlphaSwipeDistance(float fraction) { 148 mAlphaStartSwipeDistance = clamp(0f, fraction, 1f); 149 } 150 151 /** 152 * The maximum swipe distance for the view's alpha is modified. 153 * 154 * @param fraction the distance as a fraction of the view's width. 155 */ setEndAlphaSwipeDistance(float fraction)156 public void setEndAlphaSwipeDistance(float fraction) { 157 mAlphaEndSwipeDistance = clamp(0f, fraction, 1f); 158 } 159 160 /** 161 * Set the sensitivity used for detecting the start of a swipe. This only takes effect if 162 * no touch handling has occured yet. 163 * 164 * @param sensitivity Multiplier for how sensitive we should be about detecting 165 * the start of a drag. Larger values are more sensitive. 1.0f is normal. 166 */ setSensitivity(float sensitivity)167 public void setSensitivity(float sensitivity) { 168 mSensitivity = sensitivity; 169 mSensitivitySet = true; 170 } 171 172 @Override onInterceptTouchEvent(CoordinatorLayout parent, V child, MotionEvent event)173 public boolean onInterceptTouchEvent(CoordinatorLayout parent, V child, MotionEvent event) { 174 boolean dispatchEventToHelper = mInterceptingEvents; 175 176 switch (event.getActionMasked()) { 177 case MotionEvent.ACTION_DOWN: 178 mInterceptingEvents = parent.isPointInChildBounds(child, 179 (int) event.getX(), (int) event.getY()); 180 dispatchEventToHelper = mInterceptingEvents; 181 break; 182 case MotionEvent.ACTION_UP: 183 case MotionEvent.ACTION_CANCEL: 184 // Reset the ignore flag for next time 185 mInterceptingEvents = false; 186 break; 187 } 188 189 if (dispatchEventToHelper) { 190 ensureViewDragHelper(parent); 191 return mViewDragHelper.shouldInterceptTouchEvent(event); 192 } 193 return false; 194 } 195 196 @Override onTouchEvent(CoordinatorLayout parent, V child, MotionEvent event)197 public boolean onTouchEvent(CoordinatorLayout parent, V child, MotionEvent event) { 198 if (mViewDragHelper != null) { 199 mViewDragHelper.processTouchEvent(event); 200 return true; 201 } 202 return false; 203 } 204 205 /** 206 * Called when the user's input indicates that they want to swipe the given view. 207 * 208 * @param view View the user is attempting to swipe 209 * @return true if the view can be dismissed via swiping, false otherwise 210 */ canSwipeDismissView(@onNull View view)211 public boolean canSwipeDismissView(@NonNull View view) { 212 return true; 213 } 214 215 private final ViewDragHelper.Callback mDragCallback = new ViewDragHelper.Callback() { 216 private static final int INVALID_POINTER_ID = -1; 217 218 private int mOriginalCapturedViewLeft; 219 private int mActivePointerId = INVALID_POINTER_ID; 220 221 @Override 222 public boolean tryCaptureView(View child, int pointerId) { 223 // Only capture if we don't already have an active pointer id 224 return mActivePointerId == INVALID_POINTER_ID && canSwipeDismissView(child); 225 } 226 227 @Override 228 public void onViewCaptured(View capturedChild, int activePointerId) { 229 mActivePointerId = activePointerId; 230 mOriginalCapturedViewLeft = capturedChild.getLeft(); 231 232 // The view has been captured, and thus a drag is about to start so stop any parents 233 // intercepting 234 final ViewParent parent = capturedChild.getParent(); 235 if (parent != null) { 236 parent.requestDisallowInterceptTouchEvent(true); 237 } 238 } 239 240 @Override 241 public void onViewDragStateChanged(int state) { 242 if (mListener != null) { 243 mListener.onDragStateChanged(state); 244 } 245 } 246 247 @Override 248 public void onViewReleased(View child, float xvel, float yvel) { 249 // Reset the active pointer ID 250 mActivePointerId = INVALID_POINTER_ID; 251 252 final int childWidth = child.getWidth(); 253 int targetLeft; 254 boolean dismiss = false; 255 256 if (shouldDismiss(child, xvel)) { 257 targetLeft = child.getLeft() < mOriginalCapturedViewLeft 258 ? mOriginalCapturedViewLeft - childWidth 259 : mOriginalCapturedViewLeft + childWidth; 260 dismiss = true; 261 } else { 262 // Else, reset back to the original left 263 targetLeft = mOriginalCapturedViewLeft; 264 } 265 266 if (mViewDragHelper.settleCapturedViewAt(targetLeft, child.getTop())) { 267 ViewCompat.postOnAnimation(child, new SettleRunnable(child, dismiss)); 268 } else if (dismiss && mListener != null) { 269 mListener.onDismiss(child); 270 } 271 } 272 273 private boolean shouldDismiss(View child, float xvel) { 274 if (xvel != 0f) { 275 final boolean isRtl = ViewCompat.getLayoutDirection(child) 276 == ViewCompat.LAYOUT_DIRECTION_RTL; 277 278 if (mSwipeDirection == SWIPE_DIRECTION_ANY) { 279 // We don't care about the direction so return true 280 return true; 281 } else if (mSwipeDirection == SWIPE_DIRECTION_START_TO_END) { 282 // We only allow start-to-end swiping, so the fling needs to be in the 283 // correct direction 284 return isRtl ? xvel < 0f : xvel > 0f; 285 } else if (mSwipeDirection == SWIPE_DIRECTION_END_TO_START) { 286 // We only allow end-to-start swiping, so the fling needs to be in the 287 // correct direction 288 return isRtl ? xvel > 0f : xvel < 0f; 289 } 290 } else { 291 final int distance = child.getLeft() - mOriginalCapturedViewLeft; 292 final int thresholdDistance = Math.round(child.getWidth() * mDragDismissThreshold); 293 return Math.abs(distance) >= thresholdDistance; 294 } 295 296 return false; 297 } 298 299 @Override 300 public int getViewHorizontalDragRange(View child) { 301 return child.getWidth(); 302 } 303 304 @Override 305 public int clampViewPositionHorizontal(View child, int left, int dx) { 306 final boolean isRtl = ViewCompat.getLayoutDirection(child) 307 == ViewCompat.LAYOUT_DIRECTION_RTL; 308 int min, max; 309 310 if (mSwipeDirection == SWIPE_DIRECTION_START_TO_END) { 311 if (isRtl) { 312 min = mOriginalCapturedViewLeft - child.getWidth(); 313 max = mOriginalCapturedViewLeft; 314 } else { 315 min = mOriginalCapturedViewLeft; 316 max = mOriginalCapturedViewLeft + child.getWidth(); 317 } 318 } else if (mSwipeDirection == SWIPE_DIRECTION_END_TO_START) { 319 if (isRtl) { 320 min = mOriginalCapturedViewLeft; 321 max = mOriginalCapturedViewLeft + child.getWidth(); 322 } else { 323 min = mOriginalCapturedViewLeft - child.getWidth(); 324 max = mOriginalCapturedViewLeft; 325 } 326 } else { 327 min = mOriginalCapturedViewLeft - child.getWidth(); 328 max = mOriginalCapturedViewLeft + child.getWidth(); 329 } 330 331 return clamp(min, left, max); 332 } 333 334 @Override 335 public int clampViewPositionVertical(View child, int top, int dy) { 336 return child.getTop(); 337 } 338 339 @Override 340 public void onViewPositionChanged(View child, int left, int top, int dx, int dy) { 341 final float startAlphaDistance = mOriginalCapturedViewLeft 342 + child.getWidth() * mAlphaStartSwipeDistance; 343 final float endAlphaDistance = mOriginalCapturedViewLeft 344 + child.getWidth() * mAlphaEndSwipeDistance; 345 346 if (left <= startAlphaDistance) { 347 child.setAlpha(1f); 348 } else if (left >= endAlphaDistance) { 349 child.setAlpha(0f); 350 } else { 351 // We're between the start and end distances 352 final float distance = fraction(startAlphaDistance, endAlphaDistance, left); 353 child.setAlpha(clamp(0f, 1f - distance, 1f)); 354 } 355 } 356 }; 357 ensureViewDragHelper(ViewGroup parent)358 private void ensureViewDragHelper(ViewGroup parent) { 359 if (mViewDragHelper == null) { 360 mViewDragHelper = mSensitivitySet 361 ? ViewDragHelper.create(parent, mSensitivity, mDragCallback) 362 : ViewDragHelper.create(parent, mDragCallback); 363 } 364 } 365 366 private class SettleRunnable implements Runnable { 367 private final View mView; 368 private final boolean mDismiss; 369 SettleRunnable(View view, boolean dismiss)370 SettleRunnable(View view, boolean dismiss) { 371 mView = view; 372 mDismiss = dismiss; 373 } 374 375 @Override run()376 public void run() { 377 if (mViewDragHelper != null && mViewDragHelper.continueSettling(true)) { 378 ViewCompat.postOnAnimation(mView, this); 379 } else { 380 if (mDismiss && mListener != null) { 381 mListener.onDismiss(mView); 382 } 383 } 384 } 385 } 386 clamp(float min, float value, float max)387 static float clamp(float min, float value, float max) { 388 return Math.min(Math.max(min, value), max); 389 } 390 clamp(int min, int value, int max)391 static int clamp(int min, int value, int max) { 392 return Math.min(Math.max(min, value), max); 393 } 394 395 /** 396 * Retrieve the current drag state of this behavior. This will return one of 397 * {@link #STATE_IDLE}, {@link #STATE_DRAGGING} or {@link #STATE_SETTLING}. 398 * 399 * @return The current drag state 400 */ getDragState()401 public int getDragState() { 402 return mViewDragHelper != null ? mViewDragHelper.getViewDragState() : STATE_IDLE; 403 } 404 405 /** 406 * The fraction that {@code value} is between {@code startValue} and {@code endValue}. 407 */ fraction(float startValue, float endValue, float value)408 static float fraction(float startValue, float endValue, float value) { 409 return (value - startValue) / (endValue - startValue); 410 } 411 }