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