chromium/components/browser_ui/widget/android/java/src/org/chromium/components/browser_ui/widget/ContextMenuDialog.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.components.browser_ui.widget;

import android.app.Activity;
import android.graphics.Color;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
import android.os.Build;
import android.provider.Settings;
import android.view.MotionEvent;
import android.view.View;
import android.view.View.OnDragListener;
import android.view.View.OnLayoutChangeListener;
import android.view.ViewGroup.LayoutParams;
import android.view.Window;
import android.view.WindowManager;
import android.view.animation.Animation;
import android.view.animation.ScaleAnimation;
import android.widget.FrameLayout;

import androidx.annotation.Nullable;

import org.chromium.base.ContextUtils;
import org.chromium.ui.UiUtils;
import org.chromium.ui.accessibility.AccessibilityState;
import org.chromium.ui.animation.EmptyAnimationListener;
import org.chromium.ui.dragdrop.DragEventDispatchHelper;
import org.chromium.ui.dragdrop.DragEventDispatchHelper.DragEventDispatchDestination;
import org.chromium.ui.interpolators.Interpolators;
import org.chromium.ui.util.ColorUtils;
import org.chromium.ui.widget.AnchoredPopupWindow;
import org.chromium.ui.widget.RectProvider;

/**
 * ContextMenuDialog is a subclass of AlwaysDismissedDialog that ensures that the proper scale
 * animation is played upon calling {@link #show()} and {@link #dismiss()}.
 */
public class ContextMenuDialog extends AlwaysDismissedDialog {
    public static final int NO_CUSTOM_MARGIN = -1;

    private static final long ENTER_ANIMATION_DURATION_MS = 250;
    // Exit animation duration should be set to 60% of the enter animation duration.
    private static final long EXIT_ANIMATION_DURATION_MS = 150;
    private final Activity mActivity;
    private final View mContentView;
    private final boolean mIsPopup;
    private final boolean mShouldRemoveScrim;
    private final boolean mShouldSysUiMatchActivity;

    private float mContextMenuSourceXPx;
    private float mContextMenuSourceYPx;
    private int mContextMenuFirstLocationYPx;
    private @Nullable AnchoredPopupWindow mPopupWindow;
    private View mLayout;
    private OnLayoutChangeListener mOnLayoutChangeListener;
    private DragEventDispatchHelper mDragEventDispatchHelper;
    private Rect mRect;

    private int mTopMarginPx;
    private int mBottomMarginPx;

    private Integer mPopupMargin;
    private Integer mDesiredPopupContentWidth;

    /**
     * View that is showing behind the context menu. If menu is shown as a popup without scrim, this
     * view will be used to dispatch touch events other than ACTION_DOWN.
     */
    private @Nullable View mTouchEventDelegateView;

    /**
     * Creates an instance of the ContextMenuDialog.
     *
     * @param ownerActivity The activity in which the dialog should run
     * @param theme A style resource describing the theme to use for the window, or {@code 0} to use
     *     the default dialog theme
     * @param topMarginPx An explicit top margin for the dialog, or -1 to use default defined in
     *     XML.
     * @param bottomMarginPx An explicit bottom margin for the dialog, or -1 to use default defined
     *     in XML.
     * @param layout The context menu layout that will house the menu.
     * @param contentView The context menu view to display on the dialog.
     * @param isPopup Whether the context menu is being shown in a {@link AnchoredPopupWindow}.
     * @param shouldRemoveScrim Whether the context menu should removes the scrim behind the dialog
     *     visually.
     * @param shouldSysUiMatchActivity Whether the status bar and navigation bar for the dialog
     *     window should be styled to match the {@code ownerActivity}.
     * @param popupMargin The margin for the context menu.
     * @param desiredPopupContentWidth The desired width for the content of the context menu.
     * @param touchEventDelegateView View View that is showing behind the context menu. If menu is
     *     shown as a popup without scrim, and this view is provided, the context menu will dispatch
     *     touch events other than ACTION_DOWN.
     * @param rect Rect location where context menu is triggered. If this menu is a popup, the
     *     coordinates are expected to be screen coordinates.
     */
    public ContextMenuDialog(
            Activity ownerActivity,
            int theme,
            int topMarginPx,
            int bottomMarginPx,
            View layout,
            View contentView,
            boolean isPopup,
            boolean shouldRemoveScrim,
            boolean shouldSysUiMatchActivity,
            @Nullable Integer popupMargin,
            @Nullable Integer desiredPopupContentWidth,
            @Nullable View touchEventDelegateView,
            Rect rect) {
        super(ownerActivity, theme);
        mActivity = ownerActivity;
        mTopMarginPx = topMarginPx;
        mBottomMarginPx = bottomMarginPx;
        mContentView = contentView;
        mLayout = layout;
        mIsPopup = isPopup;
        mShouldRemoveScrim = shouldRemoveScrim;
        mShouldSysUiMatchActivity = shouldSysUiMatchActivity;
        mPopupMargin = popupMargin;
        mDesiredPopupContentWidth = desiredPopupContentWidth;
        mTouchEventDelegateView = touchEventDelegateView;
        mRect = rect;
    }

    @Override
    public void onStart() {
        super.onStart();
        Window dialogWindow = getWindow();
        dialogWindow.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
        dialogWindow.setLayout(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
        if (mShouldRemoveScrim) {
            dialogWindow.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND);
            dialogWindow.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL);
        }
        if (mShouldRemoveScrim || mShouldSysUiMatchActivity) {
            dialogWindow.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
            // Set the navigation bar when API level >= 27 to match android:navigationBarColor
            // reference in styles.xml.
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
                dialogWindow.setNavigationBarColor(mActivity.getWindow().getNavigationBarColor());
                UiUtils.setNavigationBarIconColor(
                        dialogWindow.getDecorView(),
                        mActivity.getResources().getBoolean(R.bool.window_light_navigation_bar));
            }
            // Apply the status bar color in case the website had override them.
            UiUtils.setStatusBarColor(dialogWindow, mActivity.getWindow().getStatusBarColor());
            UiUtils.setStatusBarIconColor(
                    dialogWindow.getDecorView().getRootView(),
                    !ColorUtils.shouldUseLightForegroundOnBackground(
                            mActivity.getWindow().getStatusBarColor()));
        }

        // Both bottom margin and top margin must be set together to ensure default
        // values are not relied upon for custom behavior.
        if (mTopMarginPx != NO_CUSTOM_MARGIN && mBottomMarginPx != NO_CUSTOM_MARGIN) {
            // TODO(benwgold): Update to relative layout to avoid have to set fixed margin.
            FrameLayout.LayoutParams layoutParams =
                    (FrameLayout.LayoutParams) mContentView.getLayoutParams();
            if (layoutParams == null) return;
            layoutParams.bottomMargin = mBottomMarginPx;
            layoutParams.topMargin = mTopMarginPx;
        }

        mOnLayoutChangeListener =
                new OnLayoutChangeListener() {
                    @Override
                    public void onLayoutChange(
                            View v,
                            int left,
                            int top,
                            int right,
                            int bottom,
                            int oldLeft,
                            int oldTop,
                            int oldRight,
                            int oldBottom) {
                        // // If the layout size does not change (e.g. call due to #forceLayout), do
                        // nothing // because we don't want to dismiss the context menu.
                        if (left == oldLeft
                                && right == oldRight
                                && top == oldTop
                                && bottom == oldBottom) {
                            return;
                        }

                        if (mIsPopup) {
                            // If the menu is a popup, wait for the layout to be measured, then
                            // proceed with showing the popup window.
                            if (v.getMeasuredHeight() == 0) return;

                            // If dialog is showing and the layout changes, we might lost the anchor
                            // point.
                            // We'll dismiss the context menu and remove the listener.
                            if (mPopupWindow != null && mPopupWindow.isShowing()) {
                                dismiss();
                                return;
                            }
                            mPopupWindow =
                                    new AnchoredPopupWindow(
                                            mActivity,
                                            mLayout,
                                            new ColorDrawable(Color.TRANSPARENT),
                                            mContentView,
                                            new RectProvider(mRect));
                            if (mPopupMargin != null) {
                                mPopupWindow.setMargin(mPopupMargin);
                            }
                            if (mDesiredPopupContentWidth != null) {
                                mPopupWindow.setDesiredContentWidth(mDesiredPopupContentWidth);
                            }
                            mPopupWindow.setSmartAnchorWithMaxWidth(true);
                            mPopupWindow.setVerticalOverlapAnchor(true);
                            mPopupWindow.setOutsideTouchable(false);
                            mPopupWindow.setAnimateFromAnchor(true);
                            // Set popup focusable so the screen reader can announce the popup
                            // properly.
                            if (AccessibilityState.isScreenReaderEnabled()) {
                                mPopupWindow.setFocusable(true);
                            }
                            // If the popup is dismissed, dismiss this dialog as well. This is
                            // required when the popup is dismissed through backpress / hardware
                            // accessiries where the #dismiss is not triggered by #onTouchEvent.
                            mPopupWindow.addOnDismissListener(ContextMenuDialog.this::dismiss);
                            mPopupWindow.show();
                        } else {
                            // Otherwise, the menu will already be in the hierarchy, and we need to
                            // make sure the menu itself is measured before starting the
                            // animation.
                            if (v.getMeasuredHeight() == 0) return;

                            startEnterAnimation();
                            v.removeOnLayoutChangeListener(this);
                            mOnLayoutChangeListener = null;
                        }
                    }
                };
        (mIsPopup ? mLayout : mContentView).addOnLayoutChangeListener(mOnLayoutChangeListener);

        // Forward the drag events to delegate view if it is an DragEventDispatchDestination.
        if (isDialogNonModal()) {
            DragEventDispatchDestination dest =
                    DragEventDispatchDestination.from(mTouchEventDelegateView);
            if (dest != null) {
                mDragEventDispatchHelper = new DragEventDispatchHelper(mLayout, dest);
            }
        }
    }

    /**
     * Start the entering animation for context menu dialog. Only used when dialog is presenting
     * as a full screen dialog.
     */
    private void startEnterAnimation() {
        Rect windowRect = new Rect();
        Window window = mActivity.getWindow();
        window.getDecorView().getWindowVisibleDisplayFrame(windowRect);

        float xOffsetPx = windowRect.left;
        float yOffsetPx = windowRect.top;

        int[] currentLocationOnScreenPx = new int[2];
        mContentView.getLocationOnScreen(currentLocationOnScreenPx);

        mContextMenuFirstLocationYPx = currentLocationOnScreenPx[1];

        // Start entering animation from the center of where ContextMenu is triggered on screen.
        // Noting that the Rect already considered the top content offset of the content view that
        // context menu is hosted.
        mContextMenuSourceXPx = mRect.centerX() - currentLocationOnScreenPx[0] + xOffsetPx;
        mContextMenuSourceYPx = mRect.centerY() - currentLocationOnScreenPx[1] + yOffsetPx;

        Animation animation = getScaleAnimation(true, mContextMenuSourceXPx, mContextMenuSourceYPx);
        mContentView.startAnimation(animation);
    }

    @Override
    public void dismiss() {
        if (mIsPopup) {
            if (mPopupWindow != null) {
                mPopupWindow.dismiss();
                mPopupWindow = null;
            }
            if (mOnLayoutChangeListener != null) {
                mLayout.removeOnLayoutChangeListener(mOnLayoutChangeListener);
                mOnLayoutChangeListener = null;
            }
            if (mDragEventDispatchHelper != null) {
                mDragEventDispatchHelper.stop();
                mDragEventDispatchHelper = null;
            }
            super.dismiss();

            return;
        }

        if (mOnLayoutChangeListener != null) {
            mContentView.removeOnLayoutChangeListener(mOnLayoutChangeListener);
            mOnLayoutChangeListener = null;
        }
        int[] contextMenuFinalLocationPx = new int[2];
        mContentView.getLocationOnScreen(contextMenuFinalLocationPx);
        // Recalculate mContextMenuDestinationY because the context menu's final location may not be
        // the same as its first location if it changed in height.
        float contextMenuDestinationYPx =
                mContextMenuSourceYPx
                        + (mContextMenuFirstLocationYPx - contextMenuFinalLocationPx[1]);

        Animation exitAnimation =
                getScaleAnimation(false, mContextMenuSourceXPx, contextMenuDestinationYPx);
        exitAnimation.setAnimationListener(
                new EmptyAnimationListener() {
                    @Override
                    public void onAnimationEnd(Animation animation) {
                        ContextMenuDialog.super.dismiss();
                    }
                });
        mContentView.startAnimation(exitAnimation);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (event.getAction() == MotionEvent.ACTION_DOWN) {
            dismiss();
            return true;
        }
        if (isDialogNonModal() && mTouchEventDelegateView.isAttachedToWindow()) {
            return mTouchEventDelegateView.dispatchTouchEvent(event);
        }
        return false;
    }

    /**
     * @param isEnterAnimation Whether the animation to be returned is for showing the context menu
     *                         as opposed to hiding it.
     * @param pivotX The X coordinate of the point about which the object is being scaled, specified
     *               as an absolute number where 0 is the left edge.
     * @param pivotY The Y coordinate of the point about which the object is being scaled, specified
     *               as an absolute number where 0 is the top edge.
     * @return Returns the scale animation for the context menu.
     */
    private Animation getScaleAnimation(boolean isEnterAnimation, float pivotX, float pivotY) {
        float fromX = isEnterAnimation ? 0f : 1f;
        float toX = isEnterAnimation ? 1f : 0f;
        float fromY = fromX;
        float toY = toX;

        ScaleAnimation animation =
                new ScaleAnimation(
                        fromX,
                        toX,
                        fromY,
                        toY,
                        Animation.ABSOLUTE,
                        pivotX,
                        Animation.ABSOLUTE,
                        pivotY);

        long duration = isEnterAnimation ? ENTER_ANIMATION_DURATION_MS : EXIT_ANIMATION_DURATION_MS;
        float durationScale =
                Settings.Global.getFloat(
                        ContextUtils.getApplicationContext().getContentResolver(),
                        Settings.Global.ANIMATOR_DURATION_SCALE,
                        1f);

        animation.setDuration((long) (duration * durationScale));
        animation.setInterpolator(Interpolators.LINEAR_OUT_SLOW_IN_INTERPOLATOR);
        return animation;
    }

    private boolean isDialogNonModal() {
        return mIsPopup && mShouldRemoveScrim && mTouchEventDelegateView != null;
    }

    OnDragListener getOnDragListenerForTesting() {
        return mDragEventDispatchHelper;
    }
}