chromium/components/browser_ui/widget/android/java/src/org/chromium/components/browser_ui/widget/gesture/SwipeGestureListener.java

// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.components.browser_ui.widget.gesture;

import android.content.Context;
import android.graphics.PointF;
import android.view.GestureDetector;
import android.view.GestureDetector.SimpleOnGestureListener;
import android.view.MotionEvent;

import androidx.annotation.IntDef;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.ThreadUtils;
import org.chromium.components.browser_ui.widget.R;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

/**
 * Recognizes directional swipe gestures using supplied {@link MotionEvent}s.
 * The {@link SwipeHandler} callbacks will notify users when a particular gesture
 * has occurred, if the handler supports the particular direction of the swipe.
 *
 * To use this class:
 * <ul>
 *  <li>Create an instance of the {@link SwipeGestureListener} for your View.
 *  <li>In the View#onTouchEvent(MotionEvent) method ensure you call
 *          {@link #onTouchEvent(MotionEvent)}. The methods defined in your callback
 *          will be executed when the gestures occur.
 *  <li>Before trying to recognize the gesture, the class will call
 *          {@link #shouldRecognizeSwipe(MotionEvent, MotionEvent)}, which allows
 *          ignoring swipe recognition based on the MotionEvents.
 *  <li>Once a swipe gesture is detected, the class will check if the the direction
 *          is supported by calling {@link SwipeHandler#isSwipeEnabled}.
 *  <li>Override {@link #onDown(MotionEvent)} to always return true if you want to intercept the
 *          event stream from the initial #onDown event. This always returns false by default, as
 *          {@link SimpleOnGestureListener} does by default.
 * </ul>
 *
 * Internally, this class uses a {@link GestureDetector} to recognize swipe gestures.
 * For convenience, this class also extends {@link SimpleOnGestureListener} which
 * is passed to the {@link GestureDetector}. This means that this class can also be
 * used to detect simple gestures defined in {@link GestureDetector}.
 */
public class SwipeGestureListener extends SimpleOnGestureListener {
    @IntDef({
        ScrollDirection.UNKNOWN,
        ScrollDirection.LEFT,
        ScrollDirection.RIGHT,
        ScrollDirection.UP,
        ScrollDirection.DOWN
    })
    @Retention(RetentionPolicy.SOURCE)
    public @interface ScrollDirection {
        int UNKNOWN = 0;
        int LEFT = 1;
        int RIGHT = 2;
        int UP = 3;
        int DOWN = 4;
    }

    public interface SwipeHandler {
        /**
         * @param direction The {@link ScrollDirection} representing the swipe direction.
         * @param ev The first down motion event triggering the swipe.
         */
        default void onSwipeStarted(@ScrollDirection int direction, MotionEvent ev) {}

        /**
         * @param current The move motion event triggering the current swipe.
         * @param tx The horizontal difference between the start and the current position in px.
         * @param ty The vertical difference between the start and the current position in px.
         * @param distanceX The distance along the X axis that has been scrolled since the last call
         *         to onScroll.
         * @param distanceY The distance along the Y axis that has been scrolled since the last call
         *         to onScroll.
         */
        default void onSwipeUpdated(
                MotionEvent current, float tx, float ty, float distanceX, float distanceY) {}

        /** @param end The last motion event canceling the swipe. */
        default void onSwipeFinished() {}

        /**
         * @param direction The {@link ScrollDirection} representing the swipe direction.
         * @param current The first down motion event triggering the swipe.
         * @param tx The horizontal difference between the start and the current position in px.
         * @param ty The vertical difference between the start and the current position in px.
         * @param velocityX The velocity of this fling measured in pixels per second along the x
         *         axis.
         * @param velocityY The velocity of this fling measured in pixels per second along the y
         *         axis.
         */
        default void onFling(
                @ScrollDirection int direction,
                MotionEvent current,
                float tx,
                float ty,
                float velocityX,
                float velocityY) {}

        /**
         * @param direction The direction of the on-going swipe.
         * @return False if this direction should be ignored.
         */
        default boolean isSwipeEnabled(@ScrollDirection int direction) {
            return true;
        }
    }

    /** The internal {@link GestureDetector} used to recognize swipe gestures. */
    private final GestureDetector mGestureDetector;

    private final PointF mMotionStartPoint = new PointF();
    @ScrollDirection private int mDirection = ScrollDirection.UNKNOWN;
    private final SwipeHandler mHandler;

    /** The threshold for a vertical swipe gesture, in px. */
    private final int mSwipeVerticalDragThreshold;

    /** The threshold for a horizontal swipe gesture, in px. */
    private final int mSwipeHorizontalDragThreshold;

    /**
     * @param context The {@link Context}.
     * @param handler The {@link SwipeHandler} to handle the swipe events.
     */
    public SwipeGestureListener(Context context, SwipeHandler handler) {
        mGestureDetector = new GestureDetector(context, this, ThreadUtils.getUiThreadHandler());
        mSwipeVerticalDragThreshold =
                context.getResources()
                        .getDimensionPixelOffset(R.dimen.swipe_vertical_drag_threshold);
        mSwipeHorizontalDragThreshold =
                context.getResources()
                        .getDimensionPixelOffset(R.dimen.swipe_horizontal_drag_threshold);
        mHandler = handler;
    }

    @VisibleForTesting
    SwipeGestureListener(
            Context context, SwipeHandler handler, int verticalThreshold, int horizontalThreshold) {
        mGestureDetector = new GestureDetector(context, this, ThreadUtils.getUiThreadHandler());
        mSwipeVerticalDragThreshold = verticalThreshold;
        mSwipeHorizontalDragThreshold = horizontalThreshold;
        mHandler = handler;
    }

    /**
     * Analyzes the given motion event by feeding it to a {@link GestureDetector}. Depending on the
     * results from the onScroll() and onFling() methods, it triggers the appropriate callbacks
     * on the {@link SwipeHandler} supplied.
     *
     * @param event The {@link MotionEvent}.
     * @return Whether the event has been consumed.
     */
    public boolean onTouchEvent(MotionEvent event) {
        boolean consumed = mGestureDetector.onTouchEvent(event);

        if (mHandler != null) {
            final int action = event.getAction();
            if ((action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL)
                    && mDirection != ScrollDirection.UNKNOWN) {
                mHandler.onSwipeFinished();
                mDirection = ScrollDirection.UNKNOWN;
                consumed = true;
            }
        }

        return consumed;
    }

    /**
     * Checks whether the swipe gestures should be recognized. If this method returns false,
     * then the whole swipe recognition process will be ignored. By default this method returns
     * true. If a more complex logic is needed, this method should be overridden.
     *
     * @param e1 The first {@link MotionEvent}.
     * @param e2 The second {@link MotionEvent}.
     * @return Whether the swipe gestures should be recognized
     */
    public boolean shouldRecognizeSwipe(MotionEvent e1, MotionEvent e2) {
        return true;
    }

    // ============================================================================================
    // Swipe Recognition Helpers
    // ============================================================================================

    // Override #onDown if necessary. See JavaDoc of this class for more details.

    @Override
    public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
        if (mHandler == null || e1 == null || e2 == null) return false;

        if (mDirection == ScrollDirection.UNKNOWN && shouldRecognizeSwipe(e1, e2)) {
            float tx = e2.getRawX() - e1.getRawX();
            float ty = e2.getRawY() - e1.getRawY();

            @ScrollDirection int direction = ScrollDirection.UNKNOWN;

            if (Math.abs(tx) < mSwipeHorizontalDragThreshold
                    && Math.abs(ty) < mSwipeVerticalDragThreshold) {
                return false;
            }
            if (Math.abs(tx) > Math.abs(ty)) {
                direction = tx > 0.f ? ScrollDirection.RIGHT : ScrollDirection.LEFT;
            } else {
                direction = ty > 0.f ? ScrollDirection.DOWN : ScrollDirection.UP;
            }

            if (direction != ScrollDirection.UNKNOWN && mHandler.isSwipeEnabled(direction)) {
                mDirection = direction;
                mHandler.onSwipeStarted(direction, e2);
                mMotionStartPoint.set(e2.getRawX(), e2.getRawY());
            }
        }

        if (mDirection != ScrollDirection.UNKNOWN) {
            mHandler.onSwipeUpdated(
                    e2,
                    e2.getRawX() - mMotionStartPoint.x,
                    e2.getRawY() - mMotionStartPoint.y,
                    -distanceX,
                    -distanceY);
            return true;
        }

        return false;
    }

    @Override
    public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
        if (mHandler == null) return false;

        if (mDirection != ScrollDirection.UNKNOWN) {
            mHandler.onFling(
                    mDirection,
                    e2,
                    e2.getRawX() - mMotionStartPoint.x,
                    e2.getRawY() - mMotionStartPoint.y,
                    velocityX,
                    velocityY);
            return true;
        }

        return false;
    }
}