Home | History | Annotate | Download | only in widget
      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 {
     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          */
     63         void onSwipeProgressChanged(SwipeDismissLayout layout, float alpha, float translate);
     64 
     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 
     96     public SwipeDismissLayout(Context context) {
     97         super(context);
     98         init(context);
     99     }
    100 
    101     public SwipeDismissLayout(Context context, AttributeSet attrs) {
    102         super(context, attrs);
    103         init(context);
    104     }
    105 
    106     public SwipeDismissLayout(Context context, AttributeSet attrs, int defStyle) {
    107         super(context, attrs, defStyle);
    108         init(context);
    109     }
    110 
    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 
    122     public void setOnDismissedListener(OnDismissedListener listener) {
    123         mDismissedListener = listener;
    124     }
    125 
    126     public void setOnSwipeProgressChangedListener(OnSwipeProgressChangedListener listener) {
    127         mProgressListener = listener;
    128     }
    129 
    130     @Override
    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
    158     protected void onDetachedFromWindow() {
    159         if (mScreenOffReceiver != null) {
    160             getContext().unregisterReceiver(mScreenOffReceiver);
    161             mScreenOffReceiver = null;
    162         }
    163         super.onDetachedFromWindow();
    164     }
    165 
    166     @Override
    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
    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 
    281     private void setProgress(float deltaX) {
    282         if (mProgressListener != null && deltaX >= 0)  {
    283             mProgressListener.onSwipeProgressChanged(
    284                     this, progressToAlpha(deltaX / getWidth()), deltaX);
    285         }
    286     }
    287 
    288     private void dismiss() {
    289         if (mDismissedListener != null) {
    290             mDismissedListener.onDismissed(this);
    291         }
    292     }
    293 
    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      */
    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 
    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 
    448         /* package */ DismissAnimator() {
    449             mDismissAnimator.addUpdateListener(this);
    450             mDismissAnimator.addListener(this);
    451         }
    452 
    453         /* package */ void animateDismissal(float currentTranslation) {
    454             animate(
    455                     currentTranslation / getWidth(),
    456                     1,
    457                     DISMISS_DURATION,
    458                     DISMISS_INTERPOLATOR,
    459                     true /* dismiss */);
    460         }
    461 
    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 
    471         /* package */ boolean isAnimating() {
    472             return mDismissAnimator.isStarted();
    473         }
    474 
    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
    486         public void onAnimationUpdate(ValueAnimator animation) {
    487             float value = (Float) animation.getAnimatedValue();
    488             setProgress(value * getWidth());
    489         }
    490 
    491         @Override
    492         public void onAnimationStart(Animator animation) {
    493             mWasCanceled = false;
    494         }
    495 
    496         @Override
    497         public void onAnimationCancel(Animator animation) {
    498             mWasCanceled = true;
    499         }
    500 
    501         @Override
    502         public void onAnimationEnd(Animator animation) {
    503             if (!mWasCanceled) {
    504                 if (mDismissOnComplete) {
    505                     dismiss();
    506                 } else {
    507                     cancel();
    508                 }
    509             }
    510         }
    511 
    512         @Override
    513         public void onAnimationRepeat(Animator animation) {
    514         }
    515     }
    516 }
    517