chromium/chrome/browser/ui/messages/android/java/src/org/chromium/chrome/browser/ui/messages/snackbar/SnackbarView.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.ui.messages.snackbar;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.ObjectAnimator;
import android.app.Activity;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.text.TextUtils;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.SurfaceView;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.View.OnLayoutChangeListener;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.LinearLayout.LayoutParams;
import android.widget.TextView;

import androidx.annotation.ColorInt;
import androidx.annotation.Nullable;

import org.chromium.chrome.browser.ui.edge_to_edge.EdgeToEdgeControllerFactory;
import org.chromium.chrome.browser.ui.edge_to_edge.EdgeToEdgePadAdjuster;
import org.chromium.chrome.browser.ui.edge_to_edge.EdgeToEdgeSupplier;
import org.chromium.chrome.ui.messages.R;
import org.chromium.components.browser_ui.styles.SemanticColorUtils;
import org.chromium.components.browser_ui.widget.text.TemplatePreservingTextView;
import org.chromium.ui.InsetObserver;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.interpolators.Interpolators;

/**
 * Visual representation of a snackbar. On phone it matches the width of the activity; on tablet it
 * has a fixed width and is anchored at the start-bottom corner of the current window.
 */
// TODO (jianli): Change this class and its methods back to package protected after the offline
// indicator experiment is done.
public class SnackbarView implements InsetObserver.WindowInsetObserver {
    private static final int MAX_LINES = 5;

    private final WindowAndroid mWindowAndroid;
    protected final ViewGroup mContainerView;
    protected final ViewGroup mSnackbarView;
    protected final TemplatePreservingTextView mMessageView;
    private final TextView mActionButtonView;
    private final ImageView mProfileImageView;
    private final int mAnimationDuration;
    private final boolean mIsTablet;
    @Nullable private final EdgeToEdgeSupplier mEdgeToEdgeSupplier;
    @Nullable private final EdgeToEdgePadAdjuster mEdgeToEdgePadAdjuster;
    private ViewGroup mOriginalParent;
    protected ViewGroup mParent;
    protected Snackbar mSnackbar;
    private View mRootContentView;
    private @ColorInt int mBackgroundColor;

    // Variables used to adjust view position and size when visible frame is changed.
    private Rect mCurrentVisibleRect = new Rect();
    private Rect mPreviousVisibleRect = new Rect();

    private OnLayoutChangeListener mLayoutListener =
            new OnLayoutChangeListener() {
                @Override
                public void onLayoutChange(
                        View v,
                        int left,
                        int top,
                        int right,
                        int bottom,
                        int oldLeft,
                        int oldTop,
                        int oldRight,
                        int oldBottom) {
                    adjustViewPosition();
                }
            };

    /**
     * Creates an instance of the {@link SnackbarView}.
     *
     * @param activity The activity that displays the snackbar.
     * @param listener An {@link OnClickListener} that will be called when the action button is
     *     clicked.
     * @param snackbar The snackbar to be displayed.
     * @param parentView The ViewGroup used to display this snackbar.
     * @param windowAndroid The WindowAndroid used for starting animation. If it is null,
     *     Animator#start is called instead.
     */
    public SnackbarView(
            Activity activity,
            OnClickListener listener,
            Snackbar snackbar,
            ViewGroup parentView,
            @Nullable WindowAndroid windowAndroid,
            boolean isTablet) {
        this(activity, listener, snackbar, parentView, windowAndroid, null, isTablet);
    }

    /**
     * Creates an instance of the {@link SnackbarView}.
     *
     * @param activity The activity that displays the snackbar.
     * @param listener An {@link OnClickListener} that will be called when the action button is
     *     clicked.
     * @param snackbar The snackbar to be displayed.
     * @param parentView The ViewGroup used to display this snackbar.
     * @param windowAndroid The WindowAndroid used for starting animation. If it is null,
     *     Animator#start is called instead.
     * @param edgeToEdgeSupplier The supplier publishes the changes of the edge-to-edge state and
     *     the expected bottom paddings when edge-to-edge is on.
     */
    public SnackbarView(
            Activity activity,
            OnClickListener listener,
            Snackbar snackbar,
            ViewGroup parentView,
            @Nullable WindowAndroid windowAndroid,
            @Nullable EdgeToEdgeSupplier edgeToEdgeSupplier,
            boolean isTablet) {
        mIsTablet = isTablet;
        mOriginalParent = parentView;
        mWindowAndroid = windowAndroid;

        mRootContentView = activity.findViewById(android.R.id.content);
        mParent = mOriginalParent;
        mContainerView =
                (ViewGroup)
                        LayoutInflater.from(activity).inflate(R.layout.snackbar, mParent, false);
        mSnackbarView = mContainerView.findViewById(R.id.snackbar);
        mAnimationDuration =
                mContainerView.getResources().getInteger(android.R.integer.config_mediumAnimTime);
        mMessageView =
                (TemplatePreservingTextView) mContainerView.findViewById(R.id.snackbar_message);
        mActionButtonView = (TextView) mContainerView.findViewById(R.id.snackbar_button);
        mActionButtonView.setOnClickListener(listener);
        mProfileImageView = (ImageView) mContainerView.findViewById(R.id.snackbar_profile_image);
        mEdgeToEdgeSupplier = edgeToEdgeSupplier;
        mEdgeToEdgePadAdjuster =
                edgeToEdgeSupplier != null
                        ? EdgeToEdgeControllerFactory.createForView(mSnackbarView)
                        : null;

        updateInternal(snackbar, false);
    }

    public void show() {
        addToParent();
        mContainerView.addOnLayoutChangeListener(
                new OnLayoutChangeListener() {
                    @Override
                    public void onLayoutChange(
                            View v,
                            int left,
                            int top,
                            int right,
                            int bottom,
                            int oldLeft,
                            int oldTop,
                            int oldRight,
                            int oldBottom) {
                        mContainerView.removeOnLayoutChangeListener(this);
                        mContainerView.setTranslationY(getYPositionForMoveAnimation());
                        Animator animator =
                                ObjectAnimator.ofFloat(mContainerView, View.TRANSLATION_Y, 0);
                        animator.setInterpolator(Interpolators.STANDARD_INTERPOLATOR);
                        animator.setDuration(mAnimationDuration);
                        startAnimatorOnSurfaceView(animator);
                    }
                });
        if (mEdgeToEdgeSupplier != null) {
            mEdgeToEdgeSupplier.registerAdjuster(mEdgeToEdgePadAdjuster);
        }
    }

    public void dismiss() {
        // Prevent clicks during dismissal animations. Intentionally not using setEnabled(false) to
        // avoid unnecessary text color changes in this transitory state.
        mActionButtonView.setOnClickListener(null);
        Animator moveAnimator =
                ObjectAnimator.ofFloat(
                        mContainerView, View.TRANSLATION_Y, getYPositionForMoveAnimation());
        moveAnimator.setInterpolator(Interpolators.DECELERATE_INTERPOLATOR);
        moveAnimator.setDuration(mAnimationDuration);
        moveAnimator.addListener(
                new AnimatorListenerAdapter() {
                    @Override
                    public void onAnimationEnd(Animator animation) {
                        mRootContentView.removeOnLayoutChangeListener(mLayoutListener);
                        mParent.removeView(mContainerView);
                    }
                });
        startAnimatorOnSurfaceView(moveAnimator);
        if (mEdgeToEdgeSupplier != null) {
            mEdgeToEdgeSupplier.unregisterAdjuster(mEdgeToEdgePadAdjuster);
        }
    }

    /**
     * Adjusts the position when visible area is updated, such as resizing the window, in order to
     * ensure its maximum width.
     */
    void adjustViewPosition() {
        mParent.getWindowVisibleDisplayFrame(mCurrentVisibleRect);
        // Only update if the visible frame has changed, otherwise there will be a layout loop.
        if (!mCurrentVisibleRect.equals(mPreviousVisibleRect)) {
            mPreviousVisibleRect.set(mCurrentVisibleRect);
            FrameLayout.LayoutParams lp = getLayoutParams();

            int prevWidth = lp.width;
            int prevGravity = lp.gravity;

            if (mIsTablet) {
                int margin =
                        mParent.getResources()
                                .getDimensionPixelSize(R.dimen.snackbar_margin_tablet);
                int width =
                        mParent.getResources().getDimensionPixelSize(R.dimen.snackbar_width_tablet);
                lp.width = Math.min(width, mParent.getWidth() - 2 * margin);
                lp.gravity = Gravity.CENTER_HORIZONTAL | Gravity.BOTTOM;
            }
            if (prevWidth != lp.width || prevGravity != lp.gravity) {
                mContainerView.setLayoutParams(lp);
            }
        }
    }

    protected int getYPositionForMoveAnimation() {
        return mContainerView.getHeight() + getLayoutParams().bottomMargin;
    }

    /**
     * @see SnackbarManager#overrideParent(ViewGroup)
     */
    void overrideParent(ViewGroup overridingParent) {
        mRootContentView.removeOnLayoutChangeListener(mLayoutListener);
        mParent = overridingParent == null ? mOriginalParent : overridingParent;
        if (mContainerView.getParent() != null) {
            ((ViewGroup) mContainerView.getParent()).removeView(mContainerView);
        }
        addToParent();
    }

    boolean isShowing() {
        return mContainerView.isShown();
    }

    void bringToFront() {
        mContainerView.bringToFront();
    }

    /**
     * Sends an accessibility event to mMessageView announcing that this window was added so that
     * the mMessageView content description is read aloud if accessibility is enabled.
     */
    public void announceforAccessibility() {
        StringBuilder accessibilityText = new StringBuilder(mMessageView.getContentDescription());
        if (mActionButtonView.getContentDescription() != null) {
            accessibilityText
                    .append(". ")
                    .append(mActionButtonView.getContentDescription())
                    .append(". ")
                    .append(
                            mContainerView
                                    .getResources()
                                    .getString(R.string.bottom_bar_screen_position));
        }

        mMessageView.announceForAccessibility(accessibilityText);
    }

    /**
     * Sends an accessibility event to mContainerView announcing that an action was taken based on
     * the action button being pressed.  May do nothing if no announcement was specified.
     */
    public void announceActionForAccessibility() {
        if (TextUtils.isEmpty(mSnackbar.getActionAccessibilityAnnouncement())) return;
        mContainerView.announceForAccessibility(mSnackbar.getActionAccessibilityAnnouncement());
    }

    /**
     * Updates the view to display data from the given snackbar. No-op if the view is already
     * showing the given snackbar.
     * @param snackbar The snackbar to display
     * @return Whether update has actually been executed.
     */
    boolean update(Snackbar snackbar) {
        return updateInternal(snackbar, true);
    }

    private void addToParent() {
        mParent.addView(mContainerView);

        // Why setting listener on parent? It turns out that if we force a relayout in the layout
        // change listener of the view itself, the force layout flag will be reset to 0 when
        // layout() returns. Therefore we have to do request layout on one level above the requested
        // view.
        mRootContentView.addOnLayoutChangeListener(mLayoutListener);
    }

    // TODO(fgorski): Start using color ID, to remove the view from arguments.
    private static int calculateBackgroundColor(View view, Snackbar snackbar) {
        // Themes are used first.
        if (snackbar.getTheme() == Snackbar.Theme.GOOGLE) {
            // TODO(crbug.com/40798080): Revisit once we know whether to make this dynamic.
            return view.getContext().getColor(R.color.default_control_color_active_baseline);
        }

        assert snackbar.getTheme() == Snackbar.Theme.BASIC;
        if (snackbar.getBackgroundColor() != 0) {
            return snackbar.getBackgroundColor();
        }

        return SemanticColorUtils.getSnackbarBackgroundColor(view.getContext());
    }

    public @ColorInt int getBackgroundColor() {
        return mBackgroundColor;
    }

    private static int getTextAppearance(Snackbar snackbar) {
        if (snackbar.getTheme() == Snackbar.Theme.GOOGLE) {
            return R.style.TextAppearance_TextMedium_Primary_OnAccent1;
        }

        assert snackbar.getTheme() == Snackbar.Theme.BASIC;
        if (snackbar.getTextAppearance() != 0) {
            return snackbar.getTextAppearance();
        }

        return R.style.TextAppearance_TextMedium_Primary;
    }

    private static int getButtonTextAppearance(Snackbar snackbar) {
        if (snackbar.getTheme() == Snackbar.Theme.GOOGLE) {
            return R.style.TextAppearance_Button_Text_Filled;
        }

        assert snackbar.getTheme() == Snackbar.Theme.BASIC;
        return R.style.TextButton;
    }

    private boolean updateInternal(Snackbar snackbar, boolean animate) {
        if (mSnackbar == snackbar) return false;
        mSnackbar = snackbar;
        mMessageView.setMaxLines(snackbar.getSingleLine() ? 1 : MAX_LINES);
        mMessageView.setTemplate(snackbar.getTemplateText());
        setViewText(mMessageView, snackbar.getText(), animate);

        mMessageView.setTextAppearance(getTextAppearance(snackbar));
        mActionButtonView.setTextAppearance(getButtonTextAppearance(snackbar));

        mBackgroundColor = calculateBackgroundColor(mContainerView, snackbar);
        if (mIsTablet) {
            // On tablet, snackbars have rounded corners.
            mSnackbarView.setBackgroundResource(R.drawable.snackbar_background_tablet);
            GradientDrawable backgroundDrawable =
                    (GradientDrawable) mSnackbarView.getBackground().mutate();
            backgroundDrawable.setColor(mBackgroundColor);
        } else {
            mSnackbarView.setBackgroundColor(mBackgroundColor);
        }

        if (snackbar.getActionText() != null) {
            mActionButtonView.setVisibility(View.VISIBLE);
            mActionButtonView.setContentDescription(snackbar.getActionText());
            setViewText(mActionButtonView, snackbar.getActionText(), animate);
            // Set the end margin on the message view to 0 when there is action text.
            if (mMessageView.getLayoutParams() instanceof LayoutParams) {
                LayoutParams lp = (LayoutParams) mMessageView.getLayoutParams();
                lp.setMarginEnd(0);
                mMessageView.setLayoutParams(lp);
            }
        } else {
            mActionButtonView.setVisibility(View.GONE);
            // Set a non-zero end margin on the message view when there is no action text.
            if (mMessageView.getLayoutParams() instanceof LayoutParams) {
                LayoutParams lp = (LayoutParams) mMessageView.getLayoutParams();
                lp.setMarginEnd(
                        mParent.getResources()
                                .getDimensionPixelSize(R.dimen.snackbar_text_view_margin));
                mMessageView.setLayoutParams(lp);
            }
        }
        Drawable profileImage = snackbar.getProfileImage();
        if (profileImage != null) {
            mProfileImageView.setVisibility(View.VISIBLE);
            mProfileImageView.setImageDrawable(profileImage);
        } else {
            mProfileImageView.setVisibility(View.GONE);
        }

        if (mIsTablet) {
            mContainerView.findViewById(R.id.snackbar_shadow_left).setVisibility(View.VISIBLE);
            mContainerView.findViewById(R.id.snackbar_shadow_right).setVisibility(View.VISIBLE);
        }

        return true;
    }

    /**
     * Starts the {@link Animator} with {@link SurfaceView} optimization disabled. If a
     * {@link SurfaceView} is not present (mWindowAndroid is null), start the {@link Animator}
     * in the normal way.
     */
    private void startAnimatorOnSurfaceView(Animator animator) {
        if (mWindowAndroid != null) {
            mWindowAndroid.startAnimationOverContent(animator);
        } else {
            animator.start();
        }
    }

    private FrameLayout.LayoutParams getLayoutParams() {
        return (FrameLayout.LayoutParams) mContainerView.getLayoutParams();
    }

    private void setViewText(TextView view, CharSequence text, boolean animate) {
        if (view.getText().toString().equals(text)) return;
        view.animate().cancel();
        if (animate) {
            view.setAlpha(0.0f);
            view.setText(text);
            view.animate().alpha(1.f).setDuration(mAnimationDuration).setListener(null);
        } else {
            view.setText(text);
        }
    }

    public ViewGroup getViewForTesting() {
        return mSnackbarView;
    }

    public EdgeToEdgePadAdjuster getEdgeToEdgePadAdjusterForTesting() {
        return mEdgeToEdgePadAdjuster;
    }
}