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 
17 package com.android.internal.widget;
18 
19 import android.app.Activity;
20 import android.content.BroadcastReceiver;
21 import android.content.Context;
22 import android.content.Intent;
23 import android.content.IntentFilter;
24 import android.content.res.TypedArray;
25 import android.util.AttributeSet;
26 import android.util.Log;
27 import android.view.MotionEvent;
28 import android.view.VelocityTracker;
29 import android.view.View;
30 import android.view.ViewConfiguration;
31 import android.view.ViewGroup;
32 import android.view.ViewTreeObserver;
33 import android.widget.FrameLayout;
34 
35 /**
36  * Special layout that finishes its activity when swiped away.
37  */
38 public class SwipeDismissLayout extends FrameLayout {
39     private static final String TAG = "SwipeDismissLayout";
40 
41     private static final float DISMISS_MIN_DRAG_WIDTH_RATIO = .33f;
42     private boolean mUseDynamicTranslucency = true;
43 
44     public interface OnDismissedListener {
onDismissed(SwipeDismissLayout layout)45         void onDismissed(SwipeDismissLayout layout);
46     }
47 
48     public interface OnSwipeProgressChangedListener {
49         /**
50          * Called when the layout has been swiped and the position of the window should change.
51          *
52          * @param progress A number in [0, 1] representing how far to the
53          * right the window has been swiped
54          * @param translate A number in [0, w], where w is the width of the
55          * layout. This is equivalent to progress * layout.getWidth().
56          */
onSwipeProgressChanged(SwipeDismissLayout layout, float progress, float translate)57         void onSwipeProgressChanged(SwipeDismissLayout layout, float progress, float translate);
58 
onSwipeCancelled(SwipeDismissLayout layout)59         void onSwipeCancelled(SwipeDismissLayout layout);
60     }
61 
62     // Cached ViewConfiguration and system-wide constant values
63     private int mSlop;
64     private int mMinFlingVelocity;
65 
66     // Transient properties
67     private int mActiveTouchId;
68     private float mDownX;
69     private float mDownY;
70     private boolean mSwiping;
71     private boolean mDismissed;
72     private boolean mDiscardIntercept;
73     private VelocityTracker mVelocityTracker;
74     private float mTranslationX;
75 
76     private OnDismissedListener mDismissedListener;
77     private OnSwipeProgressChangedListener mProgressListener;
78     private ViewTreeObserver.OnEnterAnimationCompleteListener mOnEnterAnimationCompleteListener =
79             new ViewTreeObserver.OnEnterAnimationCompleteListener() {
80                 @Override
81                 public void onEnterAnimationComplete() {
82                     // SwipeDismissLayout assumes that the host Activity is translucent
83                     // and temporarily disables translucency when it is fully visible.
84                     // As soon as the user starts swiping, we will re-enable
85                     // translucency.
86                     if (mUseDynamicTranslucency && getContext() instanceof Activity) {
87                         ((Activity) getContext()).convertFromTranslucent();
88                     }
89                 }
90             };
91     private BroadcastReceiver mScreenOffReceiver = new BroadcastReceiver() {
92         @Override
93         public void onReceive(Context context, Intent intent) {
94             if (mDismissed) {
95                 dismiss();
96             } else {
97                 cancel();
98             }
99             resetMembers();
100         }
101     };
102     private IntentFilter mScreenOffFilter = new IntentFilter(Intent.ACTION_SCREEN_OFF);
103 
104     private float mLastX;
105 
SwipeDismissLayout(Context context)106     public SwipeDismissLayout(Context context) {
107         super(context);
108         init(context);
109     }
110 
SwipeDismissLayout(Context context, AttributeSet attrs)111     public SwipeDismissLayout(Context context, AttributeSet attrs) {
112         super(context, attrs);
113         init(context);
114     }
115 
SwipeDismissLayout(Context context, AttributeSet attrs, int defStyle)116     public SwipeDismissLayout(Context context, AttributeSet attrs, int defStyle) {
117         super(context, attrs, defStyle);
118         init(context);
119     }
120 
init(Context context)121     private void init(Context context) {
122         ViewConfiguration vc = ViewConfiguration.get(context);
123         mSlop = vc.getScaledTouchSlop();
124         mMinFlingVelocity = vc.getScaledMinimumFlingVelocity();
125         TypedArray a = context.getTheme().obtainStyledAttributes(
126                 com.android.internal.R.styleable.Theme);
127         mUseDynamicTranslucency = !a.hasValue(
128                 com.android.internal.R.styleable.Window_windowIsTranslucent);
129         a.recycle();
130     }
131 
setOnDismissedListener(OnDismissedListener listener)132     public void setOnDismissedListener(OnDismissedListener listener) {
133         mDismissedListener = listener;
134     }
135 
setOnSwipeProgressChangedListener(OnSwipeProgressChangedListener listener)136     public void setOnSwipeProgressChangedListener(OnSwipeProgressChangedListener listener) {
137         mProgressListener = listener;
138     }
139 
140     @Override
onAttachedToWindow()141     protected void onAttachedToWindow() {
142         super.onAttachedToWindow();
143         if (getContext() instanceof Activity) {
144             getViewTreeObserver().addOnEnterAnimationCompleteListener(
145                     mOnEnterAnimationCompleteListener);
146         }
147         getContext().registerReceiver(mScreenOffReceiver, mScreenOffFilter);
148     }
149 
150     @Override
onDetachedFromWindow()151     protected void onDetachedFromWindow() {
152         getContext().unregisterReceiver(mScreenOffReceiver);
153         if (getContext() instanceof Activity) {
154             getViewTreeObserver().removeOnEnterAnimationCompleteListener(
155                     mOnEnterAnimationCompleteListener);
156         }
157         super.onDetachedFromWindow();
158     }
159 
160     @Override
onInterceptTouchEvent(MotionEvent ev)161     public boolean onInterceptTouchEvent(MotionEvent ev) {
162         // offset because the view is translated during swipe
163         ev.offsetLocation(mTranslationX, 0);
164 
165         switch (ev.getActionMasked()) {
166             case MotionEvent.ACTION_DOWN:
167                 resetMembers();
168                 mDownX = ev.getRawX();
169                 mDownY = ev.getRawY();
170                 mActiveTouchId = ev.getPointerId(0);
171                 mVelocityTracker = VelocityTracker.obtain();
172                 mVelocityTracker.addMovement(ev);
173                 break;
174 
175             case MotionEvent.ACTION_POINTER_DOWN:
176                 int actionIndex = ev.getActionIndex();
177                 mActiveTouchId = ev.getPointerId(actionIndex);
178                 break;
179             case MotionEvent.ACTION_POINTER_UP:
180                 actionIndex = ev.getActionIndex();
181                 int pointerId = ev.getPointerId(actionIndex);
182                 if (pointerId == mActiveTouchId) {
183                     // This was our active pointer going up. Choose a new active pointer.
184                     int newActionIndex = actionIndex == 0 ? 1 : 0;
185                     mActiveTouchId = ev.getPointerId(newActionIndex);
186                 }
187                 break;
188 
189             case MotionEvent.ACTION_CANCEL:
190             case MotionEvent.ACTION_UP:
191                 resetMembers();
192                 break;
193 
194             case MotionEvent.ACTION_MOVE:
195                 if (mVelocityTracker == null || mDiscardIntercept) {
196                     break;
197                 }
198 
199                 int pointerIndex = ev.findPointerIndex(mActiveTouchId);
200                 if (pointerIndex == -1) {
201                     Log.e(TAG, "Invalid pointer index: ignoring.");
202                     mDiscardIntercept = true;
203                     break;
204                 }
205                 float dx = ev.getRawX() - mDownX;
206                 float x = ev.getX(pointerIndex);
207                 float y = ev.getY(pointerIndex);
208                 if (dx != 0 && canScroll(this, false, dx, x, y)) {
209                     mDiscardIntercept = true;
210                     break;
211                 }
212                 updateSwiping(ev);
213                 break;
214         }
215 
216         return !mDiscardIntercept && mSwiping;
217     }
218 
219     @Override
onTouchEvent(MotionEvent ev)220     public boolean onTouchEvent(MotionEvent ev) {
221         if (mVelocityTracker == null) {
222             return super.onTouchEvent(ev);
223         }
224         // offset because the view is translated during swipe
225         ev.offsetLocation(mTranslationX, 0);
226         switch (ev.getActionMasked()) {
227             case MotionEvent.ACTION_UP:
228                 updateDismiss(ev);
229                 if (mDismissed) {
230                     dismiss();
231                 } else if (mSwiping) {
232                     cancel();
233                 }
234                 resetMembers();
235                 break;
236 
237             case MotionEvent.ACTION_CANCEL:
238                 cancel();
239                 resetMembers();
240                 break;
241 
242             case MotionEvent.ACTION_MOVE:
243                 mVelocityTracker.addMovement(ev);
244                 mLastX = ev.getRawX();
245                 updateSwiping(ev);
246                 if (mSwiping) {
247                     if (mUseDynamicTranslucency && getContext() instanceof Activity) {
248                         ((Activity) getContext()).convertToTranslucent(null, null);
249                     }
250                     setProgress(ev.getRawX() - mDownX);
251                     break;
252                 }
253         }
254         return true;
255     }
256 
setProgress(float deltaX)257     private void setProgress(float deltaX) {
258         mTranslationX = deltaX;
259         if (mProgressListener != null && deltaX >= 0)  {
260             mProgressListener.onSwipeProgressChanged(this, deltaX / getWidth(), deltaX);
261         }
262     }
263 
dismiss()264     private void dismiss() {
265         if (mDismissedListener != null) {
266             mDismissedListener.onDismissed(this);
267         }
268     }
269 
cancel()270     protected void cancel() {
271         if (mUseDynamicTranslucency && getContext() instanceof Activity) {
272             ((Activity) getContext()).convertFromTranslucent();
273         }
274         if (mProgressListener != null) {
275             mProgressListener.onSwipeCancelled(this);
276         }
277     }
278 
279     /**
280      * Resets internal members when canceling.
281      */
resetMembers()282     private void resetMembers() {
283         if (mVelocityTracker != null) {
284             mVelocityTracker.recycle();
285         }
286         mVelocityTracker = null;
287         mTranslationX = 0;
288         mDownX = 0;
289         mDownY = 0;
290         mSwiping = false;
291         mDismissed = false;
292         mDiscardIntercept = false;
293     }
294 
updateSwiping(MotionEvent ev)295     private void updateSwiping(MotionEvent ev) {
296         if (!mSwiping) {
297             float deltaX = ev.getRawX() - mDownX;
298             float deltaY = ev.getRawY() - mDownY;
299             if ((deltaX * deltaX) + (deltaY * deltaY) > mSlop * mSlop) {
300                 mSwiping = deltaX > mSlop * 2 && Math.abs(deltaY) < Math.abs(deltaX);
301             } else {
302                 mSwiping = false;
303             }
304         }
305     }
306 
307     private void updateDismiss(MotionEvent ev) {
308         float deltaX = ev.getRawX() - mDownX;
309         mVelocityTracker.addMovement(ev);
310         mVelocityTracker.computeCurrentVelocity(1000);
311         if (!mDismissed) {
312 
313             if (deltaX > (getWidth() * DISMISS_MIN_DRAG_WIDTH_RATIO) &&
314                     ev.getRawX() >= mLastX) {
315                 mDismissed = true;
316             }
317         }
318         // Check if the user tried to undo this.
319         if (mDismissed && mSwiping) {
320             // Check if the user's finger is actually back
321             if (deltaX < (getWidth() * DISMISS_MIN_DRAG_WIDTH_RATIO) ||
322                     // or user is flinging back left
323                     mVelocityTracker.getXVelocity() < -mMinFlingVelocity) {
324                 mDismissed = false;
325             }
326         }
327     }
328 
329     /**
330      * Tests scrollability within child views of v in the direction of dx.
331      *
332      * @param v View to test for horizontal scrollability
333      * @param checkV Whether the view v passed should itself be checked for scrollability (true),
334      *               or just its children (false).
335      * @param dx Delta scrolled in pixels. Only the sign of this is used.
336      * @param x X coordinate of the active touch point
337      * @param y Y coordinate of the active touch point
338      * @return true if child views of v can be scrolled by delta of dx.
339      */
340     protected boolean canScroll(View v, boolean checkV, float dx, float x, float y) {
341         if (v instanceof ViewGroup) {
342             final ViewGroup group = (ViewGroup) v;
343             final int scrollX = v.getScrollX();
344             final int scrollY = v.getScrollY();
345             final int count = group.getChildCount();
346             for (int i = count - 1; i >= 0; i--) {
347                 final View child = group.getChildAt(i);
348                 if (x + scrollX >= child.getLeft() && x + scrollX < child.getRight() &&
349                         y + scrollY >= child.getTop() && y + scrollY < child.getBottom() &&
350                         canScroll(child, true, dx, x + scrollX - child.getLeft(),
351                                 y + scrollY - child.getTop())) {
352                     return true;
353                 }
354             }
355         }
356 
357         return checkV && v.canScrollHorizontally((int) -dx);
358     }
359 }
360