chromium/chrome/android/java/src/org/chromium/chrome/browser/compositor/layouts/ToolbarSwipeLayout.java

// Copyright 2015 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.compositor.layouts;

import android.content.Context;
import android.content.res.Resources;
import android.graphics.RectF;

import org.chromium.base.MathUtils;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.base.supplier.ObservableSupplierImpl;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.browser_controls.BrowserControlsStateProvider;
import org.chromium.chrome.browser.compositor.layouts.components.LayoutTab;
import org.chromium.chrome.browser.compositor.layouts.eventfilter.BlackHoleEventFilter;
import org.chromium.chrome.browser.compositor.scene_layer.ToolbarSwipeSceneLayer;
import org.chromium.chrome.browser.layouts.EventFilter;
import org.chromium.chrome.browser.layouts.LayoutManager;
import org.chromium.chrome.browser.layouts.LayoutType;
import org.chromium.chrome.browser.layouts.animation.CompositorAnimator;
import org.chromium.chrome.browser.layouts.scene_layer.SceneLayer;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabSelectionType;
import org.chromium.chrome.browser.tab_ui.TabContentManager;
import org.chromium.chrome.browser.tabmodel.TabClosureParams;
import org.chromium.chrome.browser.tabmodel.TabModel;
import org.chromium.chrome.browser.tabmodel.TabModelUtils;
import org.chromium.chrome.browser.theme.TopUiThemeColorProvider;
import org.chromium.chrome.browser.toolbar.top.TopToolbarOverlayCoordinator;
import org.chromium.components.browser_ui.styles.SemanticColorUtils;
import org.chromium.components.browser_ui.widget.gesture.SwipeGestureListener.ScrollDirection;
import org.chromium.components.embedder_support.util.UrlUtilities;
import org.chromium.ui.base.DeviceFormFactor;
import org.chromium.ui.base.LocalizationUtils;
import org.chromium.ui.interpolators.Interpolators;
import org.chromium.ui.resources.ResourceManager;

import java.util.ArrayList;
import java.util.List;

/** Layout defining the animation and positioning of the tabs during the edge swipe effect. */
public class ToolbarSwipeLayout extends Layout {
    private static final boolean ANONYMIZE_NON_FOCUSED_TAB = true;

    // Unit is millisecond / screen.
    private static final float ANIMATION_SPEED_SCREEN_MS = 500.0f;

    // The time duration of the animation for switch to tab, Unit is millisecond.
    private static final long SWITCH_TO_TAB_DURATION_MS = 350;

    // This is the time step used to move the offset based on fling
    private static final float FLING_TIME_STEP = 1.0f / 30.0f;

    // This is the max contribution from fling in screen size percentage.
    private static final float FLING_MAX_CONTRIBUTION = 0.5f;

    private LayoutTab mLeftTab;
    private LayoutTab mRightTab;
    private LayoutTab mFromTab; // Set to either mLeftTab or mRightTab.
    private LayoutTab mToTab; // Set to mLeftTab or mRightTab or null if it is not determined.

    private TopToolbarOverlayCoordinator mLeftToolbarOverlay;
    private TopToolbarOverlayCoordinator mRightToolbarOverlay;

    private ObservableSupplierImpl<Tab> mLeftTabSupplier;
    private ObservableSupplierImpl<Tab> mRightTabSupplier;

    // Whether or not to show the toolbar.
    private boolean mMoveToolbar;

    // Offsets are in pixels [0, width].
    private float mOffsetStart;
    private float mOffset;
    private float mOffsetTarget;

    // These will be set from dimens.xml
    private final float mSpaceBetweenTabs;
    private final float mCommitDistanceFromEdge;

    private final BlackHoleEventFilter mBlackHoleEventFilter;
    private ToolbarSwipeSceneLayer mSceneLayer;

    private final BrowserControlsStateProvider mBrowserControlsStateProvider;

    // This is a work around for crbug.com/1348624. We need to call switch to tab after
    // ToolbarSwipeLayout is shown when it's switching to a tab.
    private boolean mIsSwitchToStaticTab;
    private int mToTabId;
    private int mFromTabId;

    // The tab to select on finishing the animation.
    private int mNextTabId;

    /**
     * @param context             The current Android's context.
     * @param updateHost          The {@link LayoutUpdateHost} view for this layout.
     * @param renderHost          The {@link LayoutRenderHost} view for this layout.
     */
    public ToolbarSwipeLayout(
            Context context,
            LayoutUpdateHost updateHost,
            LayoutRenderHost renderHost,
            BrowserControlsStateProvider browserControlsStateProvider,
            LayoutManager layoutManager,
            TopUiThemeColorProvider topUiColorProvider) {
        super(context, updateHost, renderHost);
        mBlackHoleEventFilter = new BlackHoleEventFilter(context);
        mBrowserControlsStateProvider = browserControlsStateProvider;
        Resources res = context.getResources();
        final float pxToDp = 1.0f / res.getDisplayMetrics().density;
        mCommitDistanceFromEdge = res.getDimension(R.dimen.toolbar_swipe_commit_distance) * pxToDp;
        mSpaceBetweenTabs = res.getDimension(R.dimen.toolbar_swipe_space_between_tabs) * pxToDp;

        mMoveToolbar = !DeviceFormFactor.isNonMultiDisplayContextOnTablet(context);

        mLeftTabSupplier = new ObservableSupplierImpl<>();
        mRightTabSupplier = new ObservableSupplierImpl<>();

        if (mMoveToolbar) {
            mLeftToolbarOverlay =
                    new TopToolbarOverlayCoordinator(
                            getContext(),
                            layoutManager,
                            (info) -> {},
                            mLeftTabSupplier,
                            mBrowserControlsStateProvider,
                            () -> mRenderHost.getResourceManager(),
                            topUiColorProvider,
                            LayoutType.TOOLBAR_SWIPE,
                            true);
            mLeftToolbarOverlay.setManualVisibility(true);
            layoutManager.addSceneOverlay(mLeftToolbarOverlay);

            mRightToolbarOverlay =
                    new TopToolbarOverlayCoordinator(
                            getContext(),
                            layoutManager,
                            (info) -> {},
                            mRightTabSupplier,
                            mBrowserControlsStateProvider,
                            () -> mRenderHost.getResourceManager(),
                            topUiColorProvider,
                            LayoutType.TOOLBAR_SWIPE,
                            true);
            mRightToolbarOverlay.setManualVisibility(true);
            layoutManager.addSceneOverlay(mRightToolbarOverlay);
        }
    }

    @Override
    protected void setTabContentManager(TabContentManager manager) {
        super.setTabContentManager(manager);
        mSceneLayer = new ToolbarSwipeSceneLayer(getContext(), manager);
    }

    @Override
    public @ViewportMode int getViewportMode() {
        // This seems counter-intuitive, but if the toolbar moves the android view is not showing.
        // That means the compositor has to draw it and therefore needs the fullscreen viewport.
        // Likewise, when the android view is showing, the compositor controls do not draw and the
        // content needs to pretend it does to draw correctly.
        // TODO(mdjones): Remove toolbar_impact_height from tab_layer.cc so this makes more sense.
        return mMoveToolbar
                ? ViewportMode.ALWAYS_FULLSCREEN
                : ViewportMode.ALWAYS_SHOWING_BROWSER_CONTROLS;
    }

    @Override
    public boolean forceHideBrowserControlsAndroidView() {
        // If the toolbar moves, the android browser controls need to be hidden.
        return super.forceHideBrowserControlsAndroidView() || mMoveToolbar;
    }

    @Override
    public void doneHiding() {
        TabModelUtils.selectTabById(mTabModelSelector, mNextTabId, TabSelectionType.FROM_USER);
        super.doneHiding();
    }

    @Override
    public void show(long time, boolean animate) {
        super.show(time, animate);
        init();
        mNextTabId = Tab.INVALID_TAB_ID;
        if (mTabModelSelector == null) return;
        Tab tab = mTabModelSelector.getCurrentTab();
        if (tab != null && tab.isNativePage()) mTabContentManager.cacheTabThumbnail(tab);

        TabModel model = mTabModelSelector.getCurrentModel();
        if (model == null) return;

        int fromTabId = mTabModelSelector.getCurrentTabId();
        if (fromTabId == TabModel.INVALID_TAB_INDEX) return;
        mFromTab = createLayoutTab(fromTabId, model.isIncognito());
        prepareLayoutTabForSwipe(mFromTab, false);

        if (mIsSwitchToStaticTab) {
            switchToTab(mToTabId, mFromTabId);

            // Close the previous tab if the previous tab is a NTP.
            // TODO(crbug.com/40233431): Move this piece logic to use a LayoutStateObserver instead
            // - let the caller of the LayoutManager#switchToTab observe the LayoutState and close
            // the ntp tab in the #doneShowing event.
            Tab lastTab = mTabModelSelector.getTabById(mFromTabId);
            if (UrlUtilities.isNtpUrl(lastTab.getUrl())
                    && !lastTab.canGoBack()
                    && !lastTab.canGoForward()) {
                mTabModelSelector
                        .getModel(lastTab.isIncognito())
                        .closeTabs(
                                TabClosureParams.closeTab(lastTab)
                                        .recommendedNextTab(tab)
                                        .allowUndo(false)
                                        .build());
            }

            mIsSwitchToStaticTab = false;
            mToTabId = TabModel.INVALID_TAB_INDEX;
            mFromTabId = TabModel.INVALID_TAB_INDEX;
        }
    }

    public void swipeStarted(long time, @ScrollDirection int direction, float x, float y) {
        if (mTabModelSelector == null || mToTab != null || direction == ScrollDirection.DOWN) {
            return;
        }

        boolean dragFromLeftEdge = direction == ScrollDirection.RIGHT;
        // Finish off any other animations.
        forceAnimationToFinish();

        // Determine which tabs we're showing.
        TabModel model = mTabModelSelector.getCurrentModel();
        if (model == null) return;
        int fromIndex = model.index();
        if (fromIndex == TabModel.INVALID_TAB_INDEX) return;

        // On RTL, edge-dragging to the left is the next tab.
        int toIndex =
                (LocalizationUtils.isLayoutRtl() ^ dragFromLeftEdge)
                        ? fromIndex - 1
                        : fromIndex + 1;

        prepareSwipeTabAnimation(direction, fromIndex, toIndex);
    }

    /**
     * Prepare the tabs sliding animations. This method need to be called before
     * {@link #doTabSwitchAnimation(int, float, float, long)}.
     * @param direction The direction of the slide.
     * @param fromIndex The index of the tab which will be switched from.
     * @param toIndex The index of the tab which will be switched to.
     */
    private void prepareSwipeTabAnimation(
            @ScrollDirection int direction, int fromIndex, int toIndex) {
        boolean dragFromLeftEdge = direction == ScrollDirection.RIGHT;

        int leftIndex = dragFromLeftEdge ? toIndex : fromIndex;
        int rightIndex = !dragFromLeftEdge ? toIndex : fromIndex;
        int leftTabId = Tab.INVALID_TAB_ID;
        int rightTabId = Tab.INVALID_TAB_ID;

        mLeftTabSupplier.set(null);
        mRightTabSupplier.set(null);

        TabModel model = mTabModelSelector.getCurrentModel();
        if (0 <= leftIndex && leftIndex < model.getCount()) {
            leftTabId = model.getTabAt(leftIndex).getId();
            mLeftTab = createLayoutTab(leftTabId, model.isIncognito());
            prepareLayoutTabForSwipe(mLeftTab, leftIndex != fromIndex);
            mLeftTabSupplier.set(model.getTabAt(leftIndex));
        }
        if (0 <= rightIndex && rightIndex < model.getCount()) {
            rightTabId = model.getTabAt(rightIndex).getId();
            mRightTab = createLayoutTab(rightTabId, model.isIncognito());
            prepareLayoutTabForSwipe(mRightTab, rightIndex != fromIndex);
            mRightTabSupplier.set(model.getTabAt(rightIndex));
        }
        // Prioritize toTabId because fromTabId likely has a live layer.
        int fromTabId = dragFromLeftEdge ? rightTabId : leftTabId;
        int toTabId = !dragFromLeftEdge ? rightTabId : leftTabId;
        List<Integer> visibleTabs = new ArrayList<Integer>();
        if (toTabId != Tab.INVALID_TAB_ID) visibleTabs.add(toTabId);
        if (fromTabId != Tab.INVALID_TAB_ID) visibleTabs.add(fromTabId);
        updateCacheVisibleIds(visibleTabs);

        mToTab = null;

        // Reset the tab offsets.
        mOffsetStart = dragFromLeftEdge ? 0 : getWidth();
        mOffset = 0;
        mOffsetTarget = 0;

        if (mLeftTab != null && mRightTab != null) {
            mLayoutTabs = new LayoutTab[] {mLeftTab, mRightTab};
        } else if (mLeftTab != null) {
            mLayoutTabs = new LayoutTab[] {mLeftTab};
        } else if (mRightTab != null) {
            mLayoutTabs = new LayoutTab[] {mRightTab};
        } else {
            mLayoutTabs = null;
        }

        requestUpdate();
    }

    private void prepareLayoutTabForSwipe(LayoutTab layoutTab, boolean anonymizeToolbar) {
        assert layoutTab != null;
        if (layoutTab.shouldStall()) layoutTab.setSaturation(0.0f);
        float heightDp = layoutTab.getOriginalContentHeight();
        layoutTab.setClipSize(layoutTab.getOriginalContentWidth(), heightDp);
        layoutTab.setScale(1.f);
        layoutTab.setBorderScale(1.f);
        layoutTab.setDecorationAlpha(0.f);
        layoutTab.setY(0.f);
        layoutTab.setShowToolbar(mMoveToolbar);
        layoutTab.setAnonymizeToolbar(anonymizeToolbar && ANONYMIZE_NON_FOCUSED_TAB);
    }

    public void swipeUpdated(long time, float x, float y, float dx, float dy, float tx, float ty) {
        mOffsetTarget = MathUtils.clamp(mOffsetStart + tx, 0, getWidth()) - mOffsetStart;
        requestUpdate();
    }

    public void swipeFlingOccurred(
            long time, float x, float y, float tx, float ty, float vx, float vy) {
        // Use the velocity to add on final step which simulate a fling.
        final float kickRangeX = getWidth() * FLING_MAX_CONTRIBUTION;
        final float kickRangeY = getHeight() * FLING_MAX_CONTRIBUTION;
        final float kickX = MathUtils.clamp(vx * FLING_TIME_STEP, -kickRangeX, kickRangeX);
        final float kickY = MathUtils.clamp(vy * FLING_TIME_STEP, -kickRangeY, kickRangeY);
        swipeUpdated(time, x, y, 0, 0, tx + kickX, ty + kickY);
    }

    public void swipeFinished(long time) {
        if (mFromTab == null || mTabModelSelector == null) return;

        // Figures out the tab to snap to and how to animate to it.
        float commitDistance = Math.min(mCommitDistanceFromEdge, getWidth() / 3);
        float offsetTo = 0;
        mToTab = mFromTab;
        if (mOffsetTarget > commitDistance && mLeftTab != null) {
            mToTab = mLeftTab;
            offsetTo += getWidth();
        } else if (mOffsetTarget < -commitDistance && mRightTab != null) {
            mToTab = mRightTab;
            offsetTo -= getWidth();
        }

        if (mToTab != mFromTab) {
            RecordUserAction.record("MobileSideSwipeFinished");
        }

        mNextTabId = mToTab.getId();
        startHiding();

        float start = mOffsetTarget;
        float end = offsetTo;
        long duration = (long) (ANIMATION_SPEED_SCREEN_MS * Math.abs(start - end) / getWidth());
        doTabSwitchAnimation(mToTab.getId(), start, end, duration);
    }

    /**
     * Perform the tabs sliding animations. {@link #prepareSwipeTabAnimation(int, int, int)} need to
     * be called before calling this method.
     * @param tabId The id of the tab which will be switched to.
     * @param start The start point of X coordinate for the animation.
     * @param end The end point of X coordinate for the animation.
     * @param duration The animation duration in millisecond.
     */
    private void doTabSwitchAnimation(int tabId, float start, float end, long duration) {
        // Animate gracefully the end of the swiping effect.
        forceAnimationToFinish();

        if (duration <= 0) return;

        CompositorAnimator offsetAnimation =
                CompositorAnimator.ofFloat(getAnimationHandler(), start, end, duration, null);
        offsetAnimation.addUpdateListener(
                animator -> {
                    mOffset = animator.getAnimatedValue();
                    mOffsetTarget = mOffset;
                });
        offsetAnimation.start();
    }

    public void swipeCancelled(long time) {
        swipeFinished(time);
    }

    @Override
    protected void updateLayout(long time, long dt) {
        super.updateLayout(time, dt);

        if (mFromTab == null) return;
        // In case the draw function get called before swipeStarted()
        if (mLeftTab == null && mRightTab == null) mRightTab = mFromTab;

        mOffset = smoothInput(mOffset, mOffsetTarget);
        boolean needUpdate = Math.abs(mOffset - mOffsetTarget) >= 0.1f;

        float rightX = 0.0f;
        float leftX = 0.0f;

        final boolean doEdge = mLeftTab != null ^ mRightTab != null;

        if (doEdge) {
            float progress = mOffset / getWidth();
            float direction = Math.signum(progress);
            float smoothedProgress =
                    Interpolators.DECELERATE_INTERPOLATOR.getInterpolation(Math.abs(progress));

            float maxSlide = getWidth() / 5.f;
            rightX = direction * smoothedProgress * maxSlide;
            leftX = rightX;
        } else {
            float progress = mOffset / getWidth();
            progress += mOffsetStart == 0.0f ? 0.0f : 1.0f;
            progress = MathUtils.clamp(progress, 0.0f, 1.0f);

            assert mLeftTab != null;
            assert mRightTab != null;
            rightX = MathUtils.interpolate(0.0f, getWidth() + mSpaceBetweenTabs, progress);
            // The left tab must be aligned on the right if the image is smaller than the screen.
            leftX =
                    rightX
                            - mSpaceBetweenTabs
                            - Math.min(getWidth(), mLeftTab.getOriginalContentWidth());
            // Compute final x post scale and ensure the tab's center point never passes the
            // center point of the screen.
            float screenCenterX = getWidth() / 2;
            rightX = Math.max(screenCenterX - mRightTab.getFinalContentWidth() / 2, rightX);
            leftX = Math.min(screenCenterX - mLeftTab.getFinalContentWidth() / 2, leftX);
        }

        // TODO(mdjones): We shouldn't be using dp here, we should convert everything to px since
        //                that's what all layouts expect as input.
        final float dpToPx = getContext().getResources().getDisplayMetrics().density;

        if (mLeftTab != null) {
            if (mLeftToolbarOverlay != null) {
                mLeftToolbarOverlay.setManualVisibility(true);
                mLeftToolbarOverlay.setAnonymize(mLeftTab != mFromTab);
                mLeftToolbarOverlay.setXOffset(leftX * dpToPx);
            }
            mLeftTab.setX(leftX);
            mLeftTab.setY(mBrowserControlsStateProvider.getContentOffset() / dpToPx);
            needUpdate = updateSnap(dt, mLeftTab) || needUpdate;
        } else if (mLeftToolbarOverlay != null) {
            mLeftToolbarOverlay.setManualVisibility(false);
        }

        if (mRightTab != null) {
            if (mRightToolbarOverlay != null) {
                mRightToolbarOverlay.setManualVisibility(true);
                mRightToolbarOverlay.setAnonymize(mRightTab != mFromTab);
                mRightToolbarOverlay.setXOffset(rightX * dpToPx);
            }
            mRightTab.setX(rightX);
            mRightTab.setY(mBrowserControlsStateProvider.getContentOffset() / dpToPx);
            needUpdate = updateSnap(dt, mRightTab) || needUpdate;
        } else if (mRightToolbarOverlay != null) {
            mRightToolbarOverlay.setManualVisibility(false);
        }

        if (needUpdate) requestUpdate();
    }

    /**
     * Smoothes input signal. The definition of the input is lower than the
     * pixel density of the screen so we need to smooth the input to give the illusion of smooth
     * animation on screen from chunky inputs.
     * The combination of 30 pixels and 0.8f ensures that the output is not more than 6 pixels away
     * from the target.
     * TODO(dtrainor): This has nothing to do with time, just draw rate.
     *       Is this okay or do we want to have the interpolation based on the time elapsed?
     * @param current The current value of the signal.
     * @param input The raw input value.
     * @return The smoothed signal.
     */
    private float smoothInput(float current, float input) {
        current = MathUtils.clamp(current, input - 30, input + 30);
        return MathUtils.interpolate(current, input, 0.8f);
    }

    private void init() {
        mLayoutTabs = null;
        mFromTab = null;
        mLeftTab = null;
        mRightTab = null;
        mToTab = null;
        mOffsetStart = 0;
        mOffset = 0;
        mOffsetTarget = 0;
    }

    @Override
    protected EventFilter getEventFilter() {
        return mBlackHoleEventFilter;
    }

    @Override
    protected SceneLayer getSceneLayer() {
        return mSceneLayer;
    }

    @Override
    protected void updateSceneLayer(
            RectF viewport,
            RectF contentViewport,
            TabContentManager tabContentManager,
            ResourceManager resourceManager,
            BrowserControlsStateProvider browserControls) {
        super.updateSceneLayer(
                viewport, contentViewport, tabContentManager, resourceManager, browserControls);

        if (mSceneLayer != null) {
            int background_color = getBackgroundColor();

            mSceneLayer.update(mLeftTab, true, background_color);
            mSceneLayer.update(mRightTab, false, background_color);
        }
    }

    /** Returns the background color of the scene layer. */
    private int getBackgroundColor() {
        if (mTabModelSelector != null && mTabModelSelector.isIncognitoSelected()) {
            return getContext().getColor(R.color.default_bg_color_dark);
        } else {
            return SemanticColorUtils.getDefaultBgColor(getContext());
        }
    }

    @Override
    public int getLayoutType() {
        return LayoutType.TOOLBAR_SWIPE;
    }

    /**
     * Perform the tabs sliding animations. If the new tab's index is smaller than the old one, new
     * tab slide in from left, and old one slide out to right, and vice versa.
     * @param toTabId The id of the next tab which will be switched to.
     * @param fromTabId The id of the previous tab which will be switched out.
     */
    public void switchToTab(int toTabId, int fromTabId) {
        int fromTabIndex =
                TabModelUtils.getTabIndexById(mTabModelSelector.getCurrentModel(), fromTabId);
        int toTabIndex =
                TabModelUtils.getTabIndexById(mTabModelSelector.getCurrentModel(), toTabId);
        prepareSwipeTabAnimation(
                fromTabIndex < toTabIndex ? ScrollDirection.LEFT : ScrollDirection.RIGHT,
                fromTabIndex,
                toTabIndex);

        mToTab = fromTabIndex < toTabIndex ? mRightTab : mLeftTab;
        float end = fromTabIndex < toTabIndex ? -getWidth() : getWidth();
        mNextTabId = toTabId;
        startHiding();
        doTabSwitchAnimation(toTabId, 0f, end, SWITCH_TO_TAB_DURATION_MS);
    }

    /**
     * Set it's switching to a tab. With |mIsSwitchToStaticTab| as true, we need to call
     * switchToTab() after this layout is shown. What is set here only applies to the next showing
     * of the layout, after that it is reset.
     * @param toTabId The id of the next tab which will be switched to.
     * @param fromTabId The id of the previous tab which will be switched out.
     */
    public void setSwitchToTab(int toTabId, int fromTabId) {
        mIsSwitchToStaticTab = true;
        mToTabId = toTabId;
        mFromTabId = fromTabId;
    }
}