SwipeDismissLayout.java revision 8a9b27773b2bd6f84a861bb0d38dcfebb3b8cfa7
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 DISMISS_MIN_DRAG_WIDTH_RATIO = .33f;
47
48    public interface OnDismissedListener {
49        void onDismissed(SwipeDismissLayout layout);
50    }
51
52    public interface OnSwipeProgressChangedListener {
53        /**
54         * Called when the layout has been swiped and the position of the window should change.
55         *
56         * @param alpha A number in [0, 1] representing what the alpha transparency of the window
57         * should be.
58         * @param translate A number in [0, w], where w is the width of the
59         * layout. This is equivalent to progress * layout.getWidth().
60         */
61        void onSwipeProgressChanged(SwipeDismissLayout layout, float alpha, float translate);
62
63        void onSwipeCancelled(SwipeDismissLayout layout);
64    }
65
66    private boolean mIsWindowNativelyTranslucent;
67
68    // Cached ViewConfiguration and system-wide constant values
69    private int mSlop;
70    private int mMinFlingVelocity;
71
72    // Transient properties
73    private int mActiveTouchId;
74    private float mDownX;
75    private float mDownY;
76    private boolean mSwiping;
77    private boolean mDismissed;
78    private boolean mDiscardIntercept;
79    private VelocityTracker mVelocityTracker;
80    private float mTranslationX;
81    private boolean mBlockGesture = false;
82
83    private final DismissAnimator mDismissAnimator = new DismissAnimator();
84
85    private OnDismissedListener mDismissedListener;
86    private OnSwipeProgressChangedListener mProgressListener;
87    private BroadcastReceiver mScreenOffReceiver = new BroadcastReceiver() {
88        private Runnable mRunnable = new Runnable() {
89            @Override
90            public void run() {
91                if (mDismissed) {
92                    dismiss();
93                } else {
94                    cancel();
95                }
96                resetMembers();
97            }
98        };
99
100        @Override
101        public void onReceive(Context context, Intent intent) {
102            post(mRunnable);
103        }
104    };
105    private IntentFilter mScreenOffFilter = new IntentFilter(Intent.ACTION_SCREEN_OFF);
106
107    private float mLastX;
108
109    private boolean mDismissable = true;
110
111    public SwipeDismissLayout(Context context) {
112        super(context);
113        init(context);
114    }
115
116    public SwipeDismissLayout(Context context, AttributeSet attrs) {
117        super(context, attrs);
118        init(context);
119    }
120
121    public SwipeDismissLayout(Context context, AttributeSet attrs, int defStyle) {
122        super(context, attrs, defStyle);
123        init(context);
124    }
125
126    private void init(Context context) {
127        ViewConfiguration vc = ViewConfiguration.get(context);
128        mSlop = vc.getScaledTouchSlop();
129        mMinFlingVelocity = vc.getScaledMinimumFlingVelocity();
130        TypedArray a = context.getTheme().obtainStyledAttributes(
131                com.android.internal.R.styleable.Theme);
132        mIsWindowNativelyTranslucent = a.getBoolean(
133                com.android.internal.R.styleable.Window_windowIsTranslucent, false);
134        a.recycle();
135    }
136
137    public void setOnDismissedListener(OnDismissedListener listener) {
138        mDismissedListener = listener;
139    }
140
141    public void setOnSwipeProgressChangedListener(OnSwipeProgressChangedListener listener) {
142        mProgressListener = listener;
143    }
144
145    @Override
146    protected void onAttachedToWindow() {
147        super.onAttachedToWindow();
148        getContext().registerReceiver(mScreenOffReceiver, mScreenOffFilter);
149    }
150
151    @Override
152    protected void onDetachedFromWindow() {
153        getContext().unregisterReceiver(mScreenOffReceiver);
154        super.onDetachedFromWindow();
155    }
156
157    @Override
158    public boolean onInterceptTouchEvent(MotionEvent ev) {
159        checkGesture((ev));
160        if (mBlockGesture) {
161            return true;
162        }
163        if (!mDismissable) {
164            return super.onInterceptTouchEvent(ev);
165        }
166
167        // offset because the view is translated during swipe
168        ev.offsetLocation(mTranslationX, 0);
169
170        switch (ev.getActionMasked()) {
171            case MotionEvent.ACTION_DOWN:
172                resetMembers();
173                mDownX = ev.getRawX();
174                mDownY = ev.getRawY();
175                mActiveTouchId = ev.getPointerId(0);
176                mVelocityTracker = VelocityTracker.obtain();
177                mVelocityTracker.addMovement(ev);
178                break;
179
180            case MotionEvent.ACTION_POINTER_DOWN:
181                int actionIndex = ev.getActionIndex();
182                mActiveTouchId = ev.getPointerId(actionIndex);
183                break;
184            case MotionEvent.ACTION_POINTER_UP:
185                actionIndex = ev.getActionIndex();
186                int pointerId = ev.getPointerId(actionIndex);
187                if (pointerId == mActiveTouchId) {
188                    // This was our active pointer going up. Choose a new active pointer.
189                    int newActionIndex = actionIndex == 0 ? 1 : 0;
190                    mActiveTouchId = ev.getPointerId(newActionIndex);
191                }
192                break;
193
194            case MotionEvent.ACTION_CANCEL:
195            case MotionEvent.ACTION_UP:
196                resetMembers();
197                break;
198
199            case MotionEvent.ACTION_MOVE:
200                if (mVelocityTracker == null || mDiscardIntercept) {
201                    break;
202                }
203
204                int pointerIndex = ev.findPointerIndex(mActiveTouchId);
205                if (pointerIndex == -1) {
206                    Log.e(TAG, "Invalid pointer index: ignoring.");
207                    mDiscardIntercept = true;
208                    break;
209                }
210                float dx = ev.getRawX() - mDownX;
211                float x = ev.getX(pointerIndex);
212                float y = ev.getY(pointerIndex);
213                if (dx != 0 && canScroll(this, false, dx, x, y)) {
214                    mDiscardIntercept = true;
215                    break;
216                }
217                updateSwiping(ev);
218                break;
219        }
220
221        return !mDiscardIntercept && mSwiping;
222    }
223
224    @Override
225    public boolean onTouchEvent(MotionEvent ev) {
226        checkGesture((ev));
227        if (mBlockGesture) {
228            return true;
229        }
230        if (mVelocityTracker == null || !mDismissable) {
231            return super.onTouchEvent(ev);
232        }
233        // offset because the view is translated during swipe
234        ev.offsetLocation(mTranslationX, 0);
235        switch (ev.getActionMasked()) {
236            case MotionEvent.ACTION_UP:
237                updateDismiss(ev);
238                if (mDismissed) {
239                    mDismissAnimator.animateDismissal(ev.getRawX() - mDownX);
240                } else if (mSwiping) {
241                    mDismissAnimator.animateRecovery(ev.getRawX() - mDownX);
242                }
243                resetMembers();
244                break;
245
246            case MotionEvent.ACTION_CANCEL:
247                cancel();
248                resetMembers();
249                break;
250
251            case MotionEvent.ACTION_MOVE:
252                mVelocityTracker.addMovement(ev);
253                mLastX = ev.getRawX();
254                updateSwiping(ev);
255                if (mSwiping) {
256                    setProgress(ev.getRawX() - mDownX);
257                    break;
258                }
259        }
260        return true;
261    }
262
263    private void setProgress(float deltaX) {
264        mTranslationX = deltaX;
265        if (mProgressListener != null && deltaX >= 0)  {
266            mProgressListener.onSwipeProgressChanged(
267                    this, progressToAlpha(deltaX / getWidth()), deltaX);
268        }
269    }
270
271    private void dismiss() {
272        if (mDismissedListener != null) {
273            mDismissedListener.onDismissed(this);
274        }
275    }
276
277    protected void cancel() {
278        if (!mIsWindowNativelyTranslucent) {
279            Activity activity = findActivity();
280            if (activity != null) {
281                activity.convertFromTranslucent();
282            }
283        }
284        if (mProgressListener != null) {
285            mProgressListener.onSwipeCancelled(this);
286        }
287    }
288
289    /**
290     * Resets internal members when canceling.
291     */
292    private void resetMembers() {
293        if (mVelocityTracker != null) {
294            mVelocityTracker.recycle();
295        }
296        mVelocityTracker = null;
297        mTranslationX = 0;
298        mDownX = 0;
299        mDownY = 0;
300        mSwiping = false;
301        mDismissed = false;
302        mDiscardIntercept = false;
303    }
304
305    private void updateSwiping(MotionEvent ev) {
306        boolean oldSwiping = mSwiping;
307        if (!mSwiping) {
308            float deltaX = ev.getRawX() - mDownX;
309            float deltaY = ev.getRawY() - mDownY;
310            if ((deltaX * deltaX) + (deltaY * deltaY) > mSlop * mSlop) {
311                mSwiping = deltaX > mSlop * 2 && Math.abs(deltaY) < Math.abs(deltaX);
312            } else {
313                mSwiping = false;
314            }
315        }
316
317        if (mSwiping && !oldSwiping) {
318            // Swiping has started
319            if (!mIsWindowNativelyTranslucent) {
320                Activity activity = findActivity();
321                if (activity != null) {
322                    activity.convertToTranslucent(null, null);
323                }
324            }
325        }
326    }
327
328    private void updateDismiss(MotionEvent ev) {
329        float deltaX = ev.getRawX() - mDownX;
330        mVelocityTracker.addMovement(ev);
331        mVelocityTracker.computeCurrentVelocity(1000);
332        if (!mDismissed) {
333            if ((deltaX > (getWidth() * DISMISS_MIN_DRAG_WIDTH_RATIO) &&
334                    ev.getRawX() >= mLastX)
335                    || mVelocityTracker.getXVelocity() >= mMinFlingVelocity) {
336                mDismissed = true;
337            }
338        }
339        // Check if the user tried to undo this.
340        if (mDismissed && mSwiping) {
341            // Check if the user's finger is actually flinging back to left
342            if (mVelocityTracker.getXVelocity() < -mMinFlingVelocity) {
343                mDismissed = false;
344            }
345        }
346    }
347
348    /**
349     * Tests scrollability within child views of v in the direction of dx.
350     *
351     * @param v View to test for horizontal scrollability
352     * @param checkV Whether the view v passed should itself be checked for scrollability (true),
353     *               or just its children (false).
354     * @param dx Delta scrolled in pixels. Only the sign of this is used.
355     * @param x X coordinate of the active touch point
356     * @param y Y coordinate of the active touch point
357     * @return true if child views of v can be scrolled by delta of dx.
358     */
359    protected boolean canScroll(View v, boolean checkV, float dx, float x, float y) {
360        if (v instanceof ViewGroup) {
361            final ViewGroup group = (ViewGroup) v;
362            final int scrollX = v.getScrollX();
363            final int scrollY = v.getScrollY();
364            final int count = group.getChildCount();
365            for (int i = count - 1; i >= 0; i--) {
366                final View child = group.getChildAt(i);
367                if (x + scrollX >= child.getLeft() && x + scrollX < child.getRight() &&
368                        y + scrollY >= child.getTop() && y + scrollY < child.getBottom() &&
369                        canScroll(child, true, dx, x + scrollX - child.getLeft(),
370                                y + scrollY - child.getTop())) {
371                    return true;
372                }
373            }
374        }
375
376        return checkV && v.canScrollHorizontally((int) -dx);
377    }
378
379    public void setDismissable(boolean dismissable) {
380        if (!dismissable && mDismissable) {
381            cancel();
382            resetMembers();
383        }
384
385        mDismissable = dismissable;
386    }
387
388    private void checkGesture(MotionEvent ev) {
389        if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) {
390            mBlockGesture = mDismissAnimator.isAnimating();
391        }
392    }
393
394    private float progressToAlpha(float progress) {
395        return 1 - progress * progress * progress;
396    }
397
398    private Activity findActivity() {
399        Context context = getContext();
400        while (context instanceof ContextWrapper) {
401            if (context instanceof Activity) {
402                return (Activity) context;
403            }
404            context = ((ContextWrapper) context).getBaseContext();
405        }
406        return null;
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