package com.android.internal.view.menu;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;

import android.annotation.AttrRes;
import android.annotation.IntDef;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.StyleRes;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Rect;
import android.os.Handler;
import android.os.Parcelable;
import android.os.SystemClock;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewTreeObserver;
import android.view.View.OnAttachStateChangeListener;
import android.view.View.OnKeyListener;
import android.view.ViewTreeObserver.OnGlobalLayoutListener;
import android.widget.AbsListView;
import android.widget.FrameLayout;
import android.widget.HeaderViewListAdapter;
import android.widget.ListAdapter;
import android.widget.MenuItemHoverListener;
import android.widget.ListView;
import android.widget.MenuPopupWindow;
import android.widget.PopupWindow;
import android.widget.PopupWindow.OnDismissListener;
import android.widget.TextView;

import com.android.internal.R;
import com.android.internal.util.Preconditions;

A popup for a menu which will allow multiple submenus to appear in a cascading fashion, side by side.
@hide
/** * A popup for a menu which will allow multiple submenus to appear in a cascading fashion, side by * side. * @hide */
final class CascadingMenuPopup extends MenuPopup implements MenuPresenter, OnKeyListener, PopupWindow.OnDismissListener { private static final int ITEM_LAYOUT = com.android.internal.R.layout.cascading_menu_item_layout; @Retention(RetentionPolicy.SOURCE) @IntDef({HORIZ_POSITION_LEFT, HORIZ_POSITION_RIGHT}) public @interface HorizPosition {} private static final int HORIZ_POSITION_LEFT = 0; private static final int HORIZ_POSITION_RIGHT = 1;
Delay between hovering over a menu item with a mouse and receiving side-effects (ex. opening a sub-menu or closing unrelated menus).
/** * Delay between hovering over a menu item with a mouse and receiving * side-effects (ex. opening a sub-menu or closing unrelated menus). */
private static final int SUBMENU_TIMEOUT_MS = 200; private final Context mContext; private final int mMenuMaxWidth; private final int mPopupStyleAttr; private final int mPopupStyleRes; private final boolean mOverflowOnly; private final Handler mSubMenuHoverHandler;
List of menus that were added before this popup was shown.
/** List of menus that were added before this popup was shown. */
private final List<MenuBuilder> mPendingMenus = new LinkedList<>();
List of open menus. The first item is the root menu and each subsequent item is a direct submenu of the previous item.
/** * List of open menus. The first item is the root menu and each * subsequent item is a direct submenu of the previous item. */
private final List<CascadingMenuInfo> mShowingMenus = new ArrayList<>(); private final OnGlobalLayoutListener mGlobalLayoutListener = new OnGlobalLayoutListener() { @Override public void onGlobalLayout() { // Only move the popup if it's showing and non-modal. We don't want // to be moving around the only interactive window, since there's a // good chance the user is interacting with it. if (isShowing() && mShowingMenus.size() > 0 && !mShowingMenus.get(0).window.isModal()) { final View anchor = mShownAnchorView; if (anchor == null || !anchor.isShown()) { dismiss(); } else { // Recompute window sizes and positions. for (CascadingMenuInfo info : mShowingMenus) { info.window.show(); } } } } }; private final OnAttachStateChangeListener mAttachStateChangeListener = new OnAttachStateChangeListener() { @Override public void onViewAttachedToWindow(View v) { } @Override public void onViewDetachedFromWindow(View v) { if (mTreeObserver != null) { if (!mTreeObserver.isAlive()) { mTreeObserver = v.getViewTreeObserver(); } mTreeObserver.removeGlobalOnLayoutListener(mGlobalLayoutListener); } v.removeOnAttachStateChangeListener(this); } }; private final MenuItemHoverListener mMenuItemHoverListener = new MenuItemHoverListener() { @Override public void onItemHoverExit(@NonNull MenuBuilder menu, @NonNull MenuItem item) { // If the mouse moves between two windows, hover enter/exit pairs // may be received out of order. So, instead of canceling all // pending runnables, only cancel runnables for the host menu. mSubMenuHoverHandler.removeCallbacksAndMessages(menu); } @Override public void onItemHoverEnter( @NonNull final MenuBuilder menu, @NonNull final MenuItem item) { // Something new was hovered, cancel all scheduled runnables. mSubMenuHoverHandler.removeCallbacksAndMessages(null); // Find the position of the hovered menu within the added menus. int menuIndex = -1; for (int i = 0, count = mShowingMenus.size(); i < count; i++) { if (menu == mShowingMenus.get(i).menu) { menuIndex = i; break; } } if (menuIndex == -1) { return; } final CascadingMenuInfo nextInfo; final int nextIndex = menuIndex + 1; if (nextIndex < mShowingMenus.size()) { nextInfo = mShowingMenus.get(nextIndex); } else { nextInfo = null; } final Runnable runnable = new Runnable() { @Override public void run() { // Close any other submenus that might be open at the // current or a deeper level. if (nextInfo != null) { // Disable exit animations to prevent overlapping // fading out submenus. mShouldCloseImmediately = true; nextInfo.menu.close(false /* closeAllMenus */); mShouldCloseImmediately = false; } // Then open the selected submenu, if there is one. if (item.isEnabled() && item.hasSubMenu()) { menu.performItemAction(item, 0); } } }; final long uptimeMillis = SystemClock.uptimeMillis() + SUBMENU_TIMEOUT_MS; mSubMenuHoverHandler.postAtTime(runnable, menu, uptimeMillis); } }; private int mRawDropDownGravity = Gravity.NO_GRAVITY; private int mDropDownGravity = Gravity.NO_GRAVITY; private View mAnchorView; private View mShownAnchorView; private int mLastPosition; private boolean mHasXOffset; private boolean mHasYOffset; private int mXOffset; private int mYOffset; private boolean mForceShowIcon; private boolean mShowTitle; private Callback mPresenterCallback; private ViewTreeObserver mTreeObserver; private PopupWindow.OnDismissListener mOnDismissListener;
Whether popup menus should disable exit animations when closing.
/** Whether popup menus should disable exit animations when closing. */
private boolean mShouldCloseImmediately;
Initializes a new cascading-capable menu popup.
Params:
/** * Initializes a new cascading-capable menu popup. * * @param anchor A parent view to get the {@link android.view.View#getWindowToken()} token from. */
public CascadingMenuPopup(@NonNull Context context, @NonNull View anchor, @AttrRes int popupStyleAttr, @StyleRes int popupStyleRes, boolean overflowOnly) { mContext = Preconditions.checkNotNull(context); mAnchorView = Preconditions.checkNotNull(anchor); mPopupStyleAttr = popupStyleAttr; mPopupStyleRes = popupStyleRes; mOverflowOnly = overflowOnly; mForceShowIcon = false; mLastPosition = getInitialMenuPosition(); final Resources res = context.getResources(); mMenuMaxWidth = Math.max(res.getDisplayMetrics().widthPixels / 2, res.getDimensionPixelSize(com.android.internal.R.dimen.config_prefDialogWidth)); mSubMenuHoverHandler = new Handler(); } @Override public void setForceShowIcon(boolean forceShow) { mForceShowIcon = forceShow; } private MenuPopupWindow createPopupWindow() { MenuPopupWindow popupWindow = new MenuPopupWindow( mContext, null, mPopupStyleAttr, mPopupStyleRes); popupWindow.setHoverListener(mMenuItemHoverListener); popupWindow.setOnItemClickListener(this); popupWindow.setOnDismissListener(this); popupWindow.setAnchorView(mAnchorView); popupWindow.setDropDownGravity(mDropDownGravity); popupWindow.setModal(true); popupWindow.setInputMethodMode(PopupWindow.INPUT_METHOD_NOT_NEEDED); return popupWindow; } @Override public void show() { if (isShowing()) { return; } // Display all pending menus. for (MenuBuilder menu : mPendingMenus) { showMenu(menu); } mPendingMenus.clear(); mShownAnchorView = mAnchorView; if (mShownAnchorView != null) { final boolean addGlobalListener = mTreeObserver == null; mTreeObserver = mShownAnchorView.getViewTreeObserver(); // Refresh to latest if (addGlobalListener) { mTreeObserver.addOnGlobalLayoutListener(mGlobalLayoutListener); } mShownAnchorView.addOnAttachStateChangeListener(mAttachStateChangeListener); } } @Override public void dismiss() { // Need to make another list to avoid a concurrent modification // exception, as #onDismiss may clear mPopupWindows while we are // iterating. Remove from the last added menu so that the callbacks // are received in order from foreground to background. final int length = mShowingMenus.size(); if (length > 0) { final CascadingMenuInfo[] addedMenus = mShowingMenus.toArray(new CascadingMenuInfo[length]); for (int i = length - 1; i >= 0; i--) { final CascadingMenuInfo info = addedMenus[i]; if (info.window.isShowing()) { info.window.dismiss(); } } } } @Override public boolean onKey(View v, int keyCode, KeyEvent event) { if (event.getAction() == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_MENU) { dismiss(); return true; } return false; }
Determines the proper initial menu position for the current LTR/RTL configuration.
Returns:The initial position.
/** * Determines the proper initial menu position for the current LTR/RTL configuration. * @return The initial position. */
@HorizPosition private int getInitialMenuPosition() { final int layoutDirection = mAnchorView.getLayoutDirection(); return layoutDirection == View.LAYOUT_DIRECTION_RTL ? HORIZ_POSITION_LEFT : HORIZ_POSITION_RIGHT; }
Determines whether the next submenu (of the given width) should display on the right or on the left of the most recent menu.
Params:
  • nextMenuWidth – Width of the next submenu to display.
Returns:The position to display it.
/** * Determines whether the next submenu (of the given width) should display on the right or on * the left of the most recent menu. * * @param nextMenuWidth Width of the next submenu to display. * @return The position to display it. */
@HorizPosition private int getNextMenuPosition(int nextMenuWidth) { ListView lastListView = mShowingMenus.get(mShowingMenus.size() - 1).getListView(); final int[] screenLocation = new int[2]; lastListView.getLocationOnScreen(screenLocation); final Rect displayFrame = new Rect(); mShownAnchorView.getWindowVisibleDisplayFrame(displayFrame); if (mLastPosition == HORIZ_POSITION_RIGHT) { final int right = screenLocation[0] + lastListView.getWidth() + nextMenuWidth; if (right > displayFrame.right) { return HORIZ_POSITION_LEFT; } return HORIZ_POSITION_RIGHT; } else { // LEFT final int left = screenLocation[0] - nextMenuWidth; if (left < 0) { return HORIZ_POSITION_RIGHT; } return HORIZ_POSITION_LEFT; } } @Override public void addMenu(MenuBuilder menu) { menu.addMenuPresenter(this, mContext); if (isShowing()) { showMenu(menu); } else { mPendingMenus.add(menu); } }
Prepares and shows the specified menu immediately.
Params:
  • menu – the menu to show
/** * Prepares and shows the specified menu immediately. * * @param menu the menu to show */
private void showMenu(@NonNull MenuBuilder menu) { final LayoutInflater inflater = LayoutInflater.from(mContext); final MenuAdapter adapter = new MenuAdapter(menu, inflater, mOverflowOnly, ITEM_LAYOUT); // Apply "force show icon" setting. There are 3 cases: // (1) This is the top level menu and icon spacing is forced. Add spacing. // (2) This is a submenu. Add spacing if any of the visible menu items has an icon. // (3) This is the top level menu and icon spacing isn't forced. Do not add spacing. if (!isShowing() && mForceShowIcon) { // Case 1 adapter.setForceShowIcon(true); } else if (isShowing()) { // Case 2 adapter.setForceShowIcon(MenuPopup.shouldPreserveIconSpacing(menu)); } // Case 3: Else, don't allow spacing for icons (default behavior; do nothing). final int menuWidth = measureIndividualMenuWidth(adapter, null, mContext, mMenuMaxWidth); final MenuPopupWindow popupWindow = createPopupWindow(); popupWindow.setAdapter(adapter); popupWindow.setContentWidth(menuWidth); popupWindow.setDropDownGravity(mDropDownGravity); final CascadingMenuInfo parentInfo; final View parentView; if (mShowingMenus.size() > 0) { parentInfo = mShowingMenus.get(mShowingMenus.size() - 1); parentView = findParentViewForSubmenu(parentInfo, menu); } else { parentInfo = null; parentView = null; } if (parentView != null) { // This menu is a cascading submenu anchored to a parent view. popupWindow.setAnchorView(parentView); popupWindow.setTouchModal(false); popupWindow.setEnterTransition(null); final @HorizPosition int nextMenuPosition = getNextMenuPosition(menuWidth); final boolean showOnRight = nextMenuPosition == HORIZ_POSITION_RIGHT; mLastPosition = nextMenuPosition; // Compute the horizontal offset to display the submenu to the right or to the left // of the parent item. // By now, mDropDownGravity is the resolved absolute gravity, so // this should work in both LTR and RTL. final int x; if ((mDropDownGravity & Gravity.RIGHT) == Gravity.RIGHT) { if (showOnRight) { x = menuWidth; } else { x = -parentView.getWidth(); } } else { if (showOnRight) { x = parentView.getWidth(); } else { x = -menuWidth; } } popupWindow.setHorizontalOffset(x); // Align with the top edge of the parent view (or the bottom edge when the submenu is // flipped vertically). popupWindow.setOverlapAnchor(true); popupWindow.setVerticalOffset(0); } else { if (mHasXOffset) { popupWindow.setHorizontalOffset(mXOffset); } if (mHasYOffset) { popupWindow.setVerticalOffset(mYOffset); } final Rect epicenterBounds = getEpicenterBounds(); popupWindow.setEpicenterBounds(epicenterBounds); } final CascadingMenuInfo menuInfo = new CascadingMenuInfo(popupWindow, menu, mLastPosition); mShowingMenus.add(menuInfo); popupWindow.show(); final ListView listView = popupWindow.getListView(); listView.setOnKeyListener(this); // If this is the root menu, show the title if requested. if (parentInfo == null && mShowTitle && menu.getHeaderTitle() != null) { final FrameLayout titleItemView = (FrameLayout) inflater.inflate( R.layout.popup_menu_header_item_layout, listView, false); final TextView titleView = (TextView) titleItemView.findViewById(R.id.title); titleItemView.setEnabled(false); titleView.setText(menu.getHeaderTitle()); listView.addHeaderView(titleItemView, null, false); // Show again to update the title. popupWindow.show(); } }
Returns the menu item within the specified parent menu that owns specified submenu.
Params:
  • parent – the parent menu
  • submenu – the submenu for which the index should be returned
Returns:the menu item that owns the submenu, or null if not present
/** * Returns the menu item within the specified parent menu that owns * specified submenu. * * @param parent the parent menu * @param submenu the submenu for which the index should be returned * @return the menu item that owns the submenu, or {@code null} if not * present */
private MenuItem findMenuItemForSubmenu( @NonNull MenuBuilder parent, @NonNull MenuBuilder submenu) { for (int i = 0, count = parent.size(); i < count; i++) { final MenuItem item = parent.getItem(i); if (item.hasSubMenu() && submenu == item.getSubMenu()) { return item; } } return null; }
Attempts to find the view for the menu item that owns the specified submenu.
Params:
  • parentInfo – info for the parent menu
  • submenu – the submenu whose parent view should be obtained
Returns:the parent view, or null if one could not be found
/** * Attempts to find the view for the menu item that owns the specified * submenu. * * @param parentInfo info for the parent menu * @param submenu the submenu whose parent view should be obtained * @return the parent view, or {@code null} if one could not be found */
@Nullable private View findParentViewForSubmenu( @NonNull CascadingMenuInfo parentInfo, @NonNull MenuBuilder submenu) { final MenuItem owner = findMenuItemForSubmenu(parentInfo.menu, submenu); if (owner == null) { // Couldn't find the submenu owner. return null; } // The adapter may be wrapped. Adjust the index if necessary. final int headersCount; final MenuAdapter menuAdapter; final ListView listView = parentInfo.getListView(); final ListAdapter listAdapter = listView.getAdapter(); if (listAdapter instanceof HeaderViewListAdapter) { final HeaderViewListAdapter headerAdapter = (HeaderViewListAdapter) listAdapter; headersCount = headerAdapter.getHeadersCount(); menuAdapter = (MenuAdapter) headerAdapter.getWrappedAdapter(); } else { headersCount = 0; menuAdapter = (MenuAdapter) listAdapter; } // Find the index within the menu adapter's data set of the menu item. int ownerPosition = AbsListView.INVALID_POSITION; for (int i = 0, count = menuAdapter.getCount(); i < count; i++) { if (owner == menuAdapter.getItem(i)) { ownerPosition = i; break; } } if (ownerPosition == AbsListView.INVALID_POSITION) { // Couldn't find the owner within the menu adapter. return null; } // Adjust the index for the adapter used to display views. ownerPosition += headersCount; // Adjust the index for the visible views. final int ownerViewPosition = ownerPosition - listView.getFirstVisiblePosition(); if (ownerViewPosition < 0 || ownerViewPosition >= listView.getChildCount()) { // Not visible on screen. return null; } return listView.getChildAt(ownerViewPosition); }
Returns:true if the popup is currently showing, false otherwise.
/** * @return {@code true} if the popup is currently showing, {@code false} otherwise. */
@Override public boolean isShowing() { return mShowingMenus.size() > 0 && mShowingMenus.get(0).window.isShowing(); }
Called when one or more of the popup windows was dismissed.
/** * Called when one or more of the popup windows was dismissed. */
@Override public void onDismiss() { // The dismiss listener doesn't pass the calling window, so walk // through the stack to figure out which one was just dismissed. CascadingMenuInfo dismissedInfo = null; for (int i = 0, count = mShowingMenus.size(); i < count; i++) { final CascadingMenuInfo info = mShowingMenus.get(i); if (!info.window.isShowing()) { dismissedInfo = info; break; } } // Close all menus starting from the dismissed menu, passing false // since we are manually closing only a subset of windows. if (dismissedInfo != null) { dismissedInfo.menu.close(false); } } @Override public void updateMenuView(boolean cleared) { for (CascadingMenuInfo info : mShowingMenus) { toMenuAdapter(info.getListView().getAdapter()).notifyDataSetChanged(); } } @Override public void setCallback(Callback cb) { mPresenterCallback = cb; } @Override public boolean onSubMenuSelected(SubMenuBuilder subMenu) { // Don't allow double-opening of the same submenu. for (CascadingMenuInfo info : mShowingMenus) { if (subMenu == info.menu) { // Just re-focus that one. info.getListView().requestFocus(); return true; } } if (subMenu.hasVisibleItems()) { addMenu(subMenu); if (mPresenterCallback != null) { mPresenterCallback.onOpenSubMenu(subMenu); } return true; } return false; }
Finds the index of the specified menu within the list of added menus.
Params:
  • menu – the menu to find
Returns:the index of the menu, or -1 if not present
/** * Finds the index of the specified menu within the list of added menus. * * @param menu the menu to find * @return the index of the menu, or {@code -1} if not present */
private int findIndexOfAddedMenu(@NonNull MenuBuilder menu) { for (int i = 0, count = mShowingMenus.size(); i < count; i++) { final CascadingMenuInfo info = mShowingMenus.get(i); if (menu == info.menu) { return i; } } return -1; } @Override public void onCloseMenu(MenuBuilder menu, boolean allMenusAreClosing) { final int menuIndex = findIndexOfAddedMenu(menu); if (menuIndex < 0) { return; } // Recursively close descendant menus. final int nextMenuIndex = menuIndex + 1; if (nextMenuIndex < mShowingMenus.size()) { final CascadingMenuInfo childInfo = mShowingMenus.get(nextMenuIndex); childInfo.menu.close(false /* closeAllMenus */); } // Close the target menu. final CascadingMenuInfo info = mShowingMenus.remove(menuIndex); info.menu.removeMenuPresenter(this); if (mShouldCloseImmediately) { // Disable all exit animations. info.window.setExitTransition(null); info.window.setAnimationStyle(0); } info.window.dismiss(); final int count = mShowingMenus.size(); if (count > 0) { mLastPosition = mShowingMenus.get(count - 1).position; } else { mLastPosition = getInitialMenuPosition(); } if (count == 0) { // This was the last window. Clean up. dismiss(); if (mPresenterCallback != null) { mPresenterCallback.onCloseMenu(menu, true); } if (mTreeObserver != null) { if (mTreeObserver.isAlive()) { mTreeObserver.removeGlobalOnLayoutListener(mGlobalLayoutListener); } mTreeObserver = null; } mShownAnchorView.removeOnAttachStateChangeListener(mAttachStateChangeListener); // If every [sub]menu was dismissed, that means the whole thing was // dismissed, so notify the owner. mOnDismissListener.onDismiss(); } else if (allMenusAreClosing) { // Close all menus starting from the root. This will recursively // close any remaining menus, so we don't need to propagate the // "closeAllMenus" flag. The last window will clean up. final CascadingMenuInfo rootInfo = mShowingMenus.get(0); rootInfo.menu.close(false /* closeAllMenus */); } } @Override public boolean flagActionItems() { return false; } @Override public Parcelable onSaveInstanceState() { return null; } @Override public void onRestoreInstanceState(Parcelable state) { } @Override public void setGravity(int dropDownGravity) { if (mRawDropDownGravity != dropDownGravity) { mRawDropDownGravity = dropDownGravity; mDropDownGravity = Gravity.getAbsoluteGravity( dropDownGravity, mAnchorView.getLayoutDirection()); } } @Override public void setAnchorView(@NonNull View anchor) { if (mAnchorView != anchor) { mAnchorView = anchor; // Gravity resolution may have changed, update from raw gravity. mDropDownGravity = Gravity.getAbsoluteGravity( mRawDropDownGravity, mAnchorView.getLayoutDirection()); } } @Override public void setOnDismissListener(OnDismissListener listener) { mOnDismissListener = listener; } @Override public ListView getListView() { return mShowingMenus.isEmpty() ? null : mShowingMenus.get(mShowingMenus.size() - 1).getListView(); } @Override public void setHorizontalOffset(int x) { mHasXOffset = true; mXOffset = x; } @Override public void setVerticalOffset(int y) { mHasYOffset = true; mYOffset = y; } @Override public void setShowTitle(boolean showTitle) { mShowTitle = showTitle; } private static class CascadingMenuInfo { public final MenuPopupWindow window; public final MenuBuilder menu; public final int position; public CascadingMenuInfo(@NonNull MenuPopupWindow window, @NonNull MenuBuilder menu, int position) { this.window = window; this.menu = menu; this.position = position; } public ListView getListView() { return window.getListView(); } } }