chromium/chrome/browser/ui/android/toolbar/java/src/org/chromium/chrome/browser/toolbar/top/tab_strip/HeightTransitionHandler.java

// Copyright 2024 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.toolbar.top.tab_strip;

import android.os.Handler;
import android.util.DisplayMetrics;
import android.view.View;
import android.view.ViewGroup;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import org.chromium.base.CallbackController;
import org.chromium.base.Log;
import org.chromium.base.ObserverList;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.supplier.OneshotSupplier;
import org.chromium.cc.input.BrowserControlsState;
import org.chromium.chrome.browser.browser_controls.BrowserControlsStateProvider;
import org.chromium.chrome.browser.browser_controls.BrowserControlsStateProvider.Observer;
import org.chromium.chrome.browser.browser_controls.BrowserControlsVisibilityManager;
import org.chromium.chrome.browser.toolbar.ControlContainer;
import org.chromium.chrome.browser.toolbar.R;
import org.chromium.chrome.browser.toolbar.top.ToolbarLayout;
import org.chromium.chrome.browser.toolbar.top.tab_strip.TabStripTransitionCoordinator.TabStripHeightObserver;
import org.chromium.chrome.browser.toolbar.top.tab_strip.TabStripTransitionCoordinator.TabStripTransitionDelegate;
import org.chromium.ui.base.ViewUtils;
import org.chromium.ui.resources.dynamics.DynamicResourceReadyOnceCallback;

/**
 * Owned and used by {@link TabStripTransitionCoordinator} to manage tab strip height transitions
 * that may be triggered by one of the following conditions: 1. Window resizing that warrants
 * showing / hiding the strip by updating its height maintained by the control container. 2. Entry
 * into / exit from desktop windowing mode that warrants the strip height to be updated to include /
 * exclude a reserved strip top padding.
 */
class HeightTransitionHandler {
    private static final String TAG = "DTCStripTransition";

    // Minimum width (in dp) of the screen for the tab strip to be shown.
    private static final int TRANSITION_THRESHOLD_DP = 412;

    private final ObserverList<TabStripHeightObserver> mTabStripHeightObservers =
            new ObserverList<>();
    private final CallbackController mCallbackController;
    private final Handler mHandler;
    private final BrowserControlsVisibilityManager mBrowserControlsVisibilityManager;
    private final ControlContainer mControlContainer;
    private final View mToolbarLayout;
    private final int mTabStripHeightFromResource;

    private OneshotSupplier<TabStripTransitionDelegate> mTabStripTransitionDelegateSupplier;

    /**
     * Current height of the tab strip represented by the space reserved on top of the toolbar
     * layout. Could be inconsistent with {@link #mTabStripVisible} during the transition.
     */
    private int mTabStripHeight;

    private int mTabStripTransitionThreshold;

    /**
     * The stable state whether the tab strip will be visible or not. This value will be changed at
     * the beginning of the transition.
     */
    private boolean mTabStripVisible;

    /** Tracks the last width seen for the tab strip. */
    private int mTabStripWidth;

    /** Tracks the additional top padding added to the tab strip. */
    private int mTopPadding;

    private boolean mIsDestroyed;

    private @Nullable BrowserControlsStateProvider.Observer mTransitionKickoffObserver;
    private @Nullable BrowserControlsStateProvider.Observer mTransitionFinishedObserver;

    private boolean mForceUpdateHeight;

    /**
     * Create the manager for transitions to show / hide the tab strip by updating the strip height.
     *
     * @param browserControlsVisibilityManager {@link BrowserControlsVisibilityManager} to observe
     *     browser controls height and animation state.
     * @param controlContainer The {@link ControlContainer} for the containing activity.
     * @param toolbarLayout {@link ToolbarLayout} for the current toolbar.
     * @param tabStripHeightFromResource The height of the tab strip defined in resource.
     * @param callbackController The {@link CallbackController} used by {@link
     *     TabStripTransitionCoordinator}.
     * @param handler The {@link Handler} used by {@link TabStripTransitionCoordinator}.
     * @param tabStripTransitionDelegateSupplier Supplier for the {@link
     *     TabStripTransitionDelegate}.
     */
    HeightTransitionHandler(
            BrowserControlsVisibilityManager browserControlsVisibilityManager,
            ControlContainer controlContainer,
            View toolbarLayout,
            int tabStripHeightFromResource,
            @NonNull CallbackController callbackController,
            @NonNull Handler handler,
            OneshotSupplier<TabStripTransitionDelegate> tabStripTransitionDelegateSupplier) {
        mBrowserControlsVisibilityManager = browserControlsVisibilityManager;
        mControlContainer = controlContainer;
        mToolbarLayout = toolbarLayout;
        mTabStripHeightFromResource = tabStripHeightFromResource;
        mCallbackController = callbackController;
        mHandler = handler;
        mTabStripTransitionDelegateSupplier = tabStripTransitionDelegateSupplier;

        mTabStripHeight = tabStripHeightFromResource;
        mTabStripVisible = mTabStripHeight > 0;
    }

    /** Remove observers and release reference to dependencies. */
    public void destroy() {
        mIsDestroyed = true;

        if (mTransitionKickoffObserver != null) {
            mBrowserControlsVisibilityManager.removeObserver(mTransitionKickoffObserver);
            mTransitionKickoffObserver = null;
        }
        if (mTransitionFinishedObserver != null) {
            mBrowserControlsVisibilityManager.removeObserver(mTransitionFinishedObserver);
            mTransitionFinishedObserver = null;
        }
        mTabStripHeightObservers.clear();
    }

    /** Return the current tab strip height. */
    int getTabStripHeight() {
        return mTabStripHeight;
    }

    /** Add observer for tab strip height change. */
    void addObserver(TabStripHeightObserver observer) {
        mTabStripHeightObservers.addObserver(observer);
    }

    /** Remove observer for tab strip height change. */
    void removeObserver(TabStripHeightObserver observer) {
        mTabStripHeightObservers.removeObserver(observer);
    }

    void updateTabStripTransitionThreshold(DisplayMetrics displayMetrics) {
        mTabStripTransitionThreshold =
                ViewUtils.dpToPx(displayMetrics, getScreenWidthThresholdDp());

        if (TabStripTransitionCoordinator.sHeightTransitionThresholdForTesting != null) {
            requestTransition();
        }
    }

    void setTabStripSize(int width, int topPadding) {
        mTabStripWidth = width;
        mTopPadding = topPadding;
    }

    void requestTransition() {
        mTabStripTransitionDelegateSupplier.runSyncOrOnAvailable(
                mCallbackController.makeCancelable(delegate -> maybeUpdateTabStripVisibility()));
    }

    private void maybeUpdateTabStripVisibility() {
        // Do not allow callback to pass through when object is destroyed.
        if (mIsDestroyed) return;

        // Invalid width / height will be ignored. This can happen when the mControlContainer is
        // created hidden after theme changes. See crbug.com/1511599.
        if (mTabStripWidth <= 0 || controlContainerView().getHeight() == 0) return;

        boolean showTabStrip = mTabStripWidth >= mTabStripTransitionThreshold;
        if (showTabStrip == mTabStripVisible && !mForceUpdateHeight) {
            // Do not transition if visibility does not change, unless we are changing the desktop
            // windowing mode, when we want to continue the transition to update the tab strip top
            // padding.
            return;
        }

        // Update the min size for the control container. This is needed one-layout-before browser
        // controls start changing its height, as it assumed a fixed size control container during
        // transition. See b/324178484.
        View toolbarHairline = controlContainerView().findViewById(R.id.toolbar_hairline);
        int maxHeight =
                calculateTabStripHeight()
                        + mToolbarLayout.getMeasuredHeight()
                        + toolbarHairline.getMeasuredHeight();
        controlContainerView().setMinimumHeight(maxHeight);

        // When transition kicked off by the BrowserControlsManager, the toolbar capture can be
        // stale e.g. still with the previous window width. Force invalidate the toolbar capture to
        // make sure the it's up-to-date with the latest Android view.
        var resourceAdapter = mControlContainer.getToolbarResourceAdapter();
        DynamicResourceReadyOnceCallback.onNext(
                resourceAdapter, (resource) -> setTabStripVisibility(showTabStrip));

        // Post the invalidate to make sure another layout pass is done. This is to make sure the
        // omnibox has the URL text updated to the final width of location bar after the toolbar
        // tablet button animations.
        // TODO(crbug.com/41493621): Trigger bitmap capture without mHandler#post.
        // TODO(crbug.com/41494086): Remove #invalidate after CaptureObservers respect a null
        // dirtyRect input.
        mHandler.post(
                mCallbackController.makeCancelable(
                        () -> {
                            resourceAdapter.invalidate(null);
                            resourceAdapter.triggerBitmapCapture();
                        }));
    }

    void setForceUpdateHeight(boolean forceUpdateHeight) {
        mForceUpdateHeight = forceUpdateHeight;
    }

    private View controlContainerView() {
        return mControlContainer.getView();
    }

    /** This may differ from |mTabStripHeight| when the transition is about to start. */
    private int calculateTabStripHeight() {
        return mTabStripHeightFromResource + mTopPadding;
    }

    /**
     * Set the new height for the tab strip. This on high level consists with 3 steps:
     *
     * <ul>
     *   <li>1. Use BrowserControlsSizer to change the browser control height. This will kick off
     *       the scene layer transition.
     *   <li>2. Add / remove margins from the toolbar and toolbar hairline based on tab strip's new
     *       height.
     *   <li>3. Notify the tab strip scene layer for the new height. This will in turn notify
     *       StripLayoutHelperManager to mark a yOffset that tabStrip will render off-screen. Note
     *       that we cannot simply mark the tab strip scene layer hidden, since it's still required
     *       to be visible during the transition.
     * </ul>
     *
     * @param show Whether the tab strip should be shown.
     */
    private void setTabStripVisibility(boolean show) {
        if (mIsDestroyed) return;

        mTabStripVisible = show;
        // Use a non-zero height when |mForceUpdateHeight| is set.
        int newHeight = show || mForceUpdateHeight ? calculateTabStripHeight() : 0;

        // TODO(crbug.com/41484284): Maybe handle mid-progress pivots for browser controls.
        if (mTransitionFinishedObserver != null) {
            Log.w(
                    TAG,
                    "TransitionFinishedObserver is not cleared when new transition starts. This"
                            + " means previous transition was not finished properly.");
            notifyTransitionFinished(false);
            recordTabStripTransitionFinished(false);
        }

        // TODO(crbug.com/41481630): Request directly instead of using observer interface.
        for (var observer : mTabStripHeightObservers) {
            observer.onTransitionRequested(newHeight);
        }

        // If the browser control is performing an browser initiated animation, we should update the
        // view margins right away. This will make sure the toolbar stays in the same place with
        // changes in control container's Y translation.
        boolean javaAnimationInProgress = mBrowserControlsVisibilityManager.offsetOverridden();

        // For cases where transition is finished in sequence during #onTransitionRequested (e.g.
        // browser control's visibility is under constraint), we'll call updateTabStripHeightImpl
        // to update the margin for the views.
        boolean browserControlsHasConstraint =
                mBrowserControlsVisibilityManager.getBrowserVisibilityDelegate().get()
                        != BrowserControlsState.BOTH;

        if (javaAnimationInProgress || browserControlsHasConstraint) {
            updateTabStripHeightImpl();
            return;
        }

        // For cc initiated transition, we'll defer the view updates until the first
        // #onControlsOffsetChanged is called. This prevents the toolbar margins from getting
        // updated too fast before the cc layer responds, in which case the Android views in the
        // browser control are still visible.
        if (mTransitionKickoffObserver != null) return;
        mTransitionKickoffObserver =
                new BrowserControlsStateProvider.Observer() {
                    @Override
                    public void onControlsOffsetChanged(
                            int topOffset,
                            int topControlsMinHeightOffset,
                            int bottomOffset,
                            int bottomControlsMinHeightOffset,
                            boolean needsAnimate,
                            boolean isVisibilityForced) {
                        updateTabStripHeightImpl();
                    }

                    @Override
                    public void onAndroidControlsVisibilityChanged(int visibility) {
                        // Update the margin when browser control turns into invisible. This can
                        // happen before onControlsOffsetChanged.
                        updateTabStripHeightImpl();
                    }
                };
        mBrowserControlsVisibilityManager.addObserver(mTransitionKickoffObserver);
    }

    // TODO(crbug.com/40939440): Find a better place to set these top margins.
    private void updateTabStripHeightImpl() {
        // Remove the mBrowserControlsObserver, to make sure this method is called only once.
        if (mTransitionKickoffObserver != null) {
            mBrowserControlsVisibilityManager.removeObserver(mTransitionKickoffObserver);
            mTransitionKickoffObserver = null;
        }

        // Change the height when we change the margin, to reflect the actual
        // tab strip height. Check the height to make sure this is only called once.
        // Use a non-zero height when |mForceUpdateHeight| is set.
        int height = mTabStripVisible || mForceUpdateHeight ? calculateTabStripHeight() : 0;
        if (mTabStripHeight == height) return;
        mTabStripHeight = height;

        // The top margin is the space left for the tab strip.
        updateTopMargin(mToolbarLayout, mTabStripHeight);

        // Change the toolbar hairline top margin.
        int topControlHeight = mTabStripHeight + mToolbarLayout.getHeight();
        View toolbarHairline = controlContainerView().findViewById(R.id.toolbar_hairline);
        updateTopMargin(toolbarHairline, topControlHeight);

        // Update the find toolbar and toolbar drop target views. Do not update find_toolbar_stub
        // since it is only used for phones.
        // TODO (crbug.com/1517059): Let FindToolbar itself decide how to set the top margin.
        updateViewStubTopMargin(R.id.find_toolbar_tablet_stub, R.id.find_toolbar, topControlHeight);
        updateViewStubTopMargin(
                R.id.target_view_stub, R.id.toolbar_drag_drop_target_view, mTabStripHeight);

        assert mTabStripTransitionDelegateSupplier.get() != null
                : "TabStripTransitionDelegate should be available.";
        mTabStripTransitionDelegateSupplier.get().onHeightChanged(mTabStripHeight);

        // If top control is already at steady state, notify right away.
        if (isTopControlAtSteadyState()) {
            notifyTransitionFinished(true);
            recordTabStripTransitionFinished(true);
            return;
        }
        // Otherwise, wait for the content offset to read steady state before notifying.
        assert mTransitionFinishedObserver == null;
        mTransitionFinishedObserver =
                new Observer() {
                    @Override
                    public void onControlsOffsetChanged(
                            int topOffset,
                            int topControlsMinHeightOffset,
                            int bottomOffset,
                            int bottomControlsMinHeightOffset,
                            boolean needsAnimate,
                            boolean isVisibilityForced) {
                        if (isTopControlAtSteadyState()) {
                            notifyTransitionFinished(true);
                            recordTabStripTransitionFinished(true);
                        }
                    }
                };
        mBrowserControlsVisibilityManager.addObserver(mTransitionFinishedObserver);
    }

    private void updateViewStubTopMargin(int viewStubId, int inflatedViewId, int topMargin) {
        View view = controlContainerView().findViewById(inflatedViewId);
        if (view != null) {
            updateTopMargin(view, topMargin);
        } else {
            // View is not yet inflated.
            View viewStub = controlContainerView().findViewById(viewStubId);
            updateTopMargin(viewStub, topMargin);
        }
    }

    private static void updateTopMargin(View view, int topMargin) {
        ViewGroup.MarginLayoutParams hairlineParams =
                (ViewGroup.MarginLayoutParams) view.getLayoutParams();
        hairlineParams.topMargin = topMargin;
        view.setLayoutParams(hairlineParams);
    }

    private boolean isTopControlAtSteadyState() {
        // At steady state, the top control's height will match the content offset;
        boolean topControlsAtSteadyState =
                mBrowserControlsVisibilityManager.getContentOffset()
                        == mBrowserControlsVisibilityManager.getTopControlsHeight();

        // The browser controls transition might be interrupted (e.g. by user
        // scrolling). In such case, when browser controls can reach the
        // "end" state without being at the steady state. We can check if browser
        // controls is entirely off screen by user scroll and dispatch the notify
        // signal.
        boolean browserControlsInvisible =
                mBrowserControlsVisibilityManager.getContentOffset() == 0;

        // TODO(crbug.com/41484284): Dispatch the transition finished signal sooner
        //  when interruption is detected.
        return topControlsAtSteadyState || browserControlsInvisible;
    }

    private void notifyTransitionFinished(boolean measureControlContainer) {
        mBrowserControlsVisibilityManager.removeObserver(mTransitionFinishedObserver);
        // Reset internal state after transition ends.
        mForceUpdateHeight = false;
        mTransitionFinishedObserver = null;

        assert mTabStripTransitionDelegateSupplier.get() != null
                : "TabStripTransitionDelegate should be available.";
        mTabStripTransitionDelegateSupplier.get().onHeightTransitionFinished();

        if (measureControlContainer) remeasureControlContainer();
    }

    private void remeasureControlContainer() {
        // Remeasure the control container in the next layout pass if needed. The post is needed due
        // to the existence of ToolbarProgressBar adjusting its position based on ToolbarLayout's
        // layout pass. The control container needs to remeasure based on the new margin in order to
        // retain the up-to-date size with size reduction for the tab strip.

        // This is not done during the transition as it could cause visual glitches.
        mHandler.post(
                mCallbackController.makeCancelable(
                        () -> {
                            View toolbarHairline =
                                    controlContainerView().findViewById(R.id.toolbar_hairline);
                            controlContainerView()
                                    .setMinimumHeight(
                                            mToolbarLayout.getHeight()
                                                    + mTabStripHeight
                                                    + toolbarHairline.getHeight());
                            ViewUtils.requestLayout(
                                    controlContainerView(),
                                    "TabStripTransitionCoordinator.remeasureControlContainer");
                        }));
    }

    private void recordTabStripTransitionFinished(boolean finished) {
        RecordHistogram.recordBooleanHistogram(
                "Android.DynamicTopChrome.TabStripTransition.Finished", finished);
    }

    /** Get the min screen width (in dp) required for the tab strip to become visible. */
    private static int getScreenWidthThresholdDp() {
        if (TabStripTransitionCoordinator.sHeightTransitionThresholdForTesting != null) {
            return TabStripTransitionCoordinator.sHeightTransitionThresholdForTesting;
        }
        return TRANSITION_THRESHOLD_DP;
    }
}