chromium/chrome/android/java/src/org/chromium/chrome/browser/gesturenav/HistoryNavigationLayout.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.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;

import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.Callback;
import org.chromium.chrome.browser.gesturenav.NavigationBubble.CloseTarget;
import org.chromium.ui.base.BackGestureEventSwipeEdge;

/** FrameLayout that supports side-wise slide gesture for history navigation. */
class HistoryNavigationLayout extends FrameLayout implements ViewGroup.OnHierarchyChangeListener {
    // Callback that performs navigation action in response to UI.,
    private final Callback<Boolean> mNavigateCallback;

    // Frame layout hosting the arrow puck UI.
    @Nullable private SideSlideLayout mSideSlideLayout;

    // Async runnable for ending the refresh animation after the page first
    // loads a frame. This is used to provide a reasonable minimum animation time.
    private Runnable mStopNavigatingRunnable;

    // Handles removing the layout from the view hierarchy.  This is posted to ensure
    // it does not conflict with pending Android draws.
    private Runnable mDetachLayoutRunnable;

    public HistoryNavigationLayout(Context context, Callback<Boolean> navigateCallback) {
        super(context);
        mNavigateCallback = navigateCallback;
        setOnHierarchyChangeListener(this);
        setVisibility(View.INVISIBLE);
    }

    @Override
    public void onChildViewAdded(View parent, View child) {
        if (getVisibility() != View.VISIBLE) setVisibility(View.VISIBLE);
    }

    @Override
    public void onChildViewRemoved(View parent, View child) {
        // TODO(jinsukkim): Replace INVISIBLE with GONE to avoid performing layout/measurements.
        if (getChildCount() == 0) setVisibility(View.INVISIBLE);
    }

    /**
     * Creates a view hosting the gesture navigation UI.
     * @return The created view.
     */
    private SideSlideLayout createLayout() {
        mSideSlideLayout = new SideSlideLayout(getContext());
        mSideSlideLayout.setLayoutParams(
                new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
        return mSideSlideLayout;
    }

    /**
     * Start showing arrow widget for navigation back/forward.
     *
     * @param forward {@code true} for forward navigation, or {@code false} for back.
     * @param initiatingEdge Which edge of the screen the gesture is navigating from.
     * @param closeIndicator
     */
    void showBubble(
            boolean forward,
            @BackGestureEventSwipeEdge int initiatingEdge,
            @CloseTarget int closeIndicator) {
        if (mSideSlideLayout == null) {
            SideSlideLayout sideSlideLayout = createLayout();
            sideSlideLayout.setOnNavigationListener(
                    (direction) -> {
                        mNavigateCallback.onResult(direction);
                        cancelStopNavigatingRunnable();
                        sideSlideLayout.post(getStopNavigatingRunnable());
                    });
            sideSlideLayout.setOnResetListener(
                    () -> {
                        if (getDetachLayoutRunnable() != null) return;
                        sideSlideLayout.post(createDetachLayoutRunnable());
                    });
        }
        mSideSlideLayout.setEnabled(true);
        mSideSlideLayout.setDirection(forward);
        mSideSlideLayout.setInitiatingEdge(initiatingEdge);
        mSideSlideLayout.setCloseIndicator(closeIndicator);
        attachLayoutIfNecessary();
        mSideSlideLayout.start();
    }

    /**
     * Signals a pull update.
     *
     * @param offset The change in horizontal pull distance (positive if toward right, negative if
     *     left).
     */
    void pullBubble(float offset) {
        if (mSideSlideLayout == null) return;
        mSideSlideLayout.pull(offset);
    }

    /**
     * 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 {@code true} if release action is supposed to trigger navigation.
     */
    void releaseBubble(boolean allowNav) {
        if (mSideSlideLayout == null) return;
        cancelStopNavigatingRunnable();
        mSideSlideLayout.release(allowNav);
    }

    /** Reset navigation bubble UI in action. */
    void resetBubble() {
        if (mSideSlideLayout == null) return;
        cancelStopNavigatingRunnable();
        mSideSlideLayout.reset();
    }

    /**
     * @return {@code true} if swiped long enough to trigger navigation upon release.
     */
    boolean willNavigate() {
        return mSideSlideLayout != null && mSideSlideLayout.willNavigate();
    }

    /** Cancel navigation operation by removing the runnable in the queue. */
    void cancelStopNavigatingRunnable() {
        if (mStopNavigatingRunnable != null) {
            mSideSlideLayout.removeCallbacks(mStopNavigatingRunnable);
            mStopNavigatingRunnable = null;
        }
    }

    Runnable getDetachLayoutRunnable() {
        return mDetachLayoutRunnable;
    }

    Runnable createDetachLayoutRunnable() {
        mDetachLayoutRunnable =
                () -> {
                    mDetachLayoutRunnable = null;
                    detachLayoutIfNecessary();
                };
        return mDetachLayoutRunnable;
    }

    /** Cancel the operation detaching the layout from view hierarchy. */
    void cancelDetachLayoutRunnable() {
        if (mDetachLayoutRunnable != null) {
            mSideSlideLayout.removeCallbacks(mDetachLayoutRunnable);
            mDetachLayoutRunnable = null;
        }
    }

    Runnable getStopNavigatingRunnable() {
        if (mStopNavigatingRunnable == null) {
            mStopNavigatingRunnable = () -> mSideSlideLayout.stopNavigating();
        }
        return mStopNavigatingRunnable;
    }

    /** Attach {@link SideSlideLayout} to view hierarchy when UI is activated. */
    private void attachLayoutIfNecessary() {
        // The animation view is attached/detached on-demand to minimize overlap
        // with composited SurfaceView content.
        cancelDetachLayoutRunnable();
        if (isLayoutDetached()) addView(mSideSlideLayout);
    }

    private void detachLayoutIfNecessary() {
        if (isLayoutDetached()) return;
        cancelDetachLayoutRunnable();
        removeView(mSideSlideLayout);
    }

    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    boolean isLayoutDetached() {
        return mSideSlideLayout == null || mSideSlideLayout.getParent() == null;
    }
}