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