chromium/chrome/android/java/src/org/chromium/chrome/browser/gesturenav/SideSlideLayout.java

// Copyright 2019 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.chrome.browser.gesturenav;

import android.content.Context;
import android.view.HapticFeedbackConstants;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.AlphaAnimation;
import android.view.animation.Animation;
import android.view.animation.Animation.AnimationListener;
import android.view.animation.AnimationSet;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.ScaleAnimation;
import android.view.animation.Transformation;

import org.chromium.chrome.R;
import org.chromium.chrome.browser.gesturenav.NavigationBubble.CloseTarget;
import org.chromium.ui.animation.EmptyAnimationListener;
import org.chromium.ui.base.BackGestureEventSwipeEdge;
import org.chromium.ui.interpolators.Interpolators;

/**
 * The SideSlideLayout can be used whenever the user navigates the contents of a view using
 * horizontal gesture. Shows an arrow widget moving horizontally in reaction to the gesture which,
 * if goes over a threshold, triggers navigation. The caller that instantiates this view should add
 * an {@link #OnNavigateListener} to be notified whenever the gesture is completed. Based on {@link
 * org.chromium.third_party.android.swiperefresh.SwipeRefreshLayout} and modified accordingly to
 * support horizontal gesture.
 */
public class SideSlideLayout extends ViewGroup {
    /**
     * Classes that wish to be notified when the swipe gesture correctly triggers navigation should
     * implement this interface.
     */
    public interface OnNavigateListener {
        void onNavigate(boolean isForward);
    }

    /**
     * Classes that wish to be notified when a reset is triggered should
     * implement this interface.
     */
    public interface OnResetListener {
        void onReset();
    }

    // Swipe offset in dips from the border of the view before applying physical tension
    // effect. The actual arrow bubble position is capped at a value three times as this
    // one, which is where navigation gets triggered.
    private static final int RAW_SWIPE_LIMIT_DP = 32;

    // Multiplier to |RAW_SWIPE_LIMIT_DP| to trigger the navigation.
    private static final int THRESHOLD_MULTIPLIER = 3;

    private static final float DECELERATE_INTERPOLATION_FACTOR = 2f;

    private static final int SCALE_DOWN_DURATION_MS = 600;
    private static final int ANIMATE_TO_START_DURATION_MS = 500;

    // Minimum number of pull updates necessary to trigger a side nav.
    private static final int MIN_PULLS_TO_ACTIVATE = 3;

    // Time threshold to detect navigation reversal - i.e. user navigating
    // forward after navigating back (or back after forward) within a short
    // period of time.
    private static final int NAVIGATION_REVERSAL_MS = 3 * 1000;

    private final DecelerateInterpolator mDecelerateInterpolator;
    private final float mTotalDragDistance;
    private final int mMediumAnimationDuration;
    private final int mCircleWidth;

    // Metrics
    private static long sLastCompletedTime;
    private static boolean sLastCompletedForward;

    // Maximum amount of overscroll for a single side gesture action. An action is regarded
    // as an attempt to navigate via a gesture ('activated') and used for UMA if the maximum
    // overscroll is bigger than a certain threshold.
    private float mMaxOverscroll;

    private OnNavigateListener mListener;
    private OnResetListener mResetListener;

    // Flag indicating that the navigation will be activated.
    private boolean mNavigating;

    private int mCurrentTargetOffset;
    private float mTotalMotion;

    // True while side gesture is in progress.
    private boolean mIsBeingDragged;

    private NavigationBubble mArrowView;
    private int mArrowViewWidth;

    // Start position for animation moving the UI back to original offset.
    private int mFrom;
    private int mOriginalOffset;

    private AnimationSet mHidingAnimation;
    private int mAnimationViewWidth;

    private boolean mIsForward;
    private @CloseTarget int mCloseIndicator;

    private @BackGestureEventSwipeEdge int mInitiatingEdge;

    // True while swiped to a distance where, if released, the navigation would be triggered.
    private boolean mWillNavigate;

    private final AnimationListener mNavigateListener =
            new EmptyAnimationListener() {
                @Override
                public void onAnimationEnd(Animation animation) {
                    mArrowView.setFaded(false, false);
                    mArrowView.setVisibility(View.INVISIBLE);
                    if (!mNavigating) reset();
                    hideCloseIndicator();
                }
            };

    private final Animation mAnimateToStartPosition =
            new Animation() {
                @Override
                public void applyTransformation(float interpolatedTime, Transformation t) {
                    int targetTop = mFrom + (int) ((mOriginalOffset - mFrom) * interpolatedTime);
                    int offset = targetTop - mArrowView.getLeft();
                    mTotalMotion += offset;
                    setTargetOffsetLeftAndRight(offset);
                }
            };

    public SideSlideLayout(Context context) {
        super(context);

        mMediumAnimationDuration =
                getResources().getInteger(android.R.integer.config_mediumAnimTime);

        setWillNotDraw(false);
        mDecelerateInterpolator = new DecelerateInterpolator(DECELERATE_INTERPOLATION_FACTOR);

        mCircleWidth = getResources().getDimensionPixelSize(R.dimen.navigation_bubble_size);

        LayoutInflater layoutInflater = LayoutInflater.from(getContext());
        mArrowView = (NavigationBubble) layoutInflater.inflate(R.layout.navigation_bubble, null);
        mArrowView
                .getTextView()
                .setText(
                        getResources()
                                .getString(
                                        R.string.overscroll_navigation_close_chrome,
                                        getContext().getString(R.string.app_name)));
        mArrowViewWidth = mCircleWidth;
        addView(mArrowView);

        // The absolute offset has to take into account that the circle starts at an offset
        mTotalDragDistance = RAW_SWIPE_LIMIT_DP * getResources().getDisplayMetrics().density;

        mAnimateToStartPosition.setAnimationListener(
                new EmptyAnimationListener() {
                    @Override
                    public void onAnimationEnd(Animation animation) {
                        reset();
                    }
                });
    }

    /** Set the listener to be notified when the navigation is triggered. */
    public void setOnNavigationListener(OnNavigateListener listener) {
        mListener = listener;
    }

    /** Set the reset listener to be notified when a reset is triggered. */
    public void setOnResetListener(OnResetListener listener) {
        mResetListener = listener;
    }

    /** Stop navigation. */
    public void stopNavigating() {
        setNavigating(false);
    }

    private void setNavigating(boolean navigating) {
        if (mNavigating != navigating) {
            mNavigating = navigating;
            if (mNavigating) startHidingAnimation(mNavigateListener);
        }
    }

    /**
     * @return Absolute swipe distance from the starting edge.
     */
    float getOverscroll() {
        return mInitiatingEdge == BackGestureEventSwipeEdge.RIGHT
                ? -Math.min(0, mTotalMotion)
                : Math.max(0, mTotalMotion);
    }

    private void startHidingAnimation(AnimationListener listener) {
        // Start animation and navigation simultaneously.
        if (mNavigating && mListener != null) mListener.onNavigate(mIsForward);

        // ScaleAnimation needs to be created again if the arrow widget width changes over time
        // (due to turning on/off close indicator) to set the right x pivot point.
        if (mHidingAnimation == null || mAnimationViewWidth != mArrowViewWidth) {
            mAnimationViewWidth = mArrowViewWidth;
            ScaleAnimation scalingDown =
                    new ScaleAnimation(1, 0, 1, 0, mArrowViewWidth / 2, mArrowView.getHeight() / 2);
            scalingDown.setInterpolator(Interpolators.LINEAR_INTERPOLATOR);
            scalingDown.setDuration(SCALE_DOWN_DURATION_MS);
            Animation fadingOut = new AlphaAnimation(1, 0);
            fadingOut.setInterpolator(mDecelerateInterpolator);
            fadingOut.setDuration(SCALE_DOWN_DURATION_MS);
            mHidingAnimation = new AnimationSet(false);
            mHidingAnimation.addAnimation(fadingOut);
            mHidingAnimation.addAnimation(scalingDown);
        }
        mArrowView.setAnimationListener(listener);
        mArrowView.clearAnimation();
        mArrowView.startAnimation(mHidingAnimation);
    }

    /**
     * Set the direction used for sliding gesture.
     * @param forward {@code true} if direction is forward.
     */
    public void setDirection(boolean forward) {
        mIsForward = forward;
        mArrowView.setIcon(
                forward ? R.drawable.ic_arrow_forward_blue_24dp : R.drawable.ic_arrow_back_24dp);
    }

    public void setInitiatingEdge(@BackGestureEventSwipeEdge int edge) {
        mInitiatingEdge = edge;
    }

    public void setCloseIndicator(@CloseTarget int target) {
        mCloseIndicator = target;
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        if (getChildCount() == 0) return;

        final int height = getMeasuredHeight();
        final int arrowWidth = mArrowView.getMeasuredWidth();
        final int arrowHeight = mArrowView.getMeasuredHeight();
        mArrowView.layout(
                mCurrentTargetOffset,
                height / 2 - arrowHeight / 2,
                mCurrentTargetOffset + arrowWidth,
                height / 2 + arrowHeight / 2);
    }

    @Override
    public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        mArrowView.measure(
                MeasureSpec.makeMeasureSpec(mArrowViewWidth, MeasureSpec.EXACTLY),
                MeasureSpec.makeMeasureSpec(mCircleWidth, MeasureSpec.EXACTLY));
    }

    private void initializeOffset() {
        mOriginalOffset =
                mInitiatingEdge == BackGestureEventSwipeEdge.RIGHT
                        ? ((View) getParent()).getWidth()
                        : -mArrowViewWidth;
        mCurrentTargetOffset = mOriginalOffset;
    }

    /**
     * Start the pull effect. If the effect is disabled or a navigation animation
     * is currently active, the request will be ignored.
     * @return whether a new pull sequence has started.
     */
    public boolean start() {
        if (!isEnabled() || mNavigating || mListener == null) return false;
        mTotalMotion = 0;
        mMaxOverscroll = 0.f;
        mIsBeingDragged = true;
        mWillNavigate = false;
        initializeOffset();
        mArrowView.setFaded(false, false);
        return true;
    }

    /**
     * Apply a pull impulse to the effect. If the effect is disabled or has yet
     * to start, the pull will be ignored.
     * @param offset Updated total pull offset.
     */
    public void pull(float offset) {
        float delta = offset - mTotalMotion;
        if (!isEnabled() || !mIsBeingDragged) return;

        float maxDelta = mTotalDragDistance / MIN_PULLS_TO_ACTIVATE;
        delta = Math.max(-maxDelta, Math.min(maxDelta, delta));
        mTotalMotion += delta;

        float overscroll = getOverscroll();
        float extraOs = overscroll - mTotalDragDistance;
        if (overscroll > mMaxOverscroll) mMaxOverscroll = overscroll;
        float slingshotDist = mTotalDragDistance;
        float tensionSlingshotPercent =
                Math.max(0, Math.min(extraOs, slingshotDist * 2) / slingshotDist);
        float tensionPercent =
                (float) ((tensionSlingshotPercent / 4) - Math.pow((tensionSlingshotPercent / 4), 2))
                        * 2f;

        if (mArrowView.getVisibility() != View.VISIBLE) mArrowView.setVisibility(View.VISIBLE);

        float originalDragPercent = overscroll / mTotalDragDistance;
        float dragPercent = Math.min(1f, Math.abs(originalDragPercent));

        // Tint the arrow blue when swiped enough to initiate navigation if released.
        boolean navigating = willNavigate();
        if (navigating != mWillNavigate) {
            mArrowView.setImageTint(navigating);
            if (navigating) performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
        }
        mWillNavigate = navigating;

        if (mCloseIndicator != CloseTarget.NONE) {
            if (mWillNavigate) {
                mArrowView.showCaption(mCloseIndicator);
                mArrowViewWidth = mArrowView.getMeasuredWidth();
            } else {
                hideCloseIndicator();
            }
        }

        float extraMove = slingshotDist * tensionPercent * 2;
        int targetDiff = (int) (slingshotDist * dragPercent + extraMove);
        int targetX =
                mOriginalOffset
                        + (mInitiatingEdge == BackGestureEventSwipeEdge.RIGHT
                                ? -targetDiff
                                : targetDiff);
        setTargetOffsetLeftAndRight(targetX - mCurrentTargetOffset);
    }

    /**
     * @return {@code true} if swiped long enough to trigger navigation upon release.
     */
    boolean willNavigate() {
        return getOverscroll() > mTotalDragDistance * THRESHOLD_MULTIPLIER;
    }

    private void hideCloseIndicator() {
        mArrowView.showCaption(CloseTarget.NONE);
        // The width when indicator text view is hidden is slightly bigger than the height.
        // Set the width to circle's diameter for the widget to be of completely round shape.
        mArrowViewWidth = mCircleWidth;
    }

    private void setTargetOffsetLeftAndRight(int offset) {
        mArrowView.offsetLeftAndRight(offset);
        mCurrentTargetOffset = mArrowView.getLeft();
    }

    /**
     * Release the active pull. If no pull has started, the release will be ignored.
     * If the pull was sufficiently large, the navigation sequence will be initiated.
     * @param allowNav whether to allow a sufficiently large pull to trigger
     *                     the navigation action and animation sequence.
     */
    public void release(boolean allowNav) {
        if (!mIsBeingDragged) return;

        // See ACTION_UP handling in {@link #onTouchEvent(...)}.
        mIsBeingDragged = false;

        boolean activated = mMaxOverscroll >= mArrowViewWidth / 3;
        if (activated) {
            GestureNavMetrics.recordHistogram("GestureNavigation.Activated2", mIsForward);
        }

        if (isEnabled() && willNavigate()) {
            if (allowNav) {
                setNavigating(true);
                GestureNavMetrics.recordHistogram("GestureNavigation.Completed2", mIsForward);
                long time = System.currentTimeMillis();
                if (sLastCompletedTime > 0
                        && time - sLastCompletedTime < NAVIGATION_REVERSAL_MS
                        && mIsForward != sLastCompletedForward) {
                    GestureNavMetrics.recordHistogram("GestureNavigation.Reversed2", mIsForward);
                }
                sLastCompletedTime = time;
                sLastCompletedForward = mIsForward;
            } else {
                // Show navigation instead of triggering navigation. Just hide the arrow
                // by fading it away.
                mNavigating = false;
                startHidingAnimation(mNavigateListener);
            }
            return;
        }
        // Cancel navigation
        mNavigating = false;
        mFrom = mCurrentTargetOffset;
        mAnimateToStartPosition.reset();
        mAnimateToStartPosition.setDuration(ANIMATE_TO_START_DURATION_MS);
        mAnimateToStartPosition.setInterpolator(mDecelerateInterpolator);
        mArrowView.clearAnimation();
        mArrowView.startAnimation(mAnimateToStartPosition);
        if (activated) {
            GestureNavMetrics.recordHistogram("GestureNavigation.Cancelled2", mIsForward);
        }
    }

    /** Reset the effect, clearing any active animations. */
    public void reset() {
        mIsBeingDragged = false;
        setNavigating(false);
        hideCloseIndicator();

        // Return the circle to its start position
        setTargetOffsetLeftAndRight(mOriginalOffset - mCurrentTargetOffset);
        mCurrentTargetOffset = mArrowView.getLeft();
        if (mResetListener != null) mResetListener.onReset();
    }
}