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