chromium/chrome/android/features/tab_ui/java/src/org/chromium/chrome/browser/tasks/tab_management/TabGridDialogView.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.tasks.tab_management;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.content.res.ColorStateList;
import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.view.accessibility.AccessibilityEvent;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.PopupWindow;
import android.widget.RelativeLayout;
import android.widget.TextView;

import androidx.annotation.ColorInt;
import androidx.annotation.IntDef;
import androidx.annotation.VisibleForTesting;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.drawable.DrawableCompat;
import androidx.core.widget.ImageViewCompat;

import org.chromium.base.Callback;
import org.chromium.base.MathUtils;
import org.chromium.base.ResettersForTesting;
import org.chromium.chrome.browser.flags.ChromeFeatureList;
import org.chromium.chrome.browser.tab_ui.TabThumbnailView;
import org.chromium.chrome.tab_ui.R;
import org.chromium.components.browser_ui.widget.scrim.ScrimCoordinator;
import org.chromium.components.browser_ui.widget.scrim.ScrimProperties;
import org.chromium.ui.KeyboardVisibilityDelegate;
import org.chromium.ui.base.DeviceFormFactor;
import org.chromium.ui.base.ViewUtils;
import org.chromium.ui.interpolators.Interpolators;
import org.chromium.ui.modelutil.PropertyModel;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.HashMap;
import java.util.Map;

/** Parent for TabGridDialog component. */
public class TabGridDialogView extends FrameLayout {
    private static final int DIALOG_ANIMATION_DURATION = 400;
    private static final int DIALOG_UNGROUP_ALPHA_ANIMATION_DURATION = 200;
    private static final int DIALOG_ALPHA_ANIMATION_DURATION = DIALOG_ANIMATION_DURATION >> 1;
    private static final int CARD_FADE_ANIMATION_DURATION = 50;
    private static final int Y_TRANSLATE_DURATION_MS = 300;
    private static final int SCRIM_FADE_DURATION_MS = 350;

    private static Callback<RectF> sSourceRectCallbackForTesting;

    @IntDef({UngroupBarStatus.SHOW, UngroupBarStatus.HIDE, UngroupBarStatus.HOVERED})
    @Retention(RetentionPolicy.SOURCE)
    public @interface UngroupBarStatus {
        int SHOW = 0;
        int HIDE = 1;
        int HOVERED = 2;
        int NUM_ENTRIES = 3;
    }

    /** An interface to listen to visibility related changes on this {@link TabGridDialogView}. */
    interface VisibilityListener {
        /** Called when the animation to hide the tab grid dialog is finished. */
        void finishedHidingDialogView();
    }

    private final Context mContext;
    private final float mTabGridCardPadding;
    private FrameLayout mAnimationClip;
    private FrameLayout mToolbarContainer;
    private FrameLayout mRecyclerViewContainer;
    private View mBackgroundFrame;
    private View mAnimationCardView;
    private View mItemView;
    private View mUngroupBar;
    private ViewGroup mSnackBarContainer;
    private ViewGroup mParent;
    private ImageView mHairline;
    private TextView mUngroupBarTextView;
    private RelativeLayout mDialogContainerView;
    private PropertyModel mScrimPropertyModel;
    private ScrimCoordinator mScrimCoordinator;
    private FrameLayout.LayoutParams mContainerParams;
    private ViewTreeObserver.OnGlobalLayoutListener mParentGlobalLayoutListener;
    private VisibilityListener mVisibilityListener;
    private Animator mCurrentDialogAnimator;
    private Animator mCurrentUngroupBarAnimator;
    private AnimatorSet mBasicFadeInAnimation;
    private AnimatorSet mBasicFadeOutAnimation;
    private ObjectAnimator mYTranslateAnimation;
    private ObjectAnimator mUngroupBarShow;
    private ObjectAnimator mUngroupBarHide;
    private AnimatorSet mShowDialogAnimation;
    private AnimatorSet mHideDialogAnimation;
    private AnimatorListenerAdapter mShowDialogAnimationListener;
    private AnimatorListenerAdapter mHideDialogAnimationListener;
    private Map<View, Integer> mAccessibilityImportanceMap = new HashMap<>();
    private int mSideMargin;
    private int mTopMargin;
    private int mOrientation;
    private int mParentHeight;
    private int mParentWidth;
    private int mBackgroundDrawableColor;
    private @UngroupBarStatus int mUngroupBarStatus = UngroupBarStatus.HIDE;
    private int mUngroupBarBackgroundColor;
    private int mUngroupBarHoveredBackgroundColor;
    @ColorInt private int mUngroupBarTextColor;
    @ColorInt private int mUngroupBarHoveredTextColor;
    private Integer mBindingToken;

    public TabGridDialogView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mContext = context;
        mTabGridCardPadding = TabUiThemeProvider.getTabGridCardMargin(mContext);
        mBackgroundDrawableColor =
                ContextCompat.getColor(mContext, R.color.tab_grid_dialog_background_color);

        mUngroupBarTextColor =
                TabUiThemeProvider.getTabGridDialogUngroupBarTextColor(mContext, false);
        mUngroupBarHoveredTextColor =
                TabUiThemeProvider.getTabGridDialogUngroupBarHoveredTextColor(mContext, false);

        mUngroupBarBackgroundColor =
                TabUiThemeProvider.getTabGridDialogUngroupBarBackgroundColor(mContext, false);
        mUngroupBarHoveredBackgroundColor =
                TabUiThemeProvider.getTabGridDialogUngroupBarHoveredBackgroundColor(
                        mContext, false);
    }

    void forceAnimationToFinish() {
        if (mCurrentDialogAnimator != null) {
            mCurrentDialogAnimator.end();
        }
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        if (event.getAction() == MotionEvent.ACTION_DOWN) {
            View v = findViewById(R.id.title);
            if (v != null && v.isFocused()) {
                Rect rect = new Rect();
                v.getGlobalVisibleRect(rect);
                if (!rect.contains((int) event.getRawX(), (int) event.getRawY())) {
                    v.clearFocus();
                }
            }
        }
        return super.dispatchTouchEvent(event);
    }

    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        mParent = (ViewGroup) getParent();
        mParentHeight = mParent.getHeight();
        mParentWidth = mParent.getWidth();
        mParentGlobalLayoutListener =
                () -> {
                    // Skip updating the parent view size caused by keyboard showing.
                    if (!KeyboardVisibilityDelegate.getInstance()
                            .isKeyboardShowing(mContext, this)) {
                        mParentWidth = mParent.getWidth();
                        mParentHeight = mParent.getHeight();
                        updateDialogWithOrientation(mOrientation);
                    }
                };
        mParent.getViewTreeObserver().addOnGlobalLayoutListener(mParentGlobalLayoutListener);
        updateDialogWithOrientation(mOrientation);
        setVisibility(GONE);
    }

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        if (mParent != null) {
            mParent.getViewTreeObserver().removeOnGlobalLayoutListener(mParentGlobalLayoutListener);
        }
    }

    @Override
    protected void onConfigurationChanged(Configuration newConfig) {
        updateDialogWithOrientation(newConfig.orientation);
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        mContainerParams =
                new FrameLayout.LayoutParams(
                        ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
        mDialogContainerView = findViewById(R.id.dialog_container_view);
        mDialogContainerView.setLayoutParams(mContainerParams);
        mToolbarContainer = findViewById(R.id.tab_grid_dialog_toolbar_container);
        mRecyclerViewContainer = findViewById(R.id.tab_grid_dialog_recycler_view_container);
        mUngroupBar = findViewById(R.id.dialog_ungroup_bar);
        mUngroupBarTextView = mUngroupBar.findViewById(R.id.dialog_ungroup_bar_text);
        mAnimationClip = findViewById(R.id.dialog_animation_clip);
        mBackgroundFrame = findViewById(R.id.dialog_frame);
        mBackgroundFrame.setLayoutParams(mContainerParams);
        mAnimationCardView = findViewById(R.id.dialog_animation_card_view);
        mSnackBarContainer = findViewById(R.id.dialog_snack_bar_container_view);
        mHairline = findViewById(R.id.tab_grid_dialog_hairline);
        updateDialogWithOrientation(mContext.getResources().getConfiguration().orientation);

        prepareAnimation();
    }

    private void prepareAnimation() {
        mShowDialogAnimation = new AnimatorSet();
        mHideDialogAnimation = new AnimatorSet();
        mBasicFadeInAnimation = new AnimatorSet();
        ObjectAnimator dialogFadeInAnimator =
                ObjectAnimator.ofFloat(mDialogContainerView, View.ALPHA, 0f, 1f);
        mBasicFadeInAnimation.play(dialogFadeInAnimator);
        mBasicFadeInAnimation.setInterpolator(Interpolators.EMPHASIZED);
        mBasicFadeInAnimation.setDuration(DIALOG_ANIMATION_DURATION);

        mBasicFadeOutAnimation = new AnimatorSet();
        ObjectAnimator dialogFadeOutAnimator =
                ObjectAnimator.ofFloat(mDialogContainerView, View.ALPHA, 1f, 0f);
        mBasicFadeOutAnimation.play(dialogFadeOutAnimator);
        mBasicFadeOutAnimation.setInterpolator(Interpolators.EMPHASIZED);
        mBasicFadeOutAnimation.setDuration(DIALOG_ANIMATION_DURATION);
        mBasicFadeOutAnimation.addListener(
                new AnimatorListenerAdapter() {
                    @Override
                    public void onAnimationEnd(Animator animation) {
                        updateItemViewAlpha();
                    }
                });

        final int screenHeightPx =
                ViewUtils.dpToPx(
                        getContext(),
                        getContext().getResources().getConfiguration().screenHeightDp);
        final float mDialogInitYPos = mDialogContainerView.getY();
        mYTranslateAnimation =
                ObjectAnimator.ofFloat(
                        mDialogContainerView, View.TRANSLATION_Y, mDialogInitYPos, screenHeightPx);
        mYTranslateAnimation.setInterpolator(Interpolators.EMPHASIZED_ACCELERATE);
        mYTranslateAnimation.setDuration(Y_TRANSLATE_DURATION_MS);
        mYTranslateAnimation.addListener(
                new AnimatorListenerAdapter() {
                    @Override
                    public void onAnimationEnd(Animator animation) {
                        updateItemViewAlpha();
                        mDialogContainerView.setY(mDialogInitYPos);
                    }
                });

        mShowDialogAnimationListener =
                new AnimatorListenerAdapter() {
                    @Override
                    public void onAnimationEnd(Animator animation) {
                        mCurrentDialogAnimator = null;
                        mDialogContainerView.requestFocus();
                        mDialogContainerView.sendAccessibilityEvent(
                                AccessibilityEvent.TYPE_VIEW_FOCUSED);
                        // TODO(crbug.com/40138401): Move clear/restore accessibility importance
                        // logic to ScrimView so that it can be shared by all components using
                        // ScrimView.
                        clearBackgroundViewAccessibilityImportance();
                    }
                };
        mHideDialogAnimationListener =
                new AnimatorListenerAdapter() {
                    @Override
                    public void onAnimationEnd(Animator animation) {
                        setVisibility(View.GONE);
                        mCurrentDialogAnimator = null;
                        mDialogContainerView.clearFocus();
                        restoreBackgroundViewAccessibilityImportance();
                        if (mVisibilityListener != null) {
                            mVisibilityListener.finishedHidingDialogView();
                        }
                    }
                };

        mUngroupBarShow = ObjectAnimator.ofFloat(mUngroupBar, View.ALPHA, 0f, 1f);
        mUngroupBarShow.setDuration(DIALOG_UNGROUP_ALPHA_ANIMATION_DURATION);
        mUngroupBarShow.setInterpolator(Interpolators.EMPHASIZED);
        mUngroupBarShow.addListener(
                new AnimatorListenerAdapter() {
                    @Override
                    public void onAnimationStart(Animator animation) {
                        if (mCurrentUngroupBarAnimator != null) {
                            mCurrentUngroupBarAnimator.end();
                        }
                        mCurrentUngroupBarAnimator = mUngroupBarShow;
                        mUngroupBar.setVisibility(View.VISIBLE);
                        mUngroupBar.setAlpha(0f);
                    }

                    @Override
                    public void onAnimationEnd(Animator animation) {
                        mCurrentUngroupBarAnimator = null;
                    }
                });

        mUngroupBarHide = ObjectAnimator.ofFloat(mUngroupBar, View.ALPHA, 1f, 0f);
        mUngroupBarHide.setDuration(DIALOG_UNGROUP_ALPHA_ANIMATION_DURATION);
        mUngroupBarHide.setInterpolator(Interpolators.EMPHASIZED);
        mUngroupBarHide.addListener(
                new AnimatorListenerAdapter() {
                    @Override
                    public void onAnimationStart(Animator animation) {
                        if (mCurrentUngroupBarAnimator != null) {
                            mCurrentUngroupBarAnimator.end();
                        }
                        mCurrentUngroupBarAnimator = mUngroupBarHide;
                    }

                    @Override
                    public void onAnimationEnd(Animator animation) {
                        mUngroupBar.setVisibility(View.INVISIBLE);
                        mCurrentUngroupBarAnimator = null;
                    }
                });
    }

    private void updateItemViewAlpha() {
        // Restore the original card.
        if (mItemView == null) return;
        mItemView.setAlpha(1f);
    }

    private void clearBackgroundViewAccessibilityImportance() {
        assert mAccessibilityImportanceMap.size() == 0;

        ViewGroup parent = (ViewGroup) getParent();
        for (int i = 0; i < parent.getChildCount(); i++) {
            View view = parent.getChildAt(i);
            if (view == TabGridDialogView.this) {
                continue;
            }
            mAccessibilityImportanceMap.put(view, view.getImportantForAccessibility());
            view.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
        }
    }

    private void restoreBackgroundViewAccessibilityImportance() {
        ViewGroup parent = (ViewGroup) getParent();
        for (int i = 0; i < parent.getChildCount(); i++) {
            View view = parent.getChildAt(i);
            if (view == TabGridDialogView.this) {
                continue;
            }
            if (!ChromeFeatureList.isEnabled(ChromeFeatureList.DATA_SHARING)) {
                assert mAccessibilityImportanceMap.containsKey(view);
            }
            Integer importance = mAccessibilityImportanceMap.get(view);
            view.setImportantForAccessibility(
                    importance == null ? IMPORTANT_FOR_ACCESSIBILITY_AUTO : importance);
        }
        mAccessibilityImportanceMap.clear();
    }

    void setVisibilityListener(VisibilityListener visibilityListener) {
        mVisibilityListener = visibilityListener;
    }

    void setupDialogAnimation(View sourceView) {
        // In case where user jumps to a new page from dialog, clean existing animations in
        // mHideDialogAnimation and play basic fade out instead of zooming back to corresponding tab
        // grid card.
        if (sourceView == null) {
            mShowDialogAnimation = new AnimatorSet();
            mShowDialogAnimation.play(mBasicFadeInAnimation);
            mShowDialogAnimation.removeAllListeners();
            mShowDialogAnimation.addListener(mShowDialogAnimationListener);

            mHideDialogAnimation = new AnimatorSet();
            Animator hideAnimator =
                    DeviceFormFactor.isNonMultiDisplayContextOnTablet(getContext())
                            ? mYTranslateAnimation
                            : mBasicFadeOutAnimation;
            mHideDialogAnimation.play(hideAnimator);
            mHideDialogAnimation.removeAllListeners();
            mHideDialogAnimation.addListener(mHideDialogAnimationListener);

            updateAnimationCardView(null);
            return;
        }

        mItemView = sourceView;
        Rect rect = new Rect();
        mItemView.getGlobalVisibleRect(rect);
        // Offset for status bar (top) and nav bar when landscape (left).
        Rect dialogParentRect = new Rect();
        mParent.getGlobalVisibleRect(dialogParentRect);
        rect.offset(-dialogParentRect.left, -dialogParentRect.top);
        // Setup a stand-in animation card that looks the same as the original tab grid card for
        // animation.
        updateAnimationCardView(mItemView);

        // Calculate dialog size.
        int dialogHeight = mParentHeight - 2 * mTopMargin;
        int dialogWidth = mParentWidth - 2 * mSideMargin;

        // Calculate a clip mask to avoid any source view that is not fully visible from drawing
        // over other UI.
        Rect itemViewParentRect = new Rect();
        ((View) mItemView.getParent()).getGlobalVisibleRect(itemViewParentRect);
        int clipTop = itemViewParentRect.top - dialogParentRect.top;
        FrameLayout.LayoutParams params =
                (FrameLayout.LayoutParams) mAnimationClip.getLayoutParams();
        params.setMargins(0, clipTop, 0, 0);
        mAnimationClip.setLayoutParams(params);

        // Because the mAnimationCardView is offset by clip top we need to compensate in the
        // opposite direction for its animation only.
        float yClipCompensation = clipTop;

        // If the item view is clipped by being offsceen the height of the visible rect and the
        // item view will differ. This is the `yClip`. If this amount is less than the card's
        // padding we need to still apply the part of the padding that is visible otherwise we
        // can ignore the padding entirely.
        int yClip = mItemView.getHeight() - rect.height();

        // The dialog and mBackgroundFrame are not clipped by the mAnimationClip (the math would be
        // broken due to those object relying on MATCH_PARENT for dimensions). So we need to use the
        // clipped height of the mItemView. Here we apply one side of the mTabGridCardPadding. The
        // other side might be clipped.
        float clippedSourceHeight = rect.height() - mTabGridCardPadding;

        // Apply the remaining tab grid card padding if the `yClip` doesn't result in it being
        // entirely occluded.
        boolean isYClipLessThanPadding = yClip < mTabGridCardPadding;
        if (isYClipLessThanPadding) {
            clippedSourceHeight += yClip - mTabGridCardPadding;
        }

        // Calculate position and size info about the original tab grid card.
        float sourceTop = rect.top;
        float sourceLeft = rect.left + mTabGridCardPadding;
        if (rect.top == clipTop) {
            // If the clipping is off the "top" of the screen i.e. the rect is touching the clip
            // bound. Then we need to add clip compensation when animating the card by starting it
            // in its original position. However, if the clip is less than padding we also need to
            // take whatever top padding is visible into account.
            if (isYClipLessThanPadding) {
                float clipDelta = mTabGridCardPadding - yClip;
                sourceTop += clipDelta;
                yClipCompensation += clipDelta + yClip;
            } else {
                yClipCompensation += yClip;
            }
        } else {
            // If the clipping either doesn't exist or is off the bottom of the screen we can assume
            // the clipping is to the bottom of the card and include the full padding.
            sourceTop += mTabGridCardPadding;
            yClipCompensation += mTabGridCardPadding;
        }
        float unclippedSourceHeight = mItemView.getHeight() - 2 * mTabGridCardPadding;
        float sourceWidth = rect.width() - 2 * mTabGridCardPadding;
        if (sSourceRectCallbackForTesting != null) {
            sSourceRectCallbackForTesting.onResult(
                    new RectF(
                            sourceLeft,
                            sourceTop,
                            sourceLeft + sourceWidth,
                            sourceTop + unclippedSourceHeight));
        }

        // Setup animation position info and scale ratio of the background frame.
        float frameInitYPosition =
                -(dialogHeight / 2 + mTopMargin - clippedSourceHeight / 2 - sourceTop);
        float frameInitXPosition = -(dialogWidth / 2 + mSideMargin - sourceWidth / 2 - sourceLeft);
        float frameScaleY = clippedSourceHeight / dialogHeight;
        float frameScaleX = sourceWidth / dialogWidth;

        // Setup scale ratio of card and dialog. Height and Width for both dialog and card scale at
        // the same rate during scaling animations.
        float cardScale = (float) dialogWidth / rect.width();
        float dialogScale = frameScaleX;

        // Setup animation position info of the animation card.
        float cardScaledYPosition =
                mTopMargin + ((cardScale - 1f) / 2) * unclippedSourceHeight - clipTop;
        float cardScaledXPosition = mSideMargin + ((cardScale - 1f) / 2) * sourceWidth;
        float cardInitYPosition = sourceTop - yClipCompensation;
        float cardInitXPosition = sourceLeft - mTabGridCardPadding;

        // Setup animation position info of the dialog.
        float dialogInitYPosition =
                frameInitYPosition - (clippedSourceHeight - (dialogHeight * dialogScale)) / 2f;
        float dialogInitXPosition = frameInitXPosition;

        // In the first half of the dialog showing animation, the animation card scales up and moves
        // towards where the dialog should be.
        final ObjectAnimator cardZoomOutMoveYAnimator =
                ObjectAnimator.ofFloat(
                        mAnimationCardView,
                        View.TRANSLATION_Y,
                        cardInitYPosition,
                        cardScaledYPosition);
        final ObjectAnimator cardZoomOutMoveXAnimator =
                ObjectAnimator.ofFloat(
                        mAnimationCardView,
                        View.TRANSLATION_X,
                        cardInitXPosition,
                        cardScaledXPosition);
        final ObjectAnimator cardZoomOutScaleXAnimator =
                ObjectAnimator.ofFloat(mAnimationCardView, View.SCALE_X, 1f, cardScale);
        final ObjectAnimator cardZoomOutScaleYAnimator =
                ObjectAnimator.ofFloat(mAnimationCardView, View.SCALE_Y, 1f, cardScale);

        AnimatorSet cardZoomOutAnimatorSet = new AnimatorSet();
        cardZoomOutAnimatorSet.setDuration(DIALOG_ANIMATION_DURATION);
        cardZoomOutAnimatorSet.setInterpolator(Interpolators.EMPHASIZED);
        cardZoomOutAnimatorSet
                .play(cardZoomOutMoveYAnimator)
                .with(cardZoomOutMoveXAnimator)
                .with(cardZoomOutScaleYAnimator)
                .with(cardZoomOutScaleXAnimator);

        // In the first half of the dialog showing animation, the animation card fades out as it
        // moves and scales up.
        final ObjectAnimator cardZoomOutAlphaAnimator =
                ObjectAnimator.ofFloat(mAnimationCardView, View.ALPHA, 1f, 0f);
        cardZoomOutAlphaAnimator.setDuration(DIALOG_ALPHA_ANIMATION_DURATION);
        cardZoomOutAlphaAnimator.setInterpolator(Interpolators.EMPHASIZED);

        // In the second half of the dialog showing animation, the dialog zooms out from where the
        // card stops at the end of the first half and moves towards where the dialog should be.
        final ObjectAnimator dialogZoomOutMoveYAnimator =
                ObjectAnimator.ofFloat(
                        mDialogContainerView, View.TRANSLATION_Y, dialogInitYPosition, 0f);
        final ObjectAnimator dialogZoomOutMoveXAnimator =
                ObjectAnimator.ofFloat(
                        mDialogContainerView, View.TRANSLATION_X, dialogInitXPosition, 0f);
        final ObjectAnimator dialogZoomOutScaleYAnimator =
                ObjectAnimator.ofFloat(mDialogContainerView, View.SCALE_Y, dialogScale, 1f);
        final ObjectAnimator dialogZoomOutScaleXAnimator =
                ObjectAnimator.ofFloat(mDialogContainerView, View.SCALE_X, dialogScale, 1f);

        AnimatorSet dialogZoomOutAnimatorSet = new AnimatorSet();
        dialogZoomOutAnimatorSet.setDuration(DIALOG_ANIMATION_DURATION);
        dialogZoomOutAnimatorSet.setInterpolator(Interpolators.EMPHASIZED);
        dialogZoomOutAnimatorSet
                .play(dialogZoomOutMoveYAnimator)
                .with(dialogZoomOutMoveXAnimator)
                .with(dialogZoomOutScaleYAnimator)
                .with(dialogZoomOutScaleXAnimator);

        // In the second half of the dialog showing animation, the dialog fades in while it moves
        // and scales up.
        final ObjectAnimator dialogZoomOutAlphaAnimator =
                ObjectAnimator.ofFloat(mDialogContainerView, View.ALPHA, 0f, 1f);
        dialogZoomOutAlphaAnimator.setDuration(DIALOG_ALPHA_ANIMATION_DURATION);
        dialogZoomOutAlphaAnimator.setStartDelay(DIALOG_ALPHA_ANIMATION_DURATION);
        dialogZoomOutAlphaAnimator.setInterpolator(Interpolators.EMPHASIZED);

        // During the whole dialog showing animation, the frame background scales up and moves so
        // that it looks like the card zooms out and becomes the dialog.
        final ObjectAnimator frameZoomOutMoveYAnimator =
                ObjectAnimator.ofFloat(
                        mBackgroundFrame, View.TRANSLATION_Y, frameInitYPosition, 0f);
        final ObjectAnimator frameZoomOutMoveXAnimator =
                ObjectAnimator.ofFloat(
                        mBackgroundFrame, View.TRANSLATION_X, frameInitXPosition, 0f);
        final ObjectAnimator frameZoomOutScaleYAnimator =
                ObjectAnimator.ofFloat(mBackgroundFrame, View.SCALE_Y, frameScaleY, 1f);
        final ObjectAnimator frameZoomOutScaleXAnimator =
                ObjectAnimator.ofFloat(mBackgroundFrame, View.SCALE_X, frameScaleX, 1f);

        AnimatorSet frameZoomOutAnimatorSet = new AnimatorSet();
        frameZoomOutAnimatorSet.setDuration(DIALOG_ANIMATION_DURATION);
        frameZoomOutAnimatorSet.setInterpolator(Interpolators.EMPHASIZED);
        frameZoomOutAnimatorSet
                .play(frameZoomOutMoveYAnimator)
                .with(frameZoomOutMoveXAnimator)
                .with(frameZoomOutScaleYAnimator)
                .with(frameZoomOutScaleXAnimator);

        // After the dialog showing animation starts, the original card in grid tab switcher fades
        // out.
        final ObjectAnimator tabFadeOutAnimator =
                ObjectAnimator.ofFloat(mItemView, View.ALPHA, 1f, 0f);
        tabFadeOutAnimator.setDuration(CARD_FADE_ANIMATION_DURATION);

        dialogZoomOutAnimatorSet.addListener(
                new AnimatorListenerAdapter() {
                    @Override
                    public void onAnimationStart(Animator animation) {
                        // At the beginning of the first half of the showing animation, the white
                        // frame and the animation card should be above the the dialog view, and
                        // their alpha should be set to 1.
                        mBackgroundFrame.bringToFront();
                        mAnimationClip.bringToFront();
                        mAnimationCardView.bringToFront();
                        mDialogContainerView.setAlpha(0f);
                        mBackgroundFrame.setAlpha(1f);
                        mAnimationCardView.setAlpha(1f);
                    }

                    @Override
                    public void onAnimationEnd(Animator animation) {
                        // At the end of the showing animation, reset the alpha of animation related
                        // views to 0.
                        mBackgroundFrame.setAlpha(0f);
                        mAnimationCardView.setAlpha(0f);
                    }
                });

        dialogZoomOutAlphaAnimator.addListener(
                new AnimatorListenerAdapter() {
                    @Override
                    public void onAnimationStart(Animator animation) {
                        // At the beginning of the second half of the showing animation, the dialog
                        // should be above the white frame and the animation card.
                        mDialogContainerView.bringToFront();
                    }
                });

        // Setup the dialog showing animation.
        mShowDialogAnimation = new AnimatorSet();
        mShowDialogAnimation
                .play(cardZoomOutAnimatorSet)
                .with(cardZoomOutAlphaAnimator)
                .with(frameZoomOutAnimatorSet)
                .with(dialogZoomOutAnimatorSet)
                .with(dialogZoomOutAlphaAnimator)
                .with(tabFadeOutAnimator);
        mShowDialogAnimation.addListener(mShowDialogAnimationListener);

        // In the first half of the dialog hiding animation, the dialog scales down and moves
        // towards where the tab grid card should be.
        final ObjectAnimator dialogZoomInMoveYAnimator =
                ObjectAnimator.ofFloat(
                        mDialogContainerView, View.TRANSLATION_Y, 0f, dialogInitYPosition);
        final ObjectAnimator dialogZoomInMoveXAnimator =
                ObjectAnimator.ofFloat(
                        mDialogContainerView, View.TRANSLATION_X, 0f, dialogInitXPosition);
        final ObjectAnimator dialogZoomInScaleYAnimator =
                ObjectAnimator.ofFloat(mDialogContainerView, View.SCALE_Y, 1f, dialogScale);
        final ObjectAnimator dialogZoomInScaleXAnimator =
                ObjectAnimator.ofFloat(mDialogContainerView, View.SCALE_X, 1f, dialogScale);

        AnimatorSet dialogZoomInAnimatorSet = new AnimatorSet();
        dialogZoomInAnimatorSet
                .play(dialogZoomInMoveYAnimator)
                .with(dialogZoomInMoveXAnimator)
                .with(dialogZoomInScaleYAnimator)
                .with(dialogZoomInScaleXAnimator);
        dialogZoomInAnimatorSet.setDuration(DIALOG_ANIMATION_DURATION);
        dialogZoomInAnimatorSet.setInterpolator(Interpolators.EMPHASIZED);

        dialogZoomInAnimatorSet.addListener(
                new AnimatorListenerAdapter() {
                    @Override
                    public void onAnimationEnd(Animator animation) {
                        mDialogContainerView.setTranslationX(0f);
                        mDialogContainerView.setTranslationY(0f);
                        mDialogContainerView.setScaleX(1f);
                        mDialogContainerView.setScaleY(1f);
                    }
                });

        // In the first half of the dialog hiding animation, the dialog fades out while it moves and
        // scales down.
        final ObjectAnimator dialogZoomInAlphaAnimator =
                ObjectAnimator.ofFloat(mDialogContainerView, View.ALPHA, 1f, 0f);
        dialogZoomInAlphaAnimator.setDuration(DIALOG_ALPHA_ANIMATION_DURATION);
        dialogZoomInAlphaAnimator.setInterpolator(Interpolators.EMPHASIZED);

        // In the second half of the dialog hiding animation, the animation card zooms in from where
        // the dialog stops at the end of the first half and moves towards where the card should be.
        final ObjectAnimator cardZoomInMoveYAnimator =
                ObjectAnimator.ofFloat(
                        mAnimationCardView,
                        View.TRANSLATION_Y,
                        cardScaledYPosition,
                        cardInitYPosition);
        final ObjectAnimator cardZoomInMoveXAnimator =
                ObjectAnimator.ofFloat(
                        mAnimationCardView,
                        View.TRANSLATION_X,
                        cardScaledXPosition,
                        cardInitXPosition);
        final ObjectAnimator cardZoomInScaleXAnimator =
                ObjectAnimator.ofFloat(mAnimationCardView, View.SCALE_X, cardScale, 1f);
        final ObjectAnimator cardZoomInScaleYAnimator =
                ObjectAnimator.ofFloat(mAnimationCardView, View.SCALE_Y, cardScale, 1f);

        AnimatorSet cardZoomInAnimatorSet = new AnimatorSet();
        cardZoomInAnimatorSet
                .play(cardZoomInMoveYAnimator)
                .with(cardZoomInMoveXAnimator)
                .with(cardZoomInScaleXAnimator)
                .with(cardZoomInScaleYAnimator);
        cardZoomInAnimatorSet.setDuration(DIALOG_ANIMATION_DURATION);
        cardZoomInAnimatorSet.setInterpolator(Interpolators.EMPHASIZED);

        // In the second half of the dialog hiding animation, the tab grid card fades in while it
        // scales down and moves.
        final ObjectAnimator cardZoomInAlphaAnimator =
                ObjectAnimator.ofFloat(mAnimationCardView, View.ALPHA, 0f, 1f);
        cardZoomInAlphaAnimator.setDuration(DIALOG_ALPHA_ANIMATION_DURATION);
        cardZoomInAlphaAnimator.setStartDelay(DIALOG_ALPHA_ANIMATION_DURATION);
        cardZoomInAlphaAnimator.setInterpolator(Interpolators.EMPHASIZED);

        cardZoomInAlphaAnimator.addListener(
                new AnimatorListenerAdapter() {
                    @Override
                    public void onAnimationStart(Animator animation) {
                        // At the beginning of the second half of the hiding animation, the white
                        // frame and the animation card should be above the the dialog view.
                        mBackgroundFrame.bringToFront();
                        mAnimationClip.bringToFront();
                        mAnimationCardView.bringToFront();
                    }

                    @Override
                    public void onAnimationEnd(Animator animation) {
                        // At the end of the hiding animation, reset the alpha of animation related
                        // views to
                        // 0.
                        mBackgroundFrame.setAlpha(0f);
                        mAnimationCardView.setAlpha(0f);
                    }
                });

        // During the whole dialog hiding animation, the frame background scales down and moves so
        // that it looks like the dialog zooms in and becomes the card.
        final ObjectAnimator frameZoomInMoveYAnimator =
                ObjectAnimator.ofFloat(
                        mBackgroundFrame, View.TRANSLATION_Y, 0f, frameInitYPosition);
        final ObjectAnimator frameZoomInMoveXAnimator =
                ObjectAnimator.ofFloat(
                        mBackgroundFrame, View.TRANSLATION_X, 0f, frameInitXPosition);
        final ObjectAnimator frameZoomInScaleYAnimator =
                ObjectAnimator.ofFloat(mBackgroundFrame, View.SCALE_Y, 1f, frameScaleY);
        final ObjectAnimator frameZoomInScaleXAnimator =
                ObjectAnimator.ofFloat(mBackgroundFrame, View.SCALE_X, 1f, frameScaleX);

        AnimatorSet frameZoomInAnimatorSet = new AnimatorSet();
        frameZoomInAnimatorSet
                .play(frameZoomInMoveYAnimator)
                .with(frameZoomInMoveXAnimator)
                .with(frameZoomInScaleYAnimator)
                .with(frameZoomInScaleXAnimator);
        frameZoomInAnimatorSet.setDuration(DIALOG_ANIMATION_DURATION);
        frameZoomInAnimatorSet.setInterpolator(Interpolators.EMPHASIZED);

        frameZoomInAnimatorSet.addListener(
                new AnimatorListenerAdapter() {
                    @Override
                    public void onAnimationStart(Animator animation) {
                        // At the beginning of the hiding animation, the alpha of white frame needs
                        // to be restored to 1.
                        mBackgroundFrame.setAlpha(1f);
                    }
                });

        // At the end of the dialog hiding animation, the original tab grid card fades in.
        final ObjectAnimator tabFadeInAnimator =
                ObjectAnimator.ofFloat(mItemView, View.ALPHA, 0f, 1f);
        tabFadeInAnimator.setDuration(CARD_FADE_ANIMATION_DURATION);
        tabFadeInAnimator.setStartDelay(DIALOG_ANIMATION_DURATION - CARD_FADE_ANIMATION_DURATION);

        // Setup the dialog hiding animation.
        mHideDialogAnimation = new AnimatorSet();
        mHideDialogAnimation
                .play(dialogZoomInAnimatorSet)
                .with(dialogZoomInAlphaAnimator)
                .with(frameZoomInAnimatorSet)
                .with(cardZoomInAnimatorSet)
                .with(cardZoomInAlphaAnimator)
                .with(tabFadeInAnimator);
        mHideDialogAnimation.addListener(mHideDialogAnimationListener);
    }

    @VisibleForTesting
    void updateDialogWithOrientation(int orientation) {
        Resources res = mContext.getResources();
        int minMargin = res.getDimensionPixelSize(R.dimen.tab_grid_dialog_min_margin);
        int maxMargin = res.getDimensionPixelSize(R.dimen.tab_grid_dialog_max_margin);
        if (orientation == Configuration.ORIENTATION_PORTRAIT) {
            mSideMargin = minMargin;
            mTopMargin = clampMargin(Math.round(mParentHeight * 0.1f), minMargin, maxMargin);
        } else {
            mSideMargin = clampMargin(Math.round(mParentWidth * 0.1f), minMargin, maxMargin);
            mTopMargin = minMargin;
        }
        mContainerParams.setMargins(mSideMargin, mTopMargin, mSideMargin, mTopMargin);
        mOrientation = orientation;
    }

    private int clampMargin(int sizeAdjustedValue, int lowerBound, int upperBound) {
        // In the event the parent isn't laid out yet just default to the upper bound.
        if (sizeAdjustedValue == 0) return upperBound;

        return MathUtils.clamp(sizeAdjustedValue, lowerBound, upperBound);
    }

    private void updateAnimationCardView(View view) {
        View animationCard = mAnimationCardView;
        TextView cardTitle = animationCard.findViewById(R.id.tab_title);
        ImageView cardFavicon = animationCard.findViewById(R.id.tab_favicon);
        TabThumbnailView cardThumbnail = animationCard.findViewById(R.id.tab_thumbnail);
        ImageView cardActionButton = animationCard.findViewById(R.id.action_button);
        View cardBackground = animationCard.findViewById(R.id.background_view);
        cardBackground.setBackground(null);

        if (view == null) {
            cardFavicon.setImageDrawable(null);
            cardTitle.setText("");
            cardThumbnail.setImageDrawable(null);
            cardActionButton.setImageDrawable(null);
            return;
        }

        // Update the stand-in animation card view with the actual item view from grid tab switcher
        // recyclerView.
        FrameLayout.LayoutParams params =
                (FrameLayout.LayoutParams) animationCard.getLayoutParams();
        params.width = view.getWidth();
        params.height = view.getHeight();
        animationCard.setLayoutParams(params);
        TextView viewTitle = view.findViewById(R.id.tab_title);
        if (viewTitle == null) {
            return;
        }

        // Sometimes we get clip artifacting when sharing a drawable, unclear why, so make a copy.
        Drawable backgroundCopy =
                view.findViewById(R.id.card_view).getBackground().getConstantState().newDrawable();
        animationCard.findViewById(R.id.card_view).setBackground(backgroundCopy);

        Drawable faviconDrawable = ((ImageView) view.findViewById(R.id.tab_favicon)).getDrawable();
        cardFavicon.setImageDrawable(faviconDrawable);
        if (faviconDrawable != null) {
            int padding = (int) TabUiThemeProvider.getTabCardTopFaviconPadding(mContext);
            cardFavicon.setPadding(padding, padding, padding, padding);
        }

        cardTitle.setText(viewTitle.getText());
        cardTitle.setTextAppearance(R.style.TextAppearance_TextMediumThick_Primary);
        cardTitle.setTextColor(viewTitle.getTextColors());

        TabThumbnailView originalThumbnailView = view.findViewById(R.id.tab_thumbnail);
        if (originalThumbnailView.isPlaceholder()) {
            cardThumbnail.setImageDrawable(null);
        } else {
            cardThumbnail.setImageDrawable(originalThumbnailView.getDrawable());
            cardThumbnail.setImageMatrix(originalThumbnailView.getImageMatrix());
            cardThumbnail.setScaleType(originalThumbnailView.getScaleType());
        }

        ImageView originalActionButton = view.findViewById(R.id.action_button);
        cardActionButton.setImageDrawable(originalActionButton.getDrawable());
        ImageViewCompat.setImageTintList(
                cardActionButton, ImageViewCompat.getImageTintList(originalActionButton));
    }

    /**
     * Setup the {@link PropertyModel} used to show scrim view.
     *
     * @param scrimClickRunnable The {@link Runnable} that runs when scrim view is clicked.
     */
    void setScrimClickRunnable(Runnable scrimClickRunnable) {
        mScrimPropertyModel =
                new PropertyModel.Builder(ScrimProperties.ALL_KEYS)
                        .with(ScrimProperties.ANCHOR_VIEW, mDialogContainerView)
                        .with(ScrimProperties.SHOW_IN_FRONT_OF_ANCHOR_VIEW, false)
                        .with(ScrimProperties.AFFECTS_STATUS_BAR, true)
                        .with(ScrimProperties.TOP_MARGIN, 0)
                        .with(ScrimProperties.CLICK_DELEGATE, scrimClickRunnable)
                        .with(ScrimProperties.AFFECTS_NAVIGATION_BAR, true)
                        .build();
    }

    void setupScrimCoordinator(ScrimCoordinator scrimCoordinator) {
        mScrimCoordinator = scrimCoordinator;
    }

    /**
     * Reset the dialog content with {@code toolbarView} and {@code recyclerView}.
     *
     * @param toolbarView The toolbarview to be added to dialog.
     * @param recyclerView The recyclerview to be added to dialog.
     */
    void resetDialog(View toolbarView, View recyclerView) {
        mToolbarContainer.removeAllViews();
        mToolbarContainer.addView(toolbarView);
        mRecyclerViewContainer.removeAllViews();
        mRecyclerViewContainer.addView(recyclerView);

        recyclerView.setVisibility(View.VISIBLE);
    }

    void refreshScrim() {
        assert mScrimCoordinator != null && mScrimPropertyModel != null;
        mScrimCoordinator.showScrim(mScrimPropertyModel);
    }

    /** Show {@link PopupWindow} for dialog with animation. */
    void showDialog() {
        if (mCurrentDialogAnimator != null && mCurrentDialogAnimator != mShowDialogAnimation) {
            mCurrentDialogAnimator.end();
        }
        mCurrentDialogAnimator = mShowDialogAnimation;
        assert mScrimCoordinator != null && mScrimPropertyModel != null;
        mScrimCoordinator.showScrim(mScrimPropertyModel);
        setVisibility(View.VISIBLE);
        mShowDialogAnimation.start();
    }

    /** Hide {@link PopupWindow} for dialog with animation. */
    void hideDialog() {
        // Skip the hideDialog call caused by initializing the dialog visibility as false.
        if (getVisibility() != VISIBLE) return;

        assert mScrimCoordinator != null && mScrimPropertyModel != null;
        if (mCurrentDialogAnimator != null && mCurrentDialogAnimator != mHideDialogAnimation) {
            mCurrentDialogAnimator.end();
        }
        mCurrentDialogAnimator = mHideDialogAnimation;
        if (mScrimCoordinator.isShowingScrim()) {
            if (DeviceFormFactor.isNonMultiDisplayContextOnTablet(mContext)) {
                mScrimCoordinator.hideScrim(true, SCRIM_FADE_DURATION_MS);
            } else {
                mScrimCoordinator.hideScrim(true);
            }
        }
        mHideDialogAnimation.start();
    }

    /**
     * Update the ungroup bar based on {@code status}.
     *
     * @param status The status in {@link TabGridDialogView.UngroupBarStatus} that the ungroup bar
     *     should be updated to.
     */
    void updateUngroupBar(@UngroupBarStatus int status) {
        if (status == mUngroupBarStatus) return;
        switch (status) {
            case UngroupBarStatus.SHOW:
                updateUngroupBarTextView(false);
                if (mUngroupBarStatus == UngroupBarStatus.HIDE) {
                    mUngroupBarShow.start();
                }
                break;
            case UngroupBarStatus.HIDE:
                mUngroupBarHide.start();
                break;
            case UngroupBarStatus.HOVERED:
                updateUngroupBarTextView(true);
                if (mUngroupBarStatus == UngroupBarStatus.HIDE) {
                    mUngroupBarShow.start();
                }
                break;
            default:
                assert false;
        }
        mUngroupBarStatus = status;
    }

    private void updateUngroupBarTextView(boolean isHovered) {
        assert mUngroupBarTextView.getBackground() instanceof GradientDrawable;
        mUngroupBar.bringToFront();
        GradientDrawable background = (GradientDrawable) mUngroupBarTextView.getBackground();
        background.setColor(
                isHovered ? mUngroupBarHoveredBackgroundColor : mUngroupBarBackgroundColor);
        mUngroupBarTextView.setTextColor(
                isHovered ? mUngroupBarHoveredTextColor : mUngroupBarTextColor);
    }

    void updateUngroupBarText(String ungroupBarText) {
        mUngroupBarTextView.setText(ungroupBarText);
    }

    /**
     * Update the dialog container background color.
     *
     * @param backgroundColor The new background color to use.
     */
    void updateDialogContainerBackgroundColor(int backgroundColor) {
        mBackgroundDrawableColor = backgroundColor;
        DrawableCompat.setTint(mDialogContainerView.getBackground(), backgroundColor);
        DrawableCompat.setTint(mBackgroundFrame.getBackground(), backgroundColor);
    }

    void updateHairlineColor(@ColorInt int hairlineColor) {
        mHairline.setImageTintList(ColorStateList.valueOf(hairlineColor));
    }

    void setHairlineVisibility(boolean visible) {
        mHairline.setVisibility(visible ? View.VISIBLE : View.GONE);
    }

    /**
     * Updates the background color for the animation card.
     *
     * @param colorInt The new color to use.
     */
    void updateAnimationBackgroundColor(@ColorInt int colorInt) {
        assert TabUiFeatureUtilities.shouldUseListMode();
        updateAnimationCardView(null);
        Drawable animationBackground =
                mAnimationCardView.findViewById(R.id.card_view).getBackground();
        DrawableCompat.setTint(animationBackground, colorInt);
    }

    /**
     * Update the ungroup bar background color.
     *
     * @param colorInt The new background color to use when ungroup bar is visible.
     */
    void updateUngroupBarBackgroundColor(int colorInt) {
        mUngroupBarBackgroundColor = colorInt;
    }

    /**
     * Update the ungroup bar background color when the ungroup bar is hovered.
     * @param colorInt The new background color to use when ungroup bar is visible and hovered.
     */
    void updateUngroupBarHoveredBackgroundColor(int colorInt) {
        mUngroupBarHoveredBackgroundColor = colorInt;
    }

    /**
     * Update the ungroup bar text color when the ungroup bar is visible but not hovered.
     * @param colorInt The new text color to use when ungroup bar is visible.
     */
    void updateUngroupBarTextColor(int colorInt) {
        mUngroupBarTextColor = colorInt;
    }

    /**
     * Update the ungroup bar text color when the ungroup bar is hovered.
     * @param colorInt The new text color to use when ungroup bar is visible and hovered.
     */
    void updateUngroupBarHoveredTextColor(int colorInt) {
        mUngroupBarHoveredTextColor = colorInt;
    }

    /** Return the container view for undo closure snack bar. */
    ViewGroup getSnackBarContainer() {
        return mSnackBarContainer;
    }

    void setBindingToken(Integer bindingToken) {
        assert mBindingToken == null || bindingToken == null;
        mBindingToken = bindingToken;
    }

    Integer getBindingToken() {
        return mBindingToken;
    }

    Animator getCurrentDialogAnimatorForTesting() {
        return mCurrentDialogAnimator;
    }

    Animator getCurrentUngroupBarAnimatorForTesting() {
        return mCurrentUngroupBarAnimator;
    }

    int getUngroupBarStatusForTesting() {
        return mUngroupBarStatus;
    }

    AnimatorSet getShowDialogAnimationForTesting() {
        return mShowDialogAnimation;
    }

    int getBackgroundColorForTesting() {
        return mBackgroundDrawableColor;
    }

    int getUngroupBarBackgroundColorForTesting() {
        return mUngroupBarBackgroundColor;
    }

    int getUngroupBarHoveredBackgroundColorForTesting() {
        return mUngroupBarHoveredBackgroundColor;
    }

    int getUngroupBarTextColorForTesting() {
        return mUngroupBarTextColor;
    }

    int getUngroupBarHoveredTextColorForTesting() {
        return mUngroupBarHoveredTextColor;
    }

    static void setSourceRectCallbackForTesting(Callback<RectF> callback) {
        sSourceRectCallbackForTesting = callback;
        ResettersForTesting.register(() -> sSourceRectCallbackForTesting = null);
    }

    ScrimCoordinator getScrimCoordinatorForTesting() {
        return mScrimCoordinator;
    }

    VisibilityListener getVisibilityListenerForTesting() {
        return mVisibilityListener;
    }
}