SwipeDismissLayout.java revision 390120b925398c754b4f785fc12a8def0d09c09b
1/*
2 * Copyright (C) 2014 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 com.android.internal.widget;
18
19import android.animation.Animator;
20import android.animation.TimeInterpolator;
21import android.animation.ValueAnimator;
22import android.animation.ValueAnimator.AnimatorUpdateListener;
23import android.app.Activity;
24import android.content.BroadcastReceiver;
25import android.content.Context;
26import android.content.ContextWrapper;
27import android.content.Intent;
28import android.content.IntentFilter;
29import android.content.res.TypedArray;
30import android.util.AttributeSet;
31import android.util.Log;
32import android.view.MotionEvent;
33import android.view.VelocityTracker;
34import android.view.View;
35import android.view.ViewConfiguration;
36import android.view.ViewGroup;
37import android.view.animation.DecelerateInterpolator;
38import android.widget.FrameLayout;
39
40/**
41 * Special layout that finishes its activity when swiped away.
42 */
43public class SwipeDismissLayout extends FrameLayout {
44    private static final String TAG = "SwipeDismissLayout";
45
46    private static final float MAX_DIST_THRESHOLD = .33f;
47    private static final float MIN_DIST_THRESHOLD = .1f;
48
49    public interface OnDismissedListener {
50        void onDismissed(SwipeDismissLayout layout);
51    }
52
53    public interface OnSwipeProgressChangedListener {
54        /**
55         * Called when the layout has been swiped and the position of the window should change.
56         *
57         * @param alpha A number in [0, 1] representing what the alpha transparency of the window
58         * should be.
59         * @param translate A number in [0, w], where w is the width of the
60         * layout. This is equivalent to progress * layout.getWidth().
61         */
62        void onSwipeProgressChanged(SwipeDismissLayout layout, float alpha, float translate);
63
64        void onSwipeCancelled(SwipeDismissLayout layout);
65    }
66
67    private boolean mIsWindowNativelyTranslucent;
68
69    // Cached ViewConfiguration and system-wide constant values
70    private int mSlop;
71    private int mMinFlingVelocity;
72
73    // Transient properties
74    private int mActiveTouchId;
75    private float mDownX;
76    private float mDownY;
77    private float mLastX;
78    private boolean mSwiping;
79    private boolean mDismissed;
80    private boolean mDiscardIntercept;
81    private VelocityTracker mVelocityTracker;
82    private boolean mBlockGesture = false;
83    private boolean mActivityTranslucencyConverted = false;
84
85    private final DismissAnimator mDismissAnimator = new DismissAnimator();
86
87    private OnDismissedListener mDismissedListener;
88    private OnSwipeProgressChangedListener mProgressListener;
89    private BroadcastReceiver mScreenOffReceiver = new BroadcastReceiver() {
90        private Runnable mRunnable = new Runnable() {
91            @Override
92            public void run() {
93                if (mDismissed) {
94                    dismiss();
95                } else {
96                    cancel();
97                }
98                resetMembers();
99            }
100        };
101
102        @Override
103        public void onReceive(Context context, Intent intent) {
104            post(mRunnable);
105        }
106    };
107    private IntentFilter mScreenOffFilter = new IntentFilter(Intent.ACTION_SCREEN_OFF);
108
109
110    private boolean mDismissable = true;
111
112    public SwipeDismissLayout(Context context) {
113        super(context);
114        init(context);
115    }
116
117    public SwipeDismissLayout(Context context, AttributeSet attrs) {
118        super(context, attrs);
119        init(context);
120    }
121
122    public SwipeDismissLayout(Context context, AttributeSet attrs, int defStyle) {
123        super(context, attrs, defStyle);
124        init(context);
125    }
126
127    private void init(Context context) {
128        ViewConfiguration vc = ViewConfiguration.get(context);
129        mSlop = vc.getScaledTouchSlop();
130        mMinFlingVelocity = vc.getScaledMinimumFlingVelocity();
131        TypedArray a = context.getTheme().obtainStyledAttributes(
132                com.android.internal.R.styleable.Theme);
133        mIsWindowNativelyTranslucent = a.getBoolean(
134                com.android.internal.R.styleable.Window_windowIsTranslucent, false);
135        a.recycle();
136    }
137
138    public void setOnDismissedListener(OnDismissedListener listener) {
139        mDismissedListener = listener;
140    }
141
142    public void setOnSwipeProgressChangedListener(OnSwipeProgressChangedListener listener) {
143        mProgressListener = listener;
144    }
145
146    @Override
147    protected void onAttachedToWindow() {
148        super.onAttachedToWindow();
149        getContext().registerReceiver(mScreenOffReceiver, mScreenOffFilter);
150    }
151
152    @Override
153    protected void onDetachedFromWindow() {
154        getContext().unregisterReceiver(mScreenOffReceiver);
155        super.onDetachedFromWindow();
156    }
157
158    @Override
159    public boolean onInterceptTouchEvent(MotionEvent ev) {
160        checkGesture((ev));
161        if (mBlockGesture) {
162            return true;
163        }
164        if (!mDismissable) {
165            return super.onInterceptTouchEvent(ev);
166        }
167
168        // Offset because the view is translated during swipe, match X with raw X. Active touch
169        // coordinates are mostly used by the velocity tracker, so offset it to match the raw
170        // coordinates which is what is primarily used elsewhere.
171        ev.offsetLocation(ev.getRawX() - ev.getX(), 0);
172
173        switch (ev.getActionMasked()) {
174            case MotionEvent.ACTION_DOWN:
175                resetMembers();
176                mDownX = ev.getRawX();
177                mDownY = ev.getRawY();
178                mActiveTouchId = ev.getPointerId(0);
179                mVelocityTracker = VelocityTracker.obtain("int1");
180                mVelocityTracker.addMovement(ev);
181                break;
182
183            case MotionEvent.ACTION_POINTER_DOWN:
184                int actionIndex = ev.getActionIndex();
185                mActiveTouchId = ev.getPointerId(actionIndex);
186                break;
187            case MotionEvent.ACTION_POINTER_UP:
188                actionIndex = ev.getActionIndex();
189                int pointerId = ev.getPointerId(actionIndex);
190                if (pointerId == mActiveTouchId) {
191                    // This was our active pointer going up. Choose a new active pointer.
192                    int newActionIndex = actionIndex == 0 ? 1 : 0;
193                    mActiveTouchId = ev.getPointerId(newActionIndex);
194                }
195                break;
196
197            case MotionEvent.ACTION_CANCEL:
198            case MotionEvent.ACTION_UP:
199                resetMembers();
200                break;
201
202            case MotionEvent.ACTION_MOVE:
203                if (mVelocityTracker == null || mDiscardIntercept) {
204                    break;
205                }
206
207                int pointerIndex = ev.findPointerIndex(mActiveTouchId);
208                if (pointerIndex == -1) {
209                    Log.e(TAG, "Invalid pointer index: ignoring.");
210                    mDiscardIntercept = true;
211                    break;
212                }
213                float dx = ev.getRawX() - mDownX;
214                float x = ev.getX(pointerIndex);
215                float y = ev.getY(pointerIndex);
216                if (dx != 0 && canScroll(this, false, dx, x, y)) {
217                    mDiscardIntercept = true;
218                    break;
219                }
220                updateSwiping(ev);
221                break;
222        }
223
224        return !mDiscardIntercept && mSwiping;
225    }
226
227    @Override
228    public boolean onTouchEvent(MotionEvent ev) {
229        checkGesture((ev));
230        if (mBlockGesture) {
231            return true;
232        }
233        if (mVelocityTracker == null || !mDismissable) {
234            return super.onTouchEvent(ev);
235        }
236
237        // Offset because the view is translated during swipe, match X with raw X. Active touch
238        // coordinates are mostly used by the velocity tracker, so offset it to match the raw
239        // coordinates which is what is primarily used elsewhere.
240        ev.offsetLocation(ev.getRawX() - ev.getX(), 0);
241
242        switch (ev.getActionMasked()) {
243            case MotionEvent.ACTION_UP:
244                updateDismiss(ev);
245                if (mDismissed) {
246                    mDismissAnimator.animateDismissal(ev.getRawX() - mDownX);
247                } else if (mSwiping
248                        // Only trigger animation if we had a MOVE event that would shift the
249                        // underlying view, otherwise the animation would be janky.
250                        && mLastX != Integer.MIN_VALUE) {
251                    mDismissAnimator.animateRecovery(ev.getRawX() - mDownX);
252                }
253                resetMembers();
254                break;
255
256            case MotionEvent.ACTION_CANCEL:
257                cancel();
258                resetMembers();
259                break;
260
261            case MotionEvent.ACTION_MOVE:
262                mVelocityTracker.addMovement(ev);
263                mLastX = ev.getRawX();
264                updateSwiping(ev);
265                if (mSwiping) {
266                    setProgress(ev.getRawX() - mDownX);
267                    break;
268                }
269        }
270        return true;
271    }
272
273    private void setProgress(float deltaX) {
274        if (mProgressListener != null && deltaX >= 0)  {
275            mProgressListener.onSwipeProgressChanged(
276                    this, progressToAlpha(deltaX / getWidth()), deltaX);
277        }
278    }
279
280    private void dismiss() {
281        if (mDismissedListener != null) {
282            mDismissedListener.onDismissed(this);
283        }
284    }
285
286    protected void cancel() {
287        if (!mIsWindowNativelyTranslucent) {
288            Activity activity = findActivity();
289            if (activity != null && mActivityTranslucencyConverted) {
290                activity.convertFromTranslucent();
291                mActivityTranslucencyConverted = false;
292            }
293        }
294        if (mProgressListener != null) {
295            mProgressListener.onSwipeCancelled(this);
296        }
297    }
298
299    /**
300     * Resets internal members when canceling.
301     */
302    private void resetMembers() {
303        if (mVelocityTracker != null) {
304            mVelocityTracker.recycle();
305        }
306        mVelocityTracker = null;
307        mDownX = 0;
308        mLastX = Integer.MIN_VALUE;
309        mDownY = 0;
310        mSwiping = false;
311        mDismissed = false;
312        mDiscardIntercept = false;
313    }
314
315    private void updateSwiping(MotionEvent ev) {
316        boolean oldSwiping = mSwiping;
317        if (!mSwiping) {
318            float deltaX = ev.getRawX() - mDownX;
319            float deltaY = ev.getRawY() - mDownY;
320            if ((deltaX * deltaX) + (deltaY * deltaY) > mSlop * mSlop) {
321                mSwiping = deltaX > mSlop * 2 && Math.abs(deltaY) < Math.abs(deltaX);
322            } else {
323                mSwiping = false;
324            }
325        }
326
327        if (mSwiping && !oldSwiping) {
328            // Swiping has started
329            if (!mIsWindowNativelyTranslucent) {
330                Activity activity = findActivity();
331                if (activity != null) {
332                    mActivityTranslucencyConverted = activity.convertToTranslucent(null, null);
333                }
334            }
335        }
336    }
337
338    private void updateDismiss(MotionEvent ev) {
339        float deltaX = ev.getRawX() - mDownX;
340        // Don't add the motion event as an UP event would clear the velocity tracker
341        mVelocityTracker.computeCurrentVelocity(1000);
342        float xVelocity = mVelocityTracker.getXVelocity();
343        if (mLastX == Integer.MIN_VALUE) {
344            // If there's no changes to mLastX, we have only one point of data, and therefore no
345            // velocity. Estimate velocity from just the up and down event in that case.
346            xVelocity = deltaX / ((ev.getEventTime() - ev.getDownTime()) / 1000);
347        }
348        if (!mDismissed) {
349            // Adjust the distance threshold linearly between the min and max threshold based on the
350            // x-velocity scaled with the the fling threshold speed
351            float distanceThreshold = getWidth() * Math.max(
352                    Math.min((MIN_DIST_THRESHOLD - MAX_DIST_THRESHOLD)
353                            * xVelocity / mMinFlingVelocity // scale x-velocity with fling velocity
354                            + MAX_DIST_THRESHOLD, // offset to start at max threshold
355                            MAX_DIST_THRESHOLD), // cap at max threshold
356                    MIN_DIST_THRESHOLD); // bottom out at min threshold
357            if ((deltaX > distanceThreshold && ev.getRawX() >= mLastX)
358                    || xVelocity >= mMinFlingVelocity) {
359                mDismissed = true;
360            }
361        }
362        // Check if the user tried to undo this.
363        if (mDismissed && mSwiping) {
364            // Check if the user's finger is actually flinging back to left
365            if (xVelocity < -mMinFlingVelocity) {
366                mDismissed = false;
367            }
368        }
369    }
370
371    /**
372     * Tests scrollability within child views of v in the direction of dx.
373     *
374     * @param v View to test for horizontal scrollability
375     * @param checkV Whether the view v passed should itself be checked for scrollability (true),
376     *               or just its children (false).
377     * @param dx Delta scrolled in pixels. Only the sign of this is used.
378     * @param x X coordinate of the active touch point
379     * @param y Y coordinate of the active touch point
380     * @return true if child views of v can be scrolled by delta of dx.
381     */
382    protected boolean canScroll(View v, boolean checkV, float dx, float x, float y) {
383        if (v instanceof ViewGroup) {
384            final ViewGroup group = (ViewGroup) v;
385            final int scrollX = v.getScrollX();
386            final int scrollY = v.getScrollY();
387            final int count = group.getChildCount();
388            for (int i = count - 1; i >= 0; i--) {
389                final View child = group.getChildAt(i);
390                if (x + scrollX >= child.getLeft() && x + scrollX < child.getRight() &&
391                        y + scrollY >= child.getTop() && y + scrollY < child.getBottom() &&
392                        canScroll(child, true, dx, x + scrollX - child.getLeft(),
393                                y + scrollY - child.getTop())) {
394                    return true;
395                }
396            }
397        }
398
399        return checkV && v.canScrollHorizontally((int) -dx);
400    }
401
402    public void setDismissable(boolean dismissable) {
403        if (!dismissable && mDismissable) {
404            cancel();
405            resetMembers();
406        }
407
408        mDismissable = dismissable;
409    }
410
411    private void checkGesture(MotionEvent ev) {
412        if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) {
413            mBlockGesture = mDismissAnimator.isAnimating();
414        }
415    }
416
417    private float progressToAlpha(float progress) {
418        return 1 - progress * progress * progress;
419    }
420
421    private Activity findActivity() {
422        Context context = getContext();
423        while (context instanceof ContextWrapper) {
424            if (context instanceof Activity) {
425                return (Activity) context;
426            }
427            context = ((ContextWrapper) context).getBaseContext();
428        }
429        return null;
430    }
431
432    private class DismissAnimator implements AnimatorUpdateListener, Animator.AnimatorListener {
433        private final TimeInterpolator DISMISS_INTERPOLATOR = new DecelerateInterpolator(1.5f);
434        private final long DISMISS_DURATION = 250;
435
436        private final ValueAnimator mDismissAnimator = new ValueAnimator();
437        private boolean mWasCanceled = false;
438        private boolean mDismissOnComplete = false;
439
440        /* package */ DismissAnimator() {
441            mDismissAnimator.addUpdateListener(this);
442            mDismissAnimator.addListener(this);
443        }
444
445        /* package */ void animateDismissal(float currentTranslation) {
446            animate(
447                    currentTranslation / getWidth(),
448                    1,
449                    DISMISS_DURATION,
450                    DISMISS_INTERPOLATOR,
451                    true /* dismiss */);
452        }
453
454        /* package */ void animateRecovery(float currentTranslation) {
455            animate(
456                    currentTranslation / getWidth(),
457                    0,
458                    DISMISS_DURATION,
459                    DISMISS_INTERPOLATOR,
460                    false /* don't dismiss */);
461        }
462
463        /* package */ boolean isAnimating() {
464            return mDismissAnimator.isStarted();
465        }
466
467        private void animate(float from, float to, long duration, TimeInterpolator interpolator,
468                boolean dismissOnComplete) {
469            mDismissAnimator.cancel();
470            mDismissOnComplete = dismissOnComplete;
471            mDismissAnimator.setFloatValues(from, to);
472            mDismissAnimator.setDuration(duration);
473            mDismissAnimator.setInterpolator(interpolator);
474            mDismissAnimator.start();
475        }
476
477        @Override
478        public void onAnimationUpdate(ValueAnimator animation) {
479            float value = (Float) animation.getAnimatedValue();
480            setProgress(value * getWidth());
481        }
482
483        @Override
484        public void onAnimationStart(Animator animation) {
485            mWasCanceled = false;
486        }
487
488        @Override
489        public void onAnimationCancel(Animator animation) {
490            mWasCanceled = true;
491        }
492
493        @Override
494        public void onAnimationEnd(Animator animation) {
495            if (!mWasCanceled) {
496                if (mDismissOnComplete) {
497                    dismiss();
498                } else {
499                    cancel();
500                }
501            }
502        }
503
504        @Override
505        public void onAnimationRepeat(Animator animation) {
506        }
507    }
508}
509