SwipeDismissLayout.java

/*
 * 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);
    }
}