chromium/ui/android/java/src/org/chromium/ui/listmenu/ListMenuButton.java

// Copyright 2017 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.ui.listmenu;

import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Color;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;

import org.chromium.base.ObserverList;
import org.chromium.ui.R;
import org.chromium.ui.widget.AnchoredPopupWindow;
import org.chromium.ui.widget.ChromeImageButton;

/**
 * A menu button meant to be used with modern lists throughout Chrome. Will automatically show and
 * anchor a popup on press and will rely on a delegate for positioning and content of the popup. You
 * can define your own content description for accessibility through the android:contentDescription
 * parameter in the XML layout of the ListMenuButton. The default content description that
 * corresponds to context.getString(R.string.accessibility_list_menu_button, "") is used otherwise.
 */
public class ListMenuButton extends ChromeImageButton
        implements AnchoredPopupWindow.LayoutObserver {
    /** A listener that is notified when the popup menu is shown or dismissed. */
    @FunctionalInterface
    public interface PopupMenuShownListener {
        void onPopupMenuShown();

        default void onPopupMenuDismissed() {}
    }

    private final boolean mMenuVerticalOverlapAnchor;
    private final boolean mMenuHorizontalOverlapAnchor;

    private int mMenuMaxWidth;
    private AnchoredPopupWindow mPopupMenu;
    private ListMenuButtonDelegate mDelegate;
    private ObserverList<PopupMenuShownListener> mPopupListeners = new ObserverList<>();
    private boolean mTryToFitLargestItem;
    private boolean mPositionedAtEnd;
    private boolean mIsAttachedToWindow;

    /**
     * Creates a new {@link ListMenuButton}.
     *
     * @param context The {@link Context} used to build the visuals from.
     * @param attrs The specific {@link AttributeSet} used to build the button.
     */
    public ListMenuButton(Context context, AttributeSet attrs) {
        super(context, attrs);

        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ListMenuButton);
        mMenuMaxWidth =
                a.getDimensionPixelSize(
                        R.styleable.ListMenuButton_menuMaxWidth,
                        getResources().getDimensionPixelSize(R.dimen.list_menu_width));
        mMenuHorizontalOverlapAnchor =
                a.getBoolean(R.styleable.ListMenuButton_menuHorizontalOverlapAnchor, true);
        mMenuVerticalOverlapAnchor =
                a.getBoolean(R.styleable.ListMenuButton_menuVerticalOverlapAnchor, true);
        mPositionedAtEnd = a.getBoolean(R.styleable.ListMenuButton_menuPositionedAtEnd, true);

        a.recycle();
    }

    /**
     * Text that represents the item this menu button is related to.  This will affect the content
     * description of the view {@see #setContentDescription(CharSequence)}.
     *
     * @param context The string representation of the list item this button represents.
     */
    public void setContentDescriptionContext(String context) {
        if (TextUtils.isEmpty(context)) {
            setContentDescription(
                    getContext().getResources().getString(R.string.accessibility_toolbar_btn_menu));
            return;
        }
        setContentDescription(
                getContext()
                        .getResources()
                        .getString(R.string.accessibility_list_menu_button, context));
    }

    /**
     * Sets the delegate this menu will rely on for populating the popup menu and handling selection
     * responses. The OnClickListener will be overridden by default to show menu. The menu will not
     * show or work without the delegate.
     *
     * @param delegate The {@link ListMenuButtonDelegate} to use for menu creation and selection
     *         handling.
     */
    public void setDelegate(ListMenuButtonDelegate delegate) {
        setDelegate(delegate, true);
    }

    /**
     * Sets the delegate this menu will rely on for populating the popup menu and handling selection
     * responses. The menu will not
     * show or work without the delegate.
     *
     * @param delegate The {@link ListMenuButtonDelegate} to use for menu creation and selection
     *         handling.
     * @param overrideOnClickListener Whether to override the click listener which can trigger
     *        the popup menu.
     */
    public void setDelegate(ListMenuButtonDelegate delegate, boolean overrideOnClickListener) {
        dismiss();
        mDelegate = delegate;
        if (overrideOnClickListener) {
            setOnClickListener((view) -> showMenu());
        }
    }

    /** Called to dismiss any popup menu that might be showing for this button. */
    public void dismiss() {
        if (mPopupMenu != null) {
            mPopupMenu.dismiss();
        }
    }

    /** Shows a popupWindow built by ListMenuButton */
    public void showMenu() {
        if (!mIsAttachedToWindow) return;
        dismiss();
        initPopupWindow();
        mPopupMenu.show();
        notifyPopupListeners(true);
    }

    /**
     * Set the max width of the popup menu.
     * @param maxWidth The max width of the popup.
     */
    public void setMenuMaxWidth(int maxWidth) {
        mMenuMaxWidth = maxWidth;
    }

    /** Init the popup window with provided attributes, called before {@link #showMenu()} */
    private void initPopupWindow() {
        if (mDelegate == null) throw new IllegalStateException("Delegate was not set.");

        ListMenu menu = mDelegate.getListMenu();
        menu.addContentViewClickRunnable(this::dismiss);

        final View contentView = menu.getContentView();
        ViewParent viewParent = contentView.getParent();
        // TODO(crbug.com/40838478): figure out why contentView is not removed from popup menu.
        if (viewParent instanceof ViewGroup) {
            ((ViewGroup) viewParent).removeView(contentView);
        }
        mPopupMenu =
                new AnchoredPopupWindow(
                        getContext(),
                        this,
                        new ColorDrawable(Color.TRANSPARENT),
                        contentView,
                        mDelegate.getRectProvider(this));
        mPopupMenu.setVerticalOverlapAnchor(mMenuVerticalOverlapAnchor);
        mPopupMenu.setHorizontalOverlapAnchor(mMenuHorizontalOverlapAnchor);
        mPopupMenu.setMaxWidth(mMenuMaxWidth);
        if (mTryToFitLargestItem) {
            // Content width includes the padding around the items, so add it here.
            final int lateralPadding = contentView.getPaddingLeft() + contentView.getPaddingRight();
            mPopupMenu.setDesiredContentWidth(menu.getMaxItemWidth() + lateralPadding);
        }
        mPopupMenu.setFocusable(true);
        mPopupMenu.setLayoutObserver(this);
        mPopupMenu.addOnDismissListener(
                () -> {
                    mPopupMenu = null;
                    notifyPopupListeners(false);
                });
        // This should be called explicitly since it is not a default behavior on Android S
        // in split-screen mode. See crbug.com/1246956.
        mPopupMenu.setOutsideTouchable(true);
    }

    /**
     * Adds a listener which will be notified when the popup menu is shown.
     *
     * @param l The listener of interest.
     */
    public void addPopupListener(PopupMenuShownListener l) {
        mPopupListeners.addObserver(l);
    }

    /**
     * Removes a popup menu listener.
     *
     * @param l The listener of interest.
     */
    public void removePopupListener(PopupMenuShownListener l) {
        mPopupListeners.removeObserver(l);
    }

    // AnchoredPopupWindow.LayoutObserver implementation.
    @Override
    public void onPreLayoutChange(
            boolean positionBelow, int x, int y, int width, int height, Rect anchorRect) {
        if (mPositionedAtEnd) {
            mPopupMenu.setAnimationStyle(
                    positionBelow ? R.style.EndIconMenuAnim : R.style.EndIconMenuAnimBottom);

        } else {
            mPopupMenu.setAnimationStyle(
                    positionBelow ? R.style.StartIconMenuAnim : R.style.StartIconMenuAnimBottom);
        }
    }

    /**
     * Determines whether to try to fit the largest menu item without overflowing by measuring the
     * exact width of each item.
     *
     * WARNING: do not call when the menu list has more than a handful of items, the performance
     * will be terrible since it measures every single item.
     *
     * @param value Determines whether to try to exactly fit the width of the largest item in the
     *              list.
     */
    public void tryToFitLargestItem(boolean value) {
        mTryToFitLargestItem = value;
    }

    // View implementation.
    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        if (TextUtils.isEmpty(getContentDescription())) setContentDescriptionContext("");
    }

    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        mIsAttachedToWindow = true;
    }

    @Override
    protected void onDetachedFromWindow() {
        dismiss();
        mIsAttachedToWindow = false;
        super.onDetachedFromWindow();
    }

    /**
     * Notify all of the PopupMenuShownListeners of a popup menu action.
     * @param shown Whether the popup menu was shown or dismissed.
     */
    private void notifyPopupListeners(boolean shown) {
        for (var l : mPopupListeners.mObservers) {
            if (shown) {
                l.onPopupMenuShown();
            } else {
                l.onPopupMenuDismissed();
            }
        }
    }

    public void setAttachedToWindowForTesting() {
        mIsAttachedToWindow = true;
    }
}