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