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}