| /* |
| * Copyright (C) 2017 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package androidx.wear.widget; |
| |
| import android.content.Context; |
| import android.content.res.Resources; |
| import android.util.AttributeSet; |
| import android.util.Log; |
| import android.view.MotionEvent; |
| import android.view.VelocityTracker; |
| import android.view.View; |
| import android.view.ViewConfiguration; |
| import android.view.ViewGroup; |
| import android.widget.FrameLayout; |
| |
| import androidx.annotation.Nullable; |
| import androidx.annotation.RestrictTo; |
| import androidx.annotation.RestrictTo.Scope; |
| import androidx.annotation.UiThread; |
| |
| /** |
| * Special layout that finishes its activity when swiped away. |
| * |
| * <p>This is a modified copy of the internal framework class |
| * com.android.internal.widget.SwipeDismissLayout. |
| * |
| * @hide |
| */ |
| @RestrictTo(Scope.LIBRARY) |
| @UiThread |
| class SwipeDismissLayout extends FrameLayout { |
| private static final String TAG = "SwipeDismissLayout"; |
| |
| public static final float DEFAULT_DISMISS_DRAG_WIDTH_RATIO = .33f; |
| // A value between 0.0 and 1.0 determining the percentage of the screen on the left-hand-side |
| // where edge swipe gestures are permitted to begin. |
| private static final float EDGE_SWIPE_THRESHOLD = 0.1f; |
| |
| /** Called when the layout is about to consider a swipe. */ |
| @UiThread |
| interface OnPreSwipeListener { |
| /** |
| * Notifies listeners that the view is now considering to start a dismiss gesture from a |
| * particular point on the screen. The default implementation returns true for all |
| * coordinates so that is is possible to start a swipe-to-dismiss gesture from any location. |
| * If any one instance of this Callback returns false for a given set of coordinates, |
| * swipe-to-dismiss will not be allowed to start in that point. |
| * |
| * @param xDown the x coordinate of the initial {@link android.view.MotionEvent#ACTION_DOWN} |
| * event for this motion |
| * @param yDown the y coordinate of the initial {@link android.view.MotionEvent#ACTION_DOWN} |
| * event for this motion |
| * @return {@code true} if these coordinates should be considered as a start of a swipe |
| * gesture, {@code false} otherwise |
| */ |
| boolean onPreSwipe(SwipeDismissLayout swipeDismissLayout, float xDown, float yDown); |
| } |
| |
| /** |
| * Interface enabling listeners to react to when the swipe gesture is done and the view should |
| * probably be dismissed from the UI. |
| */ |
| @UiThread |
| interface OnDismissedListener { |
| void onDismissed(SwipeDismissLayout layout); |
| } |
| |
| /** |
| * Interface enabling listeners to react to changes in the progress of the swipe-to-dismiss |
| * gesture. |
| */ |
| @UiThread |
| interface OnSwipeProgressChangedListener { |
| /** |
| * Called when the layout has been swiped and the position of the window should change. |
| * |
| * @param layout the layout associated with this listener. |
| * @param progress a number in [0, 1] representing how far to the right the window has |
| * been swiped |
| * @param translate a number in [0, w], where w is the width of the layout. This is |
| * equivalent to progress * layout.getWidth() |
| */ |
| void onSwipeProgressChanged(SwipeDismissLayout layout, float progress, float translate); |
| |
| /** |
| * Called when the layout started to be swiped away but then the gesture was canceled. |
| * |
| * @param layout the layout associated with this listener |
| */ |
| void onSwipeCanceled(SwipeDismissLayout layout); |
| } |
| |
| // Cached ViewConfiguration and system-wide constant values |
| private int mSlop; |
| private int mMinFlingVelocity; |
| private float mGestureThresholdPx; |
| |
| // Transient properties |
| private int mActiveTouchId; |
| private float mDownX; |
| private float mDownY; |
| private boolean mSwipeable; |
| private boolean mSwiping; |
| // This variable holds information about whether the initial move of a longer swipe |
| // (consisting of multiple move events) has conformed to the definition of a horizontal |
| // swipe-to-dismiss. A swipe gesture is only ever allowed to be recognized if this variable is |
| // set to true. Otherwise, the motion events will be allowed to propagate to the children. |
| private boolean mCanStartSwipe = true; |
| private boolean mDismissed; |
| private boolean mDiscardIntercept; |
| private VelocityTracker mVelocityTracker; |
| private float mTranslationX; |
| private boolean mDisallowIntercept; |
| |
| @Nullable |
| private OnPreSwipeListener mOnPreSwipeListener; |
| private OnDismissedListener mDismissedListener; |
| private OnSwipeProgressChangedListener mProgressListener; |
| |
| private float mLastX; |
| private float mDismissMinDragWidthRatio = DEFAULT_DISMISS_DRAG_WIDTH_RATIO; |
| |
| SwipeDismissLayout(Context context) { |
| this(context, null); |
| } |
| |
| SwipeDismissLayout(Context context, AttributeSet attrs) { |
| this(context, attrs, 0); |
| } |
| |
| SwipeDismissLayout(Context context, AttributeSet attrs, int defStyle) { |
| this(context, attrs, defStyle, 0); |
| } |
| |
| SwipeDismissLayout(Context context, AttributeSet attrs, int defStyle, int defStyleRes) { |
| super(context, attrs, defStyle, defStyleRes); |
| ViewConfiguration vc = ViewConfiguration.get(context); |
| mSlop = vc.getScaledTouchSlop(); |
| mMinFlingVelocity = vc.getScaledMinimumFlingVelocity(); |
| mGestureThresholdPx = |
| Resources.getSystem().getDisplayMetrics().widthPixels * EDGE_SWIPE_THRESHOLD; |
| |
| // By default, the view is swipeable. |
| setSwipeable(true); |
| } |
| |
| /** |
| * Sets the minimum ratio of the screen after which the swipe gesture is treated as swipe-to- |
| * dismiss. |
| * |
| * @param ratio the ratio of the screen at which the swipe gesture is treated as |
| * swipe-to-dismiss. should be provided as a fraction of the screen |
| */ |
| public void setDismissMinDragWidthRatio(float ratio) { |
| mDismissMinDragWidthRatio = ratio; |
| } |
| |
| /** |
| * Returns the current ratio of te screen at which the swipe gesture is treated as |
| * swipe-to-dismiss. |
| * |
| * @return the current ratio of te screen at which the swipe gesture is treated as |
| * swipe-to-dismiss |
| */ |
| public float getDismissMinDragWidthRatio() { |
| return mDismissMinDragWidthRatio; |
| } |
| |
| /** |
| * Sets the layout to swipeable or not. This effectively turns the functionality of this layout |
| * on or off. |
| * |
| * @param swipeable whether the layout should react to the swipe gesture |
| */ |
| public void setSwipeable(boolean swipeable) { |
| mSwipeable = swipeable; |
| } |
| |
| /** Returns true if the layout reacts to swipe gestures. */ |
| public boolean isSwipeable() { |
| return mSwipeable; |
| } |
| |
| void setOnPreSwipeListener(@Nullable OnPreSwipeListener listener) { |
| mOnPreSwipeListener = listener; |
| } |
| |
| void setOnDismissedListener(@Nullable OnDismissedListener listener) { |
| mDismissedListener = listener; |
| } |
| |
| void setOnSwipeProgressChangedListener(@Nullable OnSwipeProgressChangedListener listener) { |
| mProgressListener = listener; |
| } |
| |
| @Override |
| public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) { |
| mDisallowIntercept = disallowIntercept; |
| if (getParent() != null) { |
| getParent().requestDisallowInterceptTouchEvent(disallowIntercept); |
| } |
| } |
| |
| @Override |
| public boolean onInterceptTouchEvent(MotionEvent ev) { |
| if (!mSwipeable) { |
| return super.onInterceptTouchEvent(ev); |
| } |
| |
| // offset because the view is translated during swipe |
| ev.offsetLocation(mTranslationX, 0); |
| |
| switch (ev.getActionMasked()) { |
| case MotionEvent.ACTION_DOWN: |
| resetMembers(); |
| mDownX = ev.getRawX(); |
| mDownY = ev.getRawY(); |
| mActiveTouchId = ev.getPointerId(0); |
| mVelocityTracker = VelocityTracker.obtain(); |
| mVelocityTracker.addMovement(ev); |
| break; |
| |
| case MotionEvent.ACTION_POINTER_DOWN: |
| int actionIndex = ev.getActionIndex(); |
| mActiveTouchId = ev.getPointerId(actionIndex); |
| break; |
| case MotionEvent.ACTION_POINTER_UP: |
| actionIndex = ev.getActionIndex(); |
| int pointerId = ev.getPointerId(actionIndex); |
| if (pointerId == mActiveTouchId) { |
| // This was our active pointer going up. Choose a new active pointer. |
| int newActionIndex = actionIndex == 0 ? 1 : 0; |
| mActiveTouchId = ev.getPointerId(newActionIndex); |
| } |
| break; |
| |
| case MotionEvent.ACTION_CANCEL: |
| case MotionEvent.ACTION_UP: |
| resetMembers(); |
| break; |
| |
| case MotionEvent.ACTION_MOVE: |
| if (mVelocityTracker == null || mDiscardIntercept) { |
| break; |
| } |
| |
| int pointerIndex = ev.findPointerIndex(mActiveTouchId); |
| if (pointerIndex == -1) { |
| Log.e(TAG, "Invalid pointer index: ignoring."); |
| mDiscardIntercept = true; |
| break; |
| } |
| float dx = ev.getRawX() - mDownX; |
| float x = ev.getX(pointerIndex); |
| float y = ev.getY(pointerIndex); |
| |
| if (dx != 0 && mDownX >= mGestureThresholdPx && canScroll(this, false, dx, x, y)) { |
| mDiscardIntercept = true; |
| break; |
| } |
| updateSwiping(ev); |
| break; |
| } |
| |
| if ((mOnPreSwipeListener == null && !mDisallowIntercept) |
| || mOnPreSwipeListener.onPreSwipe(this, mDownX, mDownY)) { |
| return (!mDiscardIntercept && mSwiping); |
| } |
| return false; |
| } |
| |
| @Override |
| public boolean canScrollHorizontally(int direction) { |
| // This view can only be swiped horizontally from left to right - this means a negative |
| // SCROLLING direction. We return false if the view is not visible to avoid capturing swipe |
| // gestures when the view is hidden. |
| return direction < 0 && isSwipeable() && getVisibility() == View.VISIBLE; |
| } |
| |
| /** |
| * Helper function determining if a particular move gesture was verbose enough to qualify as a |
| * beginning of a swipe. |
| * |
| * @param dx distance traveled in the x direction, from the initial touch down |
| * @param dy distance traveled in the y direction, from the initial touch down |
| * @return {@code true} if the gesture was long enough to be considered a potential swipe |
| */ |
| private boolean isPotentialSwipe(float dx, float dy) { |
| return (dx * dx) + (dy * dy) > mSlop * mSlop; |
| } |
| |
| @Override |
| public boolean onTouchEvent(MotionEvent ev) { |
| if (!mSwipeable) { |
| return super.onTouchEvent(ev); |
| } |
| |
| if (mVelocityTracker == null) { |
| return super.onTouchEvent(ev); |
| } |
| |
| if (mOnPreSwipeListener != null && !mOnPreSwipeListener.onPreSwipe(this, mDownX, mDownY)) { |
| return super.onTouchEvent(ev); |
| } |
| |
| // offset because the view is translated during swipe |
| ev.offsetLocation(mTranslationX, 0); |
| switch (ev.getActionMasked()) { |
| case MotionEvent.ACTION_UP: |
| updateDismiss(ev); |
| if (mDismissed) { |
| dismiss(); |
| } else if (mSwiping) { |
| cancel(); |
| } |
| resetMembers(); |
| break; |
| |
| case MotionEvent.ACTION_CANCEL: |
| cancel(); |
| resetMembers(); |
| break; |
| |
| case MotionEvent.ACTION_MOVE: |
| mVelocityTracker.addMovement(ev); |
| mLastX = ev.getRawX(); |
| updateSwiping(ev); |
| if (mSwiping) { |
| setProgress(ev.getRawX() - mDownX); |
| break; |
| } |
| } |
| return true; |
| } |
| |
| private void setProgress(float deltaX) { |
| mTranslationX = deltaX; |
| if (mProgressListener != null && deltaX >= 0) { |
| mProgressListener.onSwipeProgressChanged(this, deltaX / getWidth(), deltaX); |
| } |
| } |
| |
| private void dismiss() { |
| if (mDismissedListener != null) { |
| mDismissedListener.onDismissed(this); |
| } |
| } |
| |
| private void cancel() { |
| if (mProgressListener != null) { |
| mProgressListener.onSwipeCanceled(this); |
| } |
| } |
| |
| /** Resets internal members when canceling or finishing a given gesture. */ |
| private void resetMembers() { |
| if (mVelocityTracker != null) { |
| mVelocityTracker.recycle(); |
| } |
| mVelocityTracker = null; |
| mTranslationX = 0; |
| mDownX = 0; |
| mDownY = 0; |
| mSwiping = false; |
| mDismissed = false; |
| mDiscardIntercept = false; |
| mCanStartSwipe = true; |
| mDisallowIntercept = false; |
| } |
| |
| private void updateSwiping(MotionEvent ev) { |
| if (!mSwiping) { |
| float deltaX = ev.getRawX() - mDownX; |
| float deltaY = ev.getRawY() - mDownY; |
| if (isPotentialSwipe(deltaX, deltaY)) { |
| // There are three conditions on which we want want to start swiping: |
| // 1. The swipe is from left to right AND |
| // 2. It is horizontal AND |
| // 3. We actually can start swiping |
| mSwiping = mCanStartSwipe && Math.abs(deltaY) < Math.abs(deltaX) && deltaX > 0; |
| mCanStartSwipe = mSwiping; |
| } |
| } |
| } |
| |
| private void updateDismiss(MotionEvent ev) { |
| float deltaX = ev.getRawX() - mDownX; |
| mVelocityTracker.addMovement(ev); |
| mVelocityTracker.computeCurrentVelocity(1000); |
| if (!mDismissed) { |
| if ((deltaX > (getWidth() * mDismissMinDragWidthRatio) && ev.getRawX() >= mLastX) |
| || mVelocityTracker.getXVelocity() >= mMinFlingVelocity) { |
| mDismissed = true; |
| } |
| } |
| // Check if the user tried to undo this. |
| if (mDismissed && mSwiping) { |
| // Check if the user's finger is actually flinging back to left |
| if (mVelocityTracker.getXVelocity() < -mMinFlingVelocity) { |
| mDismissed = false; |
| } |
| } |
| } |
| |
| /** |
| * Tests scrollability within child views of v in the direction of dx. |
| * |
| * @param v view to test for horizontal scrollability |
| * @param checkV whether the view v passed should itself be checked for scrollability |
| * ({@code true}), or just its children ({@code false}) |
| * @param dx delta scrolled in pixels. Only the sign of this is used |
| * @param x x coordinate of the active touch point |
| * @param y y coordinate of the active touch point |
| * @return {@code true} if child views of v can be scrolled by delta of dx |
| */ |
| protected boolean canScroll(View v, boolean checkV, float dx, float x, float y) { |
| if (v instanceof ViewGroup) { |
| final ViewGroup group = (ViewGroup) v; |
| final int scrollX = v.getScrollX(); |
| final int scrollY = v.getScrollY(); |
| final int count = group.getChildCount(); |
| for (int i = count - 1; i >= 0; i--) { |
| final View child = group.getChildAt(i); |
| if (x + scrollX >= child.getLeft() |
| && x + scrollX < child.getRight() |
| && y + scrollY >= child.getTop() |
| && y + scrollY < child.getBottom() |
| && canScroll( |
| child, true, dx, x + scrollX - child.getLeft(), |
| y + scrollY - child.getTop())) { |
| return true; |
| } |
| } |
| } |
| |
| return checkV && v.canScrollHorizontally((int) -dx); |
| } |
| } |