blob: a31031c66bcb86942f786f4e44731917c7fdcf8c [file] [log] [blame]
// Copyright 2011 The Chromium Authors. All rights reserved.
// 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.appmenu;
import android.animation.Animator;
import android.animation.Animator.AnimatorListener;
import android.animation.AnimatorSet;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import android.os.Build;
import android.support.annotation.IdRes;
import android.support.annotation.Nullable;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuItem;
import android.view.Surface;
import android.view.View;
import android.view.View.MeasureSpec;
import android.view.View.OnKeyListener;
import android.view.ViewGroup;
import android.view.ViewStub;
import android.view.WindowManager;
import android.widget.AdapterView;
import android.widget.AdapterView.OnItemClickListener;
import android.widget.ImageButton;
import android.widget.ListView;
import android.widget.PopupWindow;
import org.chromium.base.AnimationFrameTimeHistogram;
import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.base.ContextUtils;
import org.chromium.base.SysUtils;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.omaha.UpdateMenuItemHelper;
import org.chromium.chrome.browser.util.AccessibilityUtil;
import org.chromium.chrome.browser.widget.PulseDrawable;
import java.util.ArrayList;
import java.util.List;
/**
* Shows a popup of menuitems anchored to a host view. When a item is selected we call
* Activity.onOptionsItemSelected with the appropriate MenuItem.
* - Only visible MenuItems are shown.
* - Disabled items are grayed out.
*/
public class AppMenu implements OnItemClickListener, OnKeyListener {
private static final float LAST_ITEM_SHOW_FRACTION = 0.5f;
private final Menu mMenu;
private final int mItemRowHeight;
private final int mItemDividerHeight;
private final int mVerticalFadeDistance;
private final int mNegativeSoftwareVerticalOffset;
private final int[] mTempLocation;
private final boolean mTranslateMenuItemsOnShow;
private PopupWindow mPopup;
private ListView mListView;
private AppMenuAdapter mAdapter;
private AppMenuHandler mHandler;
private View mFooterView;
private int mCurrentScreenRotation = -1;
private boolean mIsByPermanentButton;
private AnimatorSet mMenuItemEnterAnimator;
private AnimatorListener mAnimationHistogramRecorder = AnimationFrameTimeHistogram
.getAnimatorRecorder("WrenchMenu.OpeningAnimationFrameTimes");
/**
* Creates and sets up the App Menu.
* @param menu Original menu created by the framework.
* @param itemRowHeight Desired height for each app menu row.
* @param itemDividerHeight Desired height for the divider between app menu items.
* @param handler AppMenuHandler receives callbacks from AppMenu.
* @param res Resources object used to get dimensions and style attributes.
* @param translateMenuItemsOnShow Whether menu items should be translated during the animation
* that is run when the menu is shown.
*/
AppMenu(Menu menu, int itemRowHeight, int itemDividerHeight, AppMenuHandler handler,
Resources res, boolean translateMenuItemsOnShow) {
mMenu = menu;
mItemRowHeight = itemRowHeight;
assert mItemRowHeight > 0;
mHandler = handler;
mItemDividerHeight = itemDividerHeight;
assert mItemDividerHeight >= 0;
mNegativeSoftwareVerticalOffset =
res.getDimensionPixelSize(R.dimen.menu_negative_software_vertical_offset);
mVerticalFadeDistance = res.getDimensionPixelSize(R.dimen.menu_vertical_fade_distance);
mTempLocation = new int[2];
mTranslateMenuItemsOnShow = translateMenuItemsOnShow;
}
/**
* Notifies the menu that the contents of the menu item specified by {@code menuRowId} have
* changed. This should be called if icons, titles, etc. are changing for a particular menu
* item while the menu is open.
* @param menuRowId The id of the menu item to change. This must be a row id and not a child
* id.
*/
public void menuItemContentChanged(int menuRowId) {
// Make sure we have all the valid state objects we need.
if (mAdapter == null || mMenu == null || mPopup == null || mListView == null) {
return;
}
// Calculate the item index.
int index = -1;
int menuSize = mMenu.size();
for (int i = 0; i < menuSize; i++) {
if (mMenu.getItem(i).getItemId() == menuRowId) {
index = i;
break;
}
}
if (index == -1) return;
// Check if the item is visible.
int startIndex = mListView.getFirstVisiblePosition();
int endIndex = mListView.getLastVisiblePosition();
if (index < startIndex || index > endIndex) return;
// Grab the correct View.
View view = mListView.getChildAt(index - startIndex);
if (view == null) return;
// Cause the Adapter to re-populate the View.
mListView.getAdapter().getView(index, view, mListView);
}
/**
* Creates and shows the app menu anchored to the specified view.
*
* @param context The context of the AppMenu (ensure the proper theme is set on this
* context).
* @param anchorView The anchor {@link View} of the {@link ListPopupWindow}.
* @param isByPermanentButton Whether or not permanent hardware button triggered it. (oppose to
* software button or keyboard).
* @param screenRotation Current device screen rotation.
* @param visibleDisplayFrame The display area rect in which AppMenu is supposed to fit in.
* @param screenHeight Current device screen height.
* @param footerResourceId The resource id for a view to add to the end of the menu list. Can
* be 0 if no such view is required.
* @param highlightedItemId The resource id of the menu item that should be highlighted. Can
* be {@code null} if no item should be highlighted. Note that
* {@code 0} is dedicated to custom menu items and can be declared by
* external apps.
*/
@SuppressLint("ResourceType")
void show(Context context, final View anchorView, boolean isByPermanentButton,
int screenRotation, Rect visibleDisplayFrame, int screenHeight,
@IdRes int footerResourceId, Integer highlightedItemId) {
mPopup = new PopupWindow(context);
mPopup.setFocusable(true);
if (!isByPermanentButton) mPopup.setClippingEnabled(false);
mPopup.setInputMethodMode(PopupWindow.INPUT_METHOD_NOT_NEEDED);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// The window layout type affects the z-index of the popup window on M+.
mPopup.setWindowLayoutType(WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL);
}
boolean anchorAtBottom = isAnchorAtBottom(anchorView, visibleDisplayFrame);
int footerHeight = 0;
mPopup.setOnDismissListener(() -> {
if (anchorView instanceof ImageButton) {
((ImageButton) anchorView).setSelected(false);
}
if (mMenuItemEnterAnimator != null) mMenuItemEnterAnimator.cancel();
mHandler.appMenuDismissed();
mHandler.onMenuVisibilityChanged(false);
});
// Some OEMs don't actually let us change the background... but they still return the
// padding of the new background, which breaks the menu height. If we still have a
// drawable here even though our style says @null we should use this padding instead...
Drawable originalBgDrawable = mPopup.getBackground();
// Need to explicitly set the background here. Relying on it being set in the style caused
// an incorrectly drawn background.
if (isByPermanentButton) {
mPopup.setBackgroundDrawable(
ApiCompatibilityUtils.getDrawable(context.getResources(), R.drawable.menu_bg));
} else {
mPopup.setBackgroundDrawable(ApiCompatibilityUtils.getDrawable(
context.getResources(), R.drawable.edge_menu_bg));
mPopup.setAnimationStyle(
anchorAtBottom ? R.style.OverflowMenuAnimBottom : R.style.OverflowMenuAnim);
}
// Turn off window animations for low end devices.
if (SysUtils.isLowEndDevice()) mPopup.setAnimationStyle(0);
Rect bgPadding = new Rect();
mPopup.getBackground().getPadding(bgPadding);
int menuWidth = context.getResources().getDimensionPixelSize(R.dimen.menu_width);
int popupWidth = menuWidth + bgPadding.left + bgPadding.right;
mPopup.setWidth(popupWidth);
mCurrentScreenRotation = screenRotation;
mIsByPermanentButton = isByPermanentButton;
// Extract visible items from the Menu.
int numItems = mMenu.size();
List<MenuItem> menuItems = new ArrayList<MenuItem>();
for (int i = 0; i < numItems; ++i) {
MenuItem item = mMenu.getItem(i);
if (item.isVisible()) {
menuItems.add(item);
}
}
Rect sizingPadding = new Rect(bgPadding);
if (isByPermanentButton && originalBgDrawable != null) {
Rect originalPadding = new Rect();
originalBgDrawable.getPadding(originalPadding);
sizingPadding.top = originalPadding.top;
sizingPadding.bottom = originalPadding.bottom;
}
// A List adapter for visible items in the Menu. The first row is added as a header to the
// list view.
mAdapter = new AppMenuAdapter(this, menuItems, LayoutInflater.from(context),
highlightedItemId, mTranslateMenuItemsOnShow);
ViewGroup contentView =
(ViewGroup) LayoutInflater.from(context).inflate(R.layout.app_menu_layout, null);
mListView = (ListView) contentView.findViewById(R.id.app_menu_list);
mListView.setAdapter(mAdapter);
if (footerResourceId != 0) {
// TODO(crbug.com/635567): Fix lint error properly.
ViewStub footerStub = (ViewStub) contentView.findViewById(R.id.app_menu_footer_stub);
footerStub.setLayoutResource(footerResourceId);
mFooterView = footerStub.inflate();
int heightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED);
int widthMeasureSpec = MeasureSpec.makeMeasureSpec(menuWidth, MeasureSpec.EXACTLY);
mFooterView.measure(widthMeasureSpec, heightMeasureSpec);
footerHeight = mFooterView.getMeasuredHeight();
highlightViewInFooter(highlightedItemId, mFooterView);
} else {
mFooterView = null;
}
int popupHeight = setMenuHeight(menuItems.size(), visibleDisplayFrame, screenHeight,
sizingPadding, footerHeight, anchorView);
int[] popupPosition = getPopupPosition(mCurrentScreenRotation, visibleDisplayFrame,
sizingPadding, anchorView, anchorAtBottom, popupWidth, popupHeight);
mPopup.setContentView(contentView);
mPopup.showAtLocation(
anchorView.getRootView(), Gravity.NO_GRAVITY, popupPosition[0], popupPosition[1]);
mListView.setOnItemClickListener(this);
mListView.setItemsCanFocus(true);
mListView.setOnKeyListener(this);
mHandler.onMenuVisibilityChanged(true);
if (mVerticalFadeDistance > 0) {
mListView.setVerticalFadingEdgeEnabled(true);
mListView.setFadingEdgeLength(mVerticalFadeDistance);
}
// Don't animate the menu items for low end devices.
if (!SysUtils.isLowEndDevice()) {
mListView.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
@Override
public void onLayoutChange(View v, int left, int top, int right, int bottom,
int oldLeft, int oldTop, int oldRight, int oldBottom) {
mListView.removeOnLayoutChangeListener(this);
runMenuItemEnterAnimations();
}
});
}
}
/**
* Highlights the given {@code footerView} or one of its child. If {@code highlightedItemId} is
* same as the id of the {@code footerView}, the entire {@code footerView} will be highlighted.
* Otherwise it will only use a circle pulse around the individual child view.
* @param highlightedItemId The resource id of the view that should be highlighted. Can be
* {@code null} if no item should be highlighted.
* @param footerView The root view in which the {@code highlightedItemId} is to be found.
*/
private void highlightViewInFooter(Integer highlightedItemId, View footerView) {
if (highlightedItemId == null) return;
View view = footerView.findViewById(highlightedItemId);
if (view == null) return;
PulseDrawable pulse = view == footerView
? PulseDrawable.createHighlight()
: PulseDrawable.createCircle(footerView.getContext());
Drawable newBackground = pulse;
Drawable currentBackground = view.getBackground();
if (currentBackground != null && currentBackground.getConstantState() != null) {
Drawable backgroundClone =
currentBackground.getConstantState().newDrawable(footerView.getResources());
newBackground = new LayerDrawable(new Drawable[] {backgroundClone, pulse});
}
view.setBackground(newBackground);
pulse.start();
}
/**
* @return The footer view for the menu or null if one has not been set.
*/
@Nullable
public View getFooterView() {
return mFooterView;
}
private boolean isAnchorAtBottom(View anchorView, Rect visibleDisplayFrame) {
anchorView.getLocationOnScreen(mTempLocation);
return (mTempLocation[1] + anchorView.getHeight()) >= visibleDisplayFrame.bottom;
}
private int[] getPopupPosition(int screenRotation, Rect appRect, Rect padding, View anchorView,
boolean anchorAtBottom, int popupWidth, int popupHeight) {
anchorView.getLocationInWindow(mTempLocation);
int anchorViewX = mTempLocation[0];
int anchorViewY = mTempLocation[1];
int[] offsets = new int[2];
// If we have a hardware menu button, locate the app menu closer to the estimated
// hardware menu button location.
if (mIsByPermanentButton) {
int horizontalOffset = -anchorViewX;
switch (screenRotation) {
case Surface.ROTATION_0:
case Surface.ROTATION_180:
horizontalOffset += (appRect.width() - popupWidth) / 2;
break;
case Surface.ROTATION_90:
horizontalOffset += appRect.width() - popupWidth;
break;
case Surface.ROTATION_270:
break;
default:
assert false;
break;
}
offsets[0] = horizontalOffset;
// The menu is displayed above the anchored view, so shift the menu up by the bottom
// padding of the background.
offsets[1] = -padding.bottom;
} else {
offsets[1] = -mNegativeSoftwareVerticalOffset;
// If the anchor is at the bottom of the screen, align the popup with the bottom of the
// anchor. The anchor may not be fully visible, so
// (appRect.bottom - anchorViewLocationOnScreenY) is used to determine the visible
// bottom edge of the anchor view.
if (anchorAtBottom) {
Rect bgPadding = new Rect();
mPopup.getBackground().getPadding(bgPadding);
anchorView.getLocationOnScreen(mTempLocation);
int anchorViewLocationOnScreenY = mTempLocation[1];
offsets[1] += appRect.bottom - anchorViewLocationOnScreenY - popupHeight;
if (!mIsByPermanentButton) offsets[1] += bgPadding.height();
}
if (!ApiCompatibilityUtils.isLayoutRtl(anchorView.getRootView())) {
offsets[0] = anchorView.getWidth() - popupWidth;
}
}
int xPos = anchorViewX + offsets[0];
int yPos = anchorViewY + offsets[1];
int[] position = {xPos, yPos};
return position;
}
/**
* Handles clicks on the AppMenu popup.
* @param menuItem The menu item in the popup that was clicked.
*/
void onItemClick(MenuItem menuItem) {
if (menuItem.isEnabled()) {
if (menuItem.getItemId() == R.id.update_menu_id) {
UpdateMenuItemHelper.getInstance().setMenuItemClicked();
}
dismiss();
mHandler.onOptionsItemSelected(menuItem);
}
}
/**
* Handles long clicks on image buttons on the AppMenu popup.
* @param menuItem The menu item in the popup that was long clicked.
* @param view The anchor view of the menu item.
*/
boolean onItemLongClick(MenuItem menuItem, View view) {
if (!menuItem.isEnabled()) return false;
String description = null;
Context context = ContextUtils.getApplicationContext();
Resources resources = context.getResources();
final int itemId = menuItem.getItemId();
if (itemId == R.id.forward_menu_id) {
description = resources.getString(R.string.menu_forward);
} else if (itemId == R.id.bookmark_this_page_id) {
description = resources.getString(R.string.menu_bookmark);
} else if (itemId == R.id.offline_page_id) {
description = resources.getString(R.string.menu_download);
} else if (itemId == R.id.info_menu_id) {
description = resources.getString(R.string.menu_page_info);
} else if (itemId == R.id.reload_menu_id) {
description = (menuItem.getIcon().getLevel()
== resources.getInteger(R.integer.reload_button_level_reload))
? resources.getString(R.string.menu_refresh)
: resources.getString(R.string.menu_stop_refresh);
}
return AccessibilityUtil.showAccessibilityToast(context, view, description);
}
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
onItemClick(mAdapter.getItem(position));
}
@Override
public boolean onKey(View v, int keyCode, KeyEvent event) {
if (mListView == null) return false;
if (event.getKeyCode() == KeyEvent.KEYCODE_MENU) {
if (event.getAction() == KeyEvent.ACTION_DOWN && event.getRepeatCount() == 0) {
event.startTracking();
v.getKeyDispatcherState().startTracking(event, this);
return true;
} else if (event.getAction() == KeyEvent.ACTION_UP) {
v.getKeyDispatcherState().handleUpEvent(event);
if (event.isTracking() && !event.isCanceled()) {
dismiss();
return true;
}
}
}
return false;
}
/**
* Dismisses the app menu and cancels the drag-to-scroll if it is taking place.
*/
void dismiss() {
if (isShowing()) {
mPopup.dismiss();
}
}
/**
* @return Whether the app menu is currently showing.
*/
boolean isShowing() {
if (mPopup == null) {
return false;
}
return mPopup.isShowing();
}
/**
* @return {@link PopupWindow} that displays all the menu options and optional footer.
*/
PopupWindow getPopup() {
return mPopup;
}
/**
* @return {@link ListView} that contains all of the menu options.
*/
ListView getListView() {
return mListView;
}
/**
* @return The menu instance inside of this class.
*/
public Menu getMenu() {
return mMenu;
}
private int setMenuHeight(int numMenuItems, Rect appDimensions, int screenHeight, Rect padding,
int footerHeight, View anchorView) {
int menuHeight;
anchorView.getLocationOnScreen(mTempLocation);
int anchorViewY = mTempLocation[1] - appDimensions.top;
if (isAnchorAtBottom(anchorView, appDimensions)) anchorViewY += anchorView.getHeight();
int anchorViewImpactHeight = mIsByPermanentButton ? anchorView.getHeight() : 0;
// Set appDimensions.height() for abnormal anchorViewLocation.
if (anchorViewY > screenHeight) {
anchorViewY = appDimensions.height();
}
int availableScreenSpace = Math.max(
anchorViewY, appDimensions.height() - anchorViewY - anchorViewImpactHeight);
availableScreenSpace -= padding.bottom + footerHeight;
if (mIsByPermanentButton) availableScreenSpace -= padding.top;
int numCanFit = availableScreenSpace / (mItemRowHeight + mItemDividerHeight);
// Fade out the last item if we cannot fit all items.
if (numCanFit < numMenuItems) {
int spaceForFullItems = numCanFit * (mItemRowHeight + mItemDividerHeight);
spaceForFullItems += footerHeight;
int spaceForPartialItem = (int) (LAST_ITEM_SHOW_FRACTION * mItemRowHeight);
// Determine which item needs hiding.
if (spaceForFullItems + spaceForPartialItem < availableScreenSpace) {
menuHeight = spaceForFullItems + spaceForPartialItem + padding.top + padding.bottom;
} else {
menuHeight = spaceForFullItems - mItemRowHeight + spaceForPartialItem + padding.top
+ padding.bottom;
}
} else {
int spaceForFullItems = numMenuItems * (mItemRowHeight + mItemDividerHeight);
spaceForFullItems += footerHeight;
menuHeight = spaceForFullItems + padding.top + padding.bottom;
}
mPopup.setHeight(menuHeight);
return menuHeight;
}
private void runMenuItemEnterAnimations() {
mMenuItemEnterAnimator = new AnimatorSet();
AnimatorSet.Builder builder = null;
ViewGroup list = mListView;
for (int i = 0; i < list.getChildCount(); i++) {
View view = list.getChildAt(i);
Object animatorObject = view.getTag(R.id.menu_item_enter_anim_id);
if (animatorObject != null) {
if (builder == null) {
builder = mMenuItemEnterAnimator.play((Animator) animatorObject);
} else {
builder.with((Animator) animatorObject);
}
}
}
mMenuItemEnterAnimator.addListener(mAnimationHistogramRecorder);
mMenuItemEnterAnimator.start();
}
}