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 static android.support.annotation.RestrictTo.Scope.LIBRARY_GROUP;
20
21import android.support.annotation.IntDef;
22import android.support.annotation.NonNull;
23import android.support.annotation.RestrictTo;
24import android.support.v4.view.ViewCompat;
25import android.support.v4.widget.ViewDragHelper;
26import android.view.MotionEvent;
27import android.view.View;
28import android.view.ViewGroup;
29import android.view.ViewParent;
30
31import java.lang.annotation.Retention;
32import 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 */
38public 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         */
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         */
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     */
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     */
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     */
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     */
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     */
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     */
167    public void setSensitivity(float sensitivity) {
168        mSensitivity = sensitivity;
169        mSensitivitySet = true;
170    }
171
172    @Override
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
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     */
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
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
370        SettleRunnable(View view, boolean dismiss) {
371            mView = view;
372            mDismiss = dismiss;
373        }
374
375        @Override
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
387    static float clamp(float min, float value, float max) {
388        return Math.min(Math.max(min, value), max);
389    }
390
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     */
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     */
408    static float fraction(float startValue, float endValue, float value) {
409        return (value - startValue) / (endValue - startValue);
410    }
411}