SwipeDismissController.java

/*
 * Copyright (C) 2020 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.Log;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;

import androidx.annotation.NonNull;
import androidx.annotation.RestrictTo;
import androidx.annotation.RestrictTo.Scope;
import androidx.annotation.UiThread;

/**
 * Controller that handles the swipe-to-dismiss gesture for dismiss the frame layout
 *
 */
@RestrictTo(Scope.LIBRARY)
@UiThread
class SwipeDismissController extends DismissController {
    private static final String TAG = "SwipeDismissController";
    public static final float DEFAULT_DISMISS_DRAG_WIDTH_RATIO = .33f;
    private float mDismissMinDragWidthRatio = DEFAULT_DISMISS_DRAG_WIDTH_RATIO;
    // 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;
    private static final int VELOCITY_UNIT = 1000;
    // Cached ViewConfiguration and system-wide constant value
    private final int mSlop;
    private final int mMinFlingVelocity;
    private final float mGestureThresholdPx;
    private final SwipeDismissTransitionHelper mSwipeDismissTransitionHelper;
    private int mActiveTouchId;
    private float mDownX;
    private float mDownY;
    private float mLastX;
    private boolean mSwiping;
    private boolean mDismissed;
    private boolean mDiscardIntercept;

    private boolean mBlockGesture = false;

    SwipeDismissController(Context context, DismissibleFrameLayout layout) {
        super(context, layout);

        ViewConfiguration vc = ViewConfiguration.get(context);
        mSlop = vc.getScaledTouchSlop();
        mMinFlingVelocity = vc.getScaledMinimumFlingVelocity();
        mGestureThresholdPx =
                Resources.getSystem().getDisplayMetrics().widthPixels * EDGE_SWIPE_THRESHOLD;

        mSwipeDismissTransitionHelper = new SwipeDismissTransitionHelper(context, layout);
    }

    public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
        if (mLayout.getParent() != null) {
            mLayout.getParent().requestDisallowInterceptTouchEvent(disallowIntercept);
        }
    }

    void setDismissMinDragWidthRatio(float ratio) {
        mDismissMinDragWidthRatio = ratio;
    }

    float getDismissMinDragWidthRatio() {
        return mDismissMinDragWidthRatio;
    }

    boolean onInterceptTouchEvent(MotionEvent ev) {
        checkGesture(ev);
        if (mBlockGesture) {
            return true;
        }
        // Offset because the view is translated during swipe, match X with raw X. Active touch
        // coordinates are mostly used by the velocity tracker, so offset it to match the raw
        // coordinates which is what is primarily used elsewhere.
        float offsetX = ev.getRawX() - ev.getX();
        float offsetY = 0.0f;
        ev.offsetLocation(offsetX, offsetY);

        switch (ev.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
                resetSwipeDetectMembers();
                mDownX = ev.getRawX();
                mDownY = ev.getRawY();
                mActiveTouchId = ev.getPointerId(0);
                mSwipeDismissTransitionHelper.obtainVelocityTracker();
                mSwipeDismissTransitionHelper.getVelocityTracker().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:
                resetSwipeDetectMembers();
                break;

            case MotionEvent.ACTION_MOVE:
                if (mSwipeDismissTransitionHelper.getVelocityTracker() == 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(mLayout, false, dx, x,
                        y)) {
                    mDiscardIntercept = true;
                    break;
                }
                updateSwiping(ev);
                break;
        }
        ev.offsetLocation(-offsetX, -offsetY);
        return (!mDiscardIntercept && mSwiping);
    }

    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 && mLayout.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;
    }

    public boolean onTouchEvent(@NonNull MotionEvent ev) {
        checkGesture(ev);
        if (mBlockGesture) {
            return true;
        }

        if (mSwipeDismissTransitionHelper.getVelocityTracker() == null) {
            return false;
        }

        // Offset because the view is translated during swipe, match X with raw X. Active touch
        // coordinates are mostly used by the velocity tracker, so offset it to match the raw
        // coordinates which is what is primarily used elsewhere.
        float offsetX = ev.getRawX() - ev.getX();
        float offsetY = 0.0f;
        ev.offsetLocation(offsetX, offsetY);
        switch (ev.getActionMasked()) {
            case MotionEvent.ACTION_UP:
                updateDismiss(ev);
                // Fall through, don't update gesture tracker with the event for ACTION_CANCEL
            case MotionEvent.ACTION_CANCEL:
                if (mDismissed) {
                    mSwipeDismissTransitionHelper.animateDismissal(mDismissListener);
                } else if (mSwiping
                        // Only trigger animation if we had a MOVE event that would shift the
                        // underlying view, otherwise the animation would be janky.
                        && mLastX != Integer.MIN_VALUE) {
                    mSwipeDismissTransitionHelper.animateRecovery(mDismissListener);
                }
                resetSwipeDetectMembers();
                break;
            case MotionEvent.ACTION_MOVE:
                mSwipeDismissTransitionHelper.getVelocityTracker().addMovement(ev);
                mLastX = ev.getRawX();
                updateSwiping(ev);
                if (mSwiping) {
                    mSwipeDismissTransitionHelper.onSwipeProgressChanged(ev.getRawX() - mDownX, ev);
                    break;
                }
        }
        ev.offsetLocation(-offsetX, -offsetY);
        return true;
    }

    /** Resets internal members when canceling or finishing a given gesture. */
    private void resetSwipeDetectMembers() {
        if (mSwipeDismissTransitionHelper.getVelocityTracker() != null) {
            mSwipeDismissTransitionHelper.getVelocityTracker().recycle();
        }
        mSwipeDismissTransitionHelper.resetVelocityTracker();
        mDownX = 0;
        mDownY = 0;
        mSwiping = false;
        mLastX = Integer.MIN_VALUE;
        mDismissed = false;
        mDiscardIntercept = false;
    }

    private void updateSwiping(MotionEvent ev) {
        if (!mSwiping) {
            float deltaX = ev.getRawX() - mDownX;
            float deltaY = ev.getRawY() - mDownY;
            if (isPotentialSwipe(deltaX, deltaY)) {
                mSwiping = deltaX > mSlop * 2
                        && Math.abs(deltaY) < Math.abs(deltaX);
            } else {
                mSwiping = false;
            }
        }
    }

    private void updateDismiss(@NonNull MotionEvent ev) {
        float deltaX = ev.getRawX() - mDownX;
        // Don't add the motion event as an UP event would clear the velocity tracker
        VelocityTracker velocityTracker = mSwipeDismissTransitionHelper.getVelocityTracker();
        velocityTracker.computeCurrentVelocity(VELOCITY_UNIT);
        float xVelocity = velocityTracker.getXVelocity();
        float yVelocity = velocityTracker.getYVelocity();
        if (mLastX == Integer.MIN_VALUE) {
            // If there's no changes to mLastX, we have only one point of data, and therefore no
            // velocity. Estimate velocity from just the up and down event in that case.
            xVelocity = deltaX / ((ev.getEventTime() - ev.getDownTime()) / 1000f);
        }

        if (!mDismissed) {
            if ((deltaX > (mLayout.getWidth() * mDismissMinDragWidthRatio)
                    && ev.getRawX() >= mLastX)
                    || (xVelocity >= mMinFlingVelocity
                    && xVelocity > Math.abs(
                    yVelocity)))  {
                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 (xVelocity < -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(@NonNull 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);
    }

    private void checkGesture(MotionEvent ev) {
        if (ev.getActionMasked() == MotionEvent.ACTION_DOWN) {
            mBlockGesture = mSwipeDismissTransitionHelper.isAnimating();
        }
    }
}