SwipeDismissLayout.java revision 7d6cb913de9b51dba0bae79e527b7d4fe79eb35d
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.Intent;
27import android.content.IntentFilter;
28import android.content.res.TypedArray;
29import android.util.AttributeSet;
30import android.util.Log;
31import android.view.MotionEvent;
32import android.view.VelocityTracker;
33import android.view.View;
34import android.view.ViewConfiguration;
35import android.view.ViewGroup;
36import android.view.ViewTreeObserver;
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 DISMISS_MIN_DRAG_WIDTH_RATIO = .33f;
47    private boolean mUseDynamicTranslucency = true;
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    // Cached ViewConfiguration and system-wide constant values
68    private int mSlop;
69    private int mMinFlingVelocity;
70
71    // Transient properties
72    private int mActiveTouchId;
73    private float mDownX;
74    private float mDownY;
75    private boolean mSwiping;
76    private boolean mDismissed;
77    private boolean mDiscardIntercept;
78    private VelocityTracker mVelocityTracker;
79    private float mTranslationX;
80    private boolean mBlockGesture = false;
81
82    private final DismissAnimator mDismissAnimator = new DismissAnimator();
83
84    private OnDismissedListener mDismissedListener;
85    private OnSwipeProgressChangedListener mProgressListener;
86    private ViewTreeObserver.OnEnterAnimationCompleteListener mOnEnterAnimationCompleteListener =
87            new ViewTreeObserver.OnEnterAnimationCompleteListener() {
88                @Override
89                public void onEnterAnimationComplete() {
90                    // SwipeDismissLayout assumes that the host Activity is translucent
91                    // and temporarily disables translucency when it is fully visible.
92                    // As soon as the user starts swiping, we will re-enable
93                    // translucency.
94                    if (mUseDynamicTranslucency && getContext() instanceof Activity) {
95                        ((Activity) getContext()).convertFromTranslucent();
96                    }
97                }
98            };
99    private BroadcastReceiver mScreenOffReceiver = new BroadcastReceiver() {
100        private Runnable mRunnable = new Runnable() {
101            @Override
102            public void run() {
103                if (mDismissed) {
104                    dismiss();
105                } else {
106                    cancel();
107                }
108                resetMembers();
109            }
110        };
111
112        @Override
113        public void onReceive(Context context, Intent intent) {
114            post(mRunnable);
115        }
116    };
117    private IntentFilter mScreenOffFilter = new IntentFilter(Intent.ACTION_SCREEN_OFF);
118
119    private float mLastX;
120
121    private boolean mDismissable = true;
122
123    public SwipeDismissLayout(Context context) {
124        super(context);
125        init(context);
126    }
127
128    public SwipeDismissLayout(Context context, AttributeSet attrs) {
129        super(context, attrs);
130        init(context);
131    }
132
133    public SwipeDismissLayout(Context context, AttributeSet attrs, int defStyle) {
134        super(context, attrs, defStyle);
135        init(context);
136    }
137
138    private void init(Context context) {
139        ViewConfiguration vc = ViewConfiguration.get(context);
140        mSlop = vc.getScaledTouchSlop();
141        mMinFlingVelocity = vc.getScaledMinimumFlingVelocity();
142        TypedArray a = context.getTheme().obtainStyledAttributes(
143                com.android.internal.R.styleable.Theme);
144        mUseDynamicTranslucency = !a.hasValue(
145                com.android.internal.R.styleable.Window_windowIsTranslucent);
146        a.recycle();
147    }
148
149    public void setOnDismissedListener(OnDismissedListener listener) {
150        mDismissedListener = listener;
151    }
152
153    public void setOnSwipeProgressChangedListener(OnSwipeProgressChangedListener listener) {
154        mProgressListener = listener;
155    }
156
157    @Override
158    protected void onAttachedToWindow() {
159        super.onAttachedToWindow();
160        if (getContext() instanceof Activity) {
161            getViewTreeObserver().addOnEnterAnimationCompleteListener(
162                    mOnEnterAnimationCompleteListener);
163        }
164        getContext().registerReceiver(mScreenOffReceiver, mScreenOffFilter);
165    }
166
167    @Override
168    protected void onDetachedFromWindow() {
169        getContext().unregisterReceiver(mScreenOffReceiver);
170        if (getContext() instanceof Activity) {
171            getViewTreeObserver().removeOnEnterAnimationCompleteListener(
172                    mOnEnterAnimationCompleteListener);
173        }
174        super.onDetachedFromWindow();
175    }
176
177    @Override
178    public boolean onInterceptTouchEvent(MotionEvent ev) {
179        checkGesture((ev));
180        if (mBlockGesture) {
181            return true;
182        }
183        if (!mDismissable) {
184            return super.onInterceptTouchEvent(ev);
185        }
186
187        // offset because the view is translated during swipe
188        ev.offsetLocation(mTranslationX, 0);
189
190        switch (ev.getActionMasked()) {
191            case MotionEvent.ACTION_DOWN:
192                resetMembers();
193                mDownX = ev.getRawX();
194                mDownY = ev.getRawY();
195                mActiveTouchId = ev.getPointerId(0);
196                mVelocityTracker = VelocityTracker.obtain();
197                mVelocityTracker.addMovement(ev);
198                break;
199
200            case MotionEvent.ACTION_POINTER_DOWN:
201                int actionIndex = ev.getActionIndex();
202                mActiveTouchId = ev.getPointerId(actionIndex);
203                break;
204            case MotionEvent.ACTION_POINTER_UP:
205                actionIndex = ev.getActionIndex();
206                int pointerId = ev.getPointerId(actionIndex);
207                if (pointerId == mActiveTouchId) {
208                    // This was our active pointer going up. Choose a new active pointer.
209                    int newActionIndex = actionIndex == 0 ? 1 : 0;
210                    mActiveTouchId = ev.getPointerId(newActionIndex);
211                }
212                break;
213
214            case MotionEvent.ACTION_CANCEL:
215            case MotionEvent.ACTION_UP:
216                resetMembers();
217                break;
218
219            case MotionEvent.ACTION_MOVE:
220                if (mVelocityTracker == null || mDiscardIntercept) {
221                    break;
222                }
223
224                int pointerIndex = ev.findPointerIndex(mActiveTouchId);
225                if (pointerIndex == -1) {
226                    Log.e(TAG, "Invalid pointer index: ignoring.");
227                    mDiscardIntercept = true;
228                    break;
229                }
230                float dx = ev.getRawX() - mDownX;
231                float x = ev.getX(pointerIndex);
232                float y = ev.getY(pointerIndex);
233                if (dx != 0 && canScroll(this, false, dx, x, y)) {
234                    mDiscardIntercept = true;
235                    break;
236                }
237                updateSwiping(ev);
238                break;
239        }
240
241        return !mDiscardIntercept && mSwiping;
242    }
243
244    @Override
245    public boolean onTouchEvent(MotionEvent ev) {
246        checkGesture((ev));
247        if (mBlockGesture) {
248            return true;
249        }
250        if (mVelocityTracker == null || !mDismissable) {
251            return super.onTouchEvent(ev);
252        }
253        // offset because the view is translated during swipe
254        ev.offsetLocation(mTranslationX, 0);
255        switch (ev.getActionMasked()) {
256            case MotionEvent.ACTION_UP:
257                updateDismiss(ev);
258                if (mDismissed) {
259                    mDismissAnimator.animateDismissal(ev.getRawX() - mDownX);
260                } else if (mSwiping) {
261                    mDismissAnimator.animateRecovery(ev.getRawX() - mDownX);
262                }
263                resetMembers();
264                break;
265
266            case MotionEvent.ACTION_CANCEL:
267                cancel();
268                resetMembers();
269                break;
270
271            case MotionEvent.ACTION_MOVE:
272                mVelocityTracker.addMovement(ev);
273                mLastX = ev.getRawX();
274                updateSwiping(ev);
275                if (mSwiping) {
276                    if (mUseDynamicTranslucency && getContext() instanceof Activity) {
277                        ((Activity) getContext()).convertToTranslucent(null, null);
278                    }
279                    setProgress(ev.getRawX() - mDownX);
280                    break;
281                }
282        }
283        return true;
284    }
285
286    private void setProgress(float deltaX) {
287        mTranslationX = deltaX;
288        if (mProgressListener != null && deltaX >= 0)  {
289            mProgressListener.onSwipeProgressChanged(
290                    this, progressToAlpha(deltaX / getWidth()), deltaX);
291        }
292    }
293
294    private void dismiss() {
295        if (mDismissedListener != null) {
296            mDismissedListener.onDismissed(this);
297        }
298    }
299
300    protected void cancel() {
301        if (mUseDynamicTranslucency && getContext() instanceof Activity) {
302            ((Activity) getContext()).convertFromTranslucent();
303        }
304        if (mProgressListener != null) {
305            mProgressListener.onSwipeCancelled(this);
306        }
307    }
308
309    /**
310     * Resets internal members when canceling.
311     */
312    private void resetMembers() {
313        if (mVelocityTracker != null) {
314            mVelocityTracker.recycle();
315        }
316        mVelocityTracker = null;
317        mTranslationX = 0;
318        mDownX = 0;
319        mDownY = 0;
320        mSwiping = false;
321        mDismissed = false;
322        mDiscardIntercept = false;
323    }
324
325    private void updateSwiping(MotionEvent ev) {
326        if (!mSwiping) {
327            float deltaX = ev.getRawX() - mDownX;
328            float deltaY = ev.getRawY() - mDownY;
329            if ((deltaX * deltaX) + (deltaY * deltaY) > mSlop * mSlop) {
330                mSwiping = deltaX > mSlop * 2 && Math.abs(deltaY) < Math.abs(deltaX);
331            } else {
332                mSwiping = false;
333            }
334        }
335    }
336
337    private void updateDismiss(MotionEvent ev) {
338        float deltaX = ev.getRawX() - mDownX;
339        mVelocityTracker.addMovement(ev);
340        mVelocityTracker.computeCurrentVelocity(1000);
341        if (!mDismissed) {
342
343            if (deltaX > (getWidth() * DISMISS_MIN_DRAG_WIDTH_RATIO) &&
344                    ev.getRawX() >= mLastX) {
345                mDismissed = true;
346            }
347        }
348        // Check if the user tried to undo this.
349        if (mDismissed && mSwiping) {
350            // Check if the user's finger is actually back
351            if (deltaX < (getWidth() * DISMISS_MIN_DRAG_WIDTH_RATIO) ||
352                    // or user is flinging back left
353                    mVelocityTracker.getXVelocity() < -mMinFlingVelocity) {
354                mDismissed = false;
355            }
356        }
357    }
358
359    /**
360     * Tests scrollability within child views of v in the direction of dx.
361     *
362     * @param v View to test for horizontal scrollability
363     * @param checkV Whether the view v passed should itself be checked for scrollability (true),
364     *               or just its children (false).
365     * @param dx Delta scrolled in pixels. Only the sign of this is used.
366     * @param x X coordinate of the active touch point
367     * @param y Y coordinate of the active touch point
368     * @return true if child views of v can be scrolled by delta of dx.
369     */
370    protected boolean canScroll(View v, boolean checkV, float dx, float x, float y) {
371        if (v instanceof ViewGroup) {
372            final ViewGroup group = (ViewGroup) v;
373            final int scrollX = v.getScrollX();
374            final int scrollY = v.getScrollY();
375            final int count = group.getChildCount();
376            for (int i = count - 1; i >= 0; i--) {
377                final View child = group.getChildAt(i);
378                if (x + scrollX >= child.getLeft() && x + scrollX < child.getRight() &&
379                        y + scrollY >= child.getTop() && y + scrollY < child.getBottom() &&
380                        canScroll(child, true, dx, x + scrollX - child.getLeft(),
381                                y + scrollY - child.getTop())) {
382                    return true;
383                }
384            }
385        }
386
387        return checkV && v.canScrollHorizontally((int) -dx);
388    }
389
390    public void setDismissable(boolean dismissable) {
391        if (!dismissable && mDismissable) {
392            cancel();
393            resetMembers();
394        }
395
396        mDismissable = dismissable;
397    }
398
399    private void checkGesture(MotionEvent ev) {
400        if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) {
401            mBlockGesture = mDismissAnimator.isAnimating();
402        }
403    }
404
405    private float progressToAlpha(float progress) {
406        return 1 - progress * progress * progress;
407    }
408
409    private class DismissAnimator implements AnimatorUpdateListener, Animator.AnimatorListener {
410        private final TimeInterpolator DISMISS_INTERPOLATOR = new DecelerateInterpolator(1.5f);
411        private final long DISMISS_DURATION = 250;
412
413        private final ValueAnimator mDismissAnimator = new ValueAnimator();
414        private boolean mWasCanceled = false;
415        private boolean mDismissOnComplete = false;
416
417        /* package */ DismissAnimator() {
418            mDismissAnimator.addUpdateListener(this);
419            mDismissAnimator.addListener(this);
420        }
421
422        /* package */ void animateDismissal(float currentTranslation) {
423            animate(
424                    currentTranslation / getWidth(),
425                    1,
426                    DISMISS_DURATION,
427                    DISMISS_INTERPOLATOR,
428                    true /* dismiss */);
429        }
430
431        /* package */ void animateRecovery(float currentTranslation) {
432            animate(
433                    currentTranslation / getWidth(),
434                    0,
435                    DISMISS_DURATION,
436                    DISMISS_INTERPOLATOR,
437                    false /* don't dismiss */);
438        }
439
440        /* package */ boolean isAnimating() {
441            return mDismissAnimator.isStarted();
442        }
443
444        private void animate(float from, float to, long duration, TimeInterpolator interpolator,
445                boolean dismissOnComplete) {
446            mDismissAnimator.cancel();
447            mDismissOnComplete = dismissOnComplete;
448            mDismissAnimator.setFloatValues(from, to);
449            mDismissAnimator.setDuration(duration);
450            mDismissAnimator.setInterpolator(interpolator);
451            mDismissAnimator.start();
452        }
453
454        @Override
455        public void onAnimationUpdate(ValueAnimator animation) {
456            float value = (Float) animation.getAnimatedValue();
457            setProgress(value * getWidth());
458        }
459
460        @Override
461        public void onAnimationStart(Animator animation) {
462            mWasCanceled = false;
463        }
464
465        @Override
466        public void onAnimationCancel(Animator animation) {
467            mWasCanceled = true;
468        }
469
470        @Override
471        public void onAnimationEnd(Animator animation) {
472            if (!mWasCanceled) {
473                if (mDismissOnComplete) {
474                    dismiss();
475                } else {
476                    cancel();
477                }
478            }
479        }
480
481        @Override
482        public void onAnimationRepeat(Animator animation) {
483        }
484    }
485}
486