Home | History | Annotate | Download | only in widget
      1 /*
      2  * Copyright (C) 2017 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 androidx.wear.widget;
     18 
     19 import android.content.Context;
     20 import android.content.res.Resources;
     21 import android.util.AttributeSet;
     22 import android.util.Log;
     23 import android.view.MotionEvent;
     24 import android.view.VelocityTracker;
     25 import android.view.View;
     26 import android.view.ViewConfiguration;
     27 import android.view.ViewGroup;
     28 import android.widget.FrameLayout;
     29 
     30 import androidx.annotation.Nullable;
     31 import androidx.annotation.RestrictTo;
     32 import androidx.annotation.RestrictTo.Scope;
     33 import androidx.annotation.UiThread;
     34 
     35 /**
     36  * Special layout that finishes its activity when swiped away.
     37  *
     38  * <p>This is a modified copy of the internal framework class
     39  * com.android.internal.widget.SwipeDismissLayout.
     40  *
     41  * @hide
     42  */
     43 @RestrictTo(Scope.LIBRARY)
     44 @UiThread
     45 class SwipeDismissLayout extends FrameLayout {
     46     private static final String TAG = "SwipeDismissLayout";
     47 
     48     public static final float DEFAULT_DISMISS_DRAG_WIDTH_RATIO = .33f;
     49     // A value between 0.0 and 1.0 determining the percentage of the screen on the left-hand-side
     50     // where edge swipe gestures are permitted to begin.
     51     private static final float EDGE_SWIPE_THRESHOLD = 0.1f;
     52 
     53     /** Called when the layout is about to consider a swipe. */
     54     @UiThread
     55     interface OnPreSwipeListener {
     56         /**
     57          * Notifies listeners that the view is now considering to start a dismiss gesture from a
     58          * particular point on the screen. The default implementation returns true for all
     59          * coordinates so that is is possible to start a swipe-to-dismiss gesture from any location.
     60          * If any one instance of this Callback returns false for a given set of coordinates,
     61          * swipe-to-dismiss will not be allowed to start in that point.
     62          *
     63          * @param xDown the x coordinate of the initial {@link android.view.MotionEvent#ACTION_DOWN}
     64          *              event for this motion
     65          * @param yDown the y coordinate of the initial {@link android.view.MotionEvent#ACTION_DOWN}
     66          *              event for this motion
     67          * @return {@code true} if these coordinates should be considered as a start of a swipe
     68          * gesture, {@code false} otherwise
     69          */
     70         boolean onPreSwipe(SwipeDismissLayout swipeDismissLayout, float xDown, float yDown);
     71     }
     72 
     73     /**
     74      * Interface enabling listeners to react to when the swipe gesture is done and the view should
     75      * probably be dismissed from the UI.
     76      */
     77     @UiThread
     78     interface OnDismissedListener {
     79         void onDismissed(SwipeDismissLayout layout);
     80     }
     81 
     82     /**
     83      * Interface enabling listeners to react to changes in the progress of the swipe-to-dismiss
     84      * gesture.
     85      */
     86     @UiThread
     87     interface OnSwipeProgressChangedListener {
     88         /**
     89          * Called when the layout has been swiped and the position of the window should change.
     90          *
     91          * @param layout    the layout associated with this listener.
     92          * @param progress  a number in [0, 1] representing how far to the right the window has
     93          *                  been swiped
     94          * @param translate a number in [0, w], where w is the width of the layout. This is
     95          *                  equivalent to progress * layout.getWidth()
     96          */
     97         void onSwipeProgressChanged(SwipeDismissLayout layout, float progress, float translate);
     98 
     99         /**
    100          * Called when the layout started to be swiped away but then the gesture was canceled.
    101          *
    102          * @param layout    the layout associated with this listener
    103          */
    104         void onSwipeCanceled(SwipeDismissLayout layout);
    105     }
    106 
    107     // Cached ViewConfiguration and system-wide constant values
    108     private int mSlop;
    109     private int mMinFlingVelocity;
    110     private float mGestureThresholdPx;
    111 
    112     // Transient properties
    113     private int mActiveTouchId;
    114     private float mDownX;
    115     private float mDownY;
    116     private boolean mSwipeable;
    117     private boolean mSwiping;
    118     // This variable holds information about whether the initial move of a longer swipe
    119     // (consisting of multiple move events) has conformed to the definition of a horizontal
    120     // swipe-to-dismiss. A swipe gesture is only ever allowed to be recognized if this variable is
    121     // set to true. Otherwise, the motion events will be allowed to propagate to the children.
    122     private boolean mCanStartSwipe = true;
    123     private boolean mDismissed;
    124     private boolean mDiscardIntercept;
    125     private VelocityTracker mVelocityTracker;
    126     private float mTranslationX;
    127     private boolean mDisallowIntercept;
    128 
    129     @Nullable
    130     private OnPreSwipeListener mOnPreSwipeListener;
    131     private OnDismissedListener mDismissedListener;
    132     private OnSwipeProgressChangedListener mProgressListener;
    133 
    134     private float mLastX;
    135     private float mDismissMinDragWidthRatio = DEFAULT_DISMISS_DRAG_WIDTH_RATIO;
    136 
    137     SwipeDismissLayout(Context context) {
    138         this(context, null);
    139     }
    140 
    141     SwipeDismissLayout(Context context, AttributeSet attrs) {
    142         this(context, attrs, 0);
    143     }
    144 
    145     SwipeDismissLayout(Context context, AttributeSet attrs, int defStyle) {
    146         this(context, attrs, defStyle, 0);
    147     }
    148 
    149     SwipeDismissLayout(Context context, AttributeSet attrs, int defStyle, int defStyleRes) {
    150         super(context, attrs, defStyle, defStyleRes);
    151         ViewConfiguration vc = ViewConfiguration.get(context);
    152         mSlop = vc.getScaledTouchSlop();
    153         mMinFlingVelocity = vc.getScaledMinimumFlingVelocity();
    154         mGestureThresholdPx =
    155                 Resources.getSystem().getDisplayMetrics().widthPixels * EDGE_SWIPE_THRESHOLD;
    156 
    157         // By default, the view is swipeable.
    158         setSwipeable(true);
    159     }
    160 
    161     /**
    162      * Sets the minimum ratio of the screen after which the swipe gesture is treated as swipe-to-
    163      * dismiss.
    164      *
    165      * @param ratio  the ratio of the screen at which the swipe gesture is treated as
    166      *               swipe-to-dismiss. should be provided as a fraction of the screen
    167      */
    168     public void setDismissMinDragWidthRatio(float ratio) {
    169         mDismissMinDragWidthRatio = ratio;
    170     }
    171 
    172     /**
    173      * Returns the current ratio of te screen at which the swipe gesture is treated as
    174      * swipe-to-dismiss.
    175      *
    176      * @return the current ratio of te screen at which the swipe gesture is treated as
    177      * swipe-to-dismiss
    178      */
    179     public float getDismissMinDragWidthRatio() {
    180         return mDismissMinDragWidthRatio;
    181     }
    182 
    183     /**
    184      * Sets the layout to swipeable or not. This effectively turns the functionality of this layout
    185      * on or off.
    186      *
    187      * @param swipeable whether the layout should react to the swipe gesture
    188      */
    189     public void setSwipeable(boolean swipeable) {
    190         mSwipeable = swipeable;
    191     }
    192 
    193     /** Returns true if the layout reacts to swipe gestures. */
    194     public boolean isSwipeable() {
    195         return mSwipeable;
    196     }
    197 
    198     void setOnPreSwipeListener(@Nullable OnPreSwipeListener listener) {
    199         mOnPreSwipeListener = listener;
    200     }
    201 
    202     void setOnDismissedListener(@Nullable OnDismissedListener listener) {
    203         mDismissedListener = listener;
    204     }
    205 
    206     void setOnSwipeProgressChangedListener(@Nullable OnSwipeProgressChangedListener listener) {
    207         mProgressListener = listener;
    208     }
    209 
    210     @Override
    211     public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
    212         mDisallowIntercept = disallowIntercept;
    213         if (getParent() != null) {
    214             getParent().requestDisallowInterceptTouchEvent(disallowIntercept);
    215         }
    216     }
    217 
    218     @Override
    219     public boolean onInterceptTouchEvent(MotionEvent ev) {
    220         if (!mSwipeable) {
    221             return super.onInterceptTouchEvent(ev);
    222         }
    223 
    224         // offset because the view is translated during swipe
    225         ev.offsetLocation(mTranslationX, 0);
    226 
    227         switch (ev.getActionMasked()) {
    228             case MotionEvent.ACTION_DOWN:
    229                 resetMembers();
    230                 mDownX = ev.getRawX();
    231                 mDownY = ev.getRawY();
    232                 mActiveTouchId = ev.getPointerId(0);
    233                 mVelocityTracker = VelocityTracker.obtain();
    234                 mVelocityTracker.addMovement(ev);
    235                 break;
    236 
    237             case MotionEvent.ACTION_POINTER_DOWN:
    238                 int actionIndex = ev.getActionIndex();
    239                 mActiveTouchId = ev.getPointerId(actionIndex);
    240                 break;
    241             case MotionEvent.ACTION_POINTER_UP:
    242                 actionIndex = ev.getActionIndex();
    243                 int pointerId = ev.getPointerId(actionIndex);
    244                 if (pointerId == mActiveTouchId) {
    245                     // This was our active pointer going up. Choose a new active pointer.
    246                     int newActionIndex = actionIndex == 0 ? 1 : 0;
    247                     mActiveTouchId = ev.getPointerId(newActionIndex);
    248                 }
    249                 break;
    250 
    251             case MotionEvent.ACTION_CANCEL:
    252             case MotionEvent.ACTION_UP:
    253                 resetMembers();
    254                 break;
    255 
    256             case MotionEvent.ACTION_MOVE:
    257                 if (mVelocityTracker == null || mDiscardIntercept) {
    258                     break;
    259                 }
    260 
    261                 int pointerIndex = ev.findPointerIndex(mActiveTouchId);
    262                 if (pointerIndex == -1) {
    263                     Log.e(TAG, "Invalid pointer index: ignoring.");
    264                     mDiscardIntercept = true;
    265                     break;
    266                 }
    267                 float dx = ev.getRawX() - mDownX;
    268                 float x = ev.getX(pointerIndex);
    269                 float y = ev.getY(pointerIndex);
    270 
    271                 if (dx != 0 && mDownX >= mGestureThresholdPx && canScroll(this, false, dx, x, y)) {
    272                     mDiscardIntercept = true;
    273                     break;
    274                 }
    275                 updateSwiping(ev);
    276                 break;
    277         }
    278 
    279         if ((mOnPreSwipeListener == null && !mDisallowIntercept)
    280                 || mOnPreSwipeListener.onPreSwipe(this, mDownX, mDownY)) {
    281             return (!mDiscardIntercept && mSwiping);
    282         }
    283         return false;
    284     }
    285 
    286     @Override
    287     public boolean canScrollHorizontally(int direction) {
    288         // This view can only be swiped horizontally from left to right - this means a negative
    289         // SCROLLING direction. We return false if the view is not visible to avoid capturing swipe
    290         // gestures when the view is hidden.
    291         return direction < 0 && isSwipeable() && getVisibility() == View.VISIBLE;
    292     }
    293 
    294     /**
    295      * Helper function determining if a particular move gesture was verbose enough to qualify as a
    296      * beginning of a swipe.
    297      *
    298      * @param dx distance traveled in the x direction, from the initial touch down
    299      * @param dy distance traveled in the y direction, from the initial touch down
    300      * @return {@code true} if the gesture was long enough to be considered a potential swipe
    301      */
    302     private boolean isPotentialSwipe(float dx, float dy) {
    303         return (dx * dx) + (dy * dy) > mSlop * mSlop;
    304     }
    305 
    306     @Override
    307     public boolean onTouchEvent(MotionEvent ev) {
    308         if (!mSwipeable) {
    309             return super.onTouchEvent(ev);
    310         }
    311 
    312         if (mVelocityTracker == null) {
    313             return super.onTouchEvent(ev);
    314         }
    315 
    316         if (mOnPreSwipeListener != null && !mOnPreSwipeListener.onPreSwipe(this, mDownX, mDownY)) {
    317             return super.onTouchEvent(ev);
    318         }
    319 
    320         // offset because the view is translated during swipe
    321         ev.offsetLocation(mTranslationX, 0);
    322         switch (ev.getActionMasked()) {
    323             case MotionEvent.ACTION_UP:
    324                 updateDismiss(ev);
    325                 if (mDismissed) {
    326                     dismiss();
    327                 } else if (mSwiping) {
    328                     cancel();
    329                 }
    330                 resetMembers();
    331                 break;
    332 
    333             case MotionEvent.ACTION_CANCEL:
    334                 cancel();
    335                 resetMembers();
    336                 break;
    337 
    338             case MotionEvent.ACTION_MOVE:
    339                 mVelocityTracker.addMovement(ev);
    340                 mLastX = ev.getRawX();
    341                 updateSwiping(ev);
    342                 if (mSwiping) {
    343                     setProgress(ev.getRawX() - mDownX);
    344                     break;
    345                 }
    346         }
    347         return true;
    348     }
    349 
    350     private void setProgress(float deltaX) {
    351         mTranslationX = deltaX;
    352         if (mProgressListener != null && deltaX >= 0) {
    353             mProgressListener.onSwipeProgressChanged(this, deltaX / getWidth(), deltaX);
    354         }
    355     }
    356 
    357     private void dismiss() {
    358         if (mDismissedListener != null) {
    359             mDismissedListener.onDismissed(this);
    360         }
    361     }
    362 
    363     private void cancel() {
    364         if (mProgressListener != null) {
    365             mProgressListener.onSwipeCanceled(this);
    366         }
    367     }
    368 
    369     /** Resets internal members when canceling or finishing a given gesture. */
    370     private void resetMembers() {
    371         if (mVelocityTracker != null) {
    372             mVelocityTracker.recycle();
    373         }
    374         mVelocityTracker = null;
    375         mTranslationX = 0;
    376         mDownX = 0;
    377         mDownY = 0;
    378         mSwiping = false;
    379         mDismissed = false;
    380         mDiscardIntercept = false;
    381         mCanStartSwipe = true;
    382         mDisallowIntercept = false;
    383     }
    384 
    385     private void updateSwiping(MotionEvent ev) {
    386         if (!mSwiping) {
    387             float deltaX = ev.getRawX() - mDownX;
    388             float deltaY = ev.getRawY() - mDownY;
    389             if (isPotentialSwipe(deltaX, deltaY)) {
    390                 // There are three conditions on which we want want to start swiping:
    391                 // 1. The swipe is from left to right AND
    392                 // 2. It is horizontal AND
    393                 // 3. We actually can start swiping
    394                 mSwiping = mCanStartSwipe && Math.abs(deltaY) < Math.abs(deltaX) && deltaX > 0;
    395                 mCanStartSwipe = mSwiping;
    396             }
    397         }
    398     }
    399 
    400     private void updateDismiss(MotionEvent ev) {
    401         float deltaX = ev.getRawX() - mDownX;
    402         mVelocityTracker.addMovement(ev);
    403         mVelocityTracker.computeCurrentVelocity(1000);
    404         if (!mDismissed) {
    405             if ((deltaX > (getWidth() * mDismissMinDragWidthRatio) && ev.getRawX() >= mLastX)
    406                     || mVelocityTracker.getXVelocity() >= mMinFlingVelocity) {
    407                 mDismissed = true;
    408             }
    409         }
    410         // Check if the user tried to undo this.
    411         if (mDismissed && mSwiping) {
    412             // Check if the user's finger is actually flinging back to left
    413             if (mVelocityTracker.getXVelocity() < -mMinFlingVelocity) {
    414                 mDismissed = false;
    415             }
    416         }
    417     }
    418 
    419     /**
    420      * Tests scrollability within child views of v in the direction of dx.
    421      *
    422      * @param v      view to test for horizontal scrollability
    423      * @param checkV whether the view v passed should itself be checked for scrollability
    424      *               ({@code true}), or just its children ({@code false})
    425      * @param dx     delta scrolled in pixels. Only the sign of this is used
    426      * @param x      x coordinate of the active touch point
    427      * @param y      y coordinate of the active touch point
    428      * @return {@code true} if child views of v can be scrolled by delta of dx
    429      */
    430     protected boolean canScroll(View v, boolean checkV, float dx, float x, float y) {
    431         if (v instanceof ViewGroup) {
    432             final ViewGroup group = (ViewGroup) v;
    433             final int scrollX = v.getScrollX();
    434             final int scrollY = v.getScrollY();
    435             final int count = group.getChildCount();
    436             for (int i = count - 1; i >= 0; i--) {
    437                 final View child = group.getChildAt(i);
    438                 if (x + scrollX >= child.getLeft()
    439                         && x + scrollX < child.getRight()
    440                         && y + scrollY >= child.getTop()
    441                         && y + scrollY < child.getBottom()
    442                         && canScroll(
    443                         child, true, dx, x + scrollX - child.getLeft(),
    444                         y + scrollY - child.getTop())) {
    445                     return true;
    446                 }
    447             }
    448         }
    449 
    450         return checkV && v.canScrollHorizontally((int) -dx);
    451     }
    452 }
    453