/* * Copyright (C) 2006 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.internal.view.menu; import com.android.internal.view.menu.MenuBuilder.ItemInvoker; import android.content.Context; import android.content.res.Resources; import android.content.res.TypedArray; import android.graphics.Canvas; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.os.Parcel; import android.os.Parcelable; import android.util.AttributeSet; import android.view.KeyEvent; import android.view.View; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.view.LayoutInflater; import java.util.ArrayList; /** * The icon menu view is an icon-based menu usually with a subset of all the menu items. * It is opened as the default menu, and shows either the first five or all six of the menu items * with text and icon. In the situation of there being more than six items, the first five items * will be accompanied with a 'More' button that opens an {@link ExpandedMenuView} which lists * all the menu items. * * @attr ref android.R.styleable#IconMenuView_rowHeight * @attr ref android.R.styleable#IconMenuView_maxRows * @attr ref android.R.styleable#IconMenuView_maxItemsPerRow * * @hide */ public final class IconMenuView extends ViewGroup implements ItemInvoker, MenuView, Runnable { private static final int ITEM_CAPTION_CYCLE_DELAY = 1000; private MenuBuilder mMenu; /** Height of each row */ private int mRowHeight; /** Maximum number of rows to be shown */ private int mMaxRows; /** Maximum number of items to show in the icon menu. */ private int mMaxItems; /** Maximum number of items per row */ private int mMaxItemsPerRow; /** Actual number of items (the 'More' view does not count as an item) shown */ private int mNumActualItemsShown; /** Divider that is drawn between all rows */ private Drawable mHorizontalDivider; /** Height of the horizontal divider */ private int mHorizontalDividerHeight; /** Set of horizontal divider positions where the horizontal divider will be drawn */ private ArrayList<Rect> mHorizontalDividerRects; /** Divider that is drawn between all columns */ private Drawable mVerticalDivider; /** Width of the vertical divider */ private int mVerticalDividerWidth; /** Set of vertical divider positions where the vertical divider will be drawn */ private ArrayList<Rect> mVerticalDividerRects; /** Icon for the 'More' button */ private Drawable mMoreIcon; /** Item view for the 'More' button */ private IconMenuItemView mMoreItemView; /** Background of each item (should contain the selected and focused states) */ private Drawable mItemBackground; /** Default animations for this menu */ private int mAnimations; /** * Whether this IconMenuView has stale children and needs to update them. * Set true by {@link #markStaleChildren()} and reset to false by * {@link #onMeasure(int, int)} */ private boolean mHasStaleChildren; /** * Longpress on MENU (while this is shown) switches to shortcut caption * mode. When the user releases the longpress, we do not want to pass the * key-up event up since that will dismiss the menu. */ private boolean mMenuBeingLongpressed = false; /** * While {@link #mMenuBeingLongpressed}, we toggle the children's caption * mode between each's title and its shortcut. This is the last caption mode * we broadcasted to children. */ private boolean mLastChildrenCaptionMode; /** * The layout to use for menu items. Each index is the row number (0 is the * top-most). Each value contains the number of items in that row. * <p> * The length of this array should not be used to get the number of rows in * the current layout, instead use {@link #mLayoutNumRows}. */ private int[] mLayout; /** * The number of rows in the current layout. */ private int mLayoutNumRows; /** * Instantiates the IconMenuView that is linked with the provided MenuBuilder. */ public IconMenuView(Context context, AttributeSet attrs) { super(context, attrs); TypedArray a = context.obtainStyledAttributes(attrs, com.android.internal.R.styleable.IconMenuView, 0, 0); mRowHeight = a.getDimensionPixelSize(com.android.internal.R.styleable.IconMenuView_rowHeight, 64); mMaxRows = a.getInt(com.android.internal.R.styleable.IconMenuView_maxRows, 2); mMaxItems = a.getInt(com.android.internal.R.styleable.IconMenuView_maxItems, 6); mMaxItemsPerRow = a.getInt(com.android.internal.R.styleable.IconMenuView_maxItemsPerRow, 3); mMoreIcon = a.getDrawable(com.android.internal.R.styleable.IconMenuView_moreIcon); a.recycle(); a = context.obtainStyledAttributes(attrs, com.android.internal.R.styleable.MenuView, 0, 0); mItemBackground = a.getDrawable(com.android.internal.R.styleable.MenuView_itemBackground); mHorizontalDivider = a.getDrawable(com.android.internal.R.styleable.MenuView_horizontalDivider); mHorizontalDividerRects = new ArrayList<Rect>(); mVerticalDivider = a.getDrawable(com.android.internal.R.styleable.MenuView_verticalDivider); mVerticalDividerRects = new ArrayList<Rect>(); mAnimations = a.getResourceId(com.android.internal.R.styleable.MenuView_windowAnimationStyle, 0); a.recycle(); if (mHorizontalDivider != null) { mHorizontalDividerHeight = mHorizontalDivider.getIntrinsicHeight(); // Make sure to have some height for the divider if (mHorizontalDividerHeight == -1) mHorizontalDividerHeight = 1; } if (mVerticalDivider != null) { mVerticalDividerWidth = mVerticalDivider.getIntrinsicWidth(); // Make sure to have some width for the divider if (mVerticalDividerWidth == -1) mVerticalDividerWidth = 1; } mLayout = new int[mMaxRows]; // This view will be drawing the dividers setWillNotDraw(false); // This is so we'll receive the MENU key in touch mode setFocusableInTouchMode(true); // This is so our children can still be arrow-key focused setDescendantFocusability(FOCUS_AFTER_DESCENDANTS); } /** * Figures out the layout for the menu items. * * @param width The available width for the icon menu. */ private void layoutItems(int width) { int numItems = getChildCount(); // Start with the least possible number of rows int curNumRows = Math.min((int) Math.ceil(numItems / (float) mMaxItemsPerRow), mMaxRows); /* * Increase the number of rows until we find a configuration that fits * all of the items' titles. Worst case, we use mMaxRows. */ for (; curNumRows <= mMaxRows; curNumRows++) { layoutItemsUsingGravity(curNumRows, numItems); if (curNumRows >= numItems) { // Can't have more rows than items break; } if (doItemsFit()) { // All the items fit, so this is a good configuration break; } } } /** * Figures out the layout for the menu items by equally distributing, and * adding any excess items equally to lower rows. * * @param numRows The total number of rows for the menu view * @param numItems The total number of items (across all rows) contained in * the menu view * @return int[] Where the value of index i contains the number of items for row i */ private void layoutItemsUsingGravity(int numRows, int numItems) { int numBaseItemsPerRow = numItems / numRows; int numLeftoverItems = numItems % numRows; /** * The bottom rows will each get a leftover item. Rows (indexed at 0) * that are >= this get a leftover item. Note: if there are 0 leftover * items, no rows will get them since this value will be greater than * the last row. */ int rowsThatGetALeftoverItem = numRows - numLeftoverItems; int[] layout = mLayout; for (int i = 0; i < numRows; i++) { layout[i] = numBaseItemsPerRow; // Fill the bottom rows with a leftover item each if (i >= rowsThatGetALeftoverItem) { layout[i]++; } } mLayoutNumRows = numRows; } /** * Checks whether each item's title is fully visible using the current * layout. * * @return True if the items fit (each item's text is fully visible), false * otherwise. */ private boolean doItemsFit() { int itemPos = 0; int[] layout = mLayout; int numRows = mLayoutNumRows; for (int row = 0; row < numRows; row++) { int numItemsOnRow = layout[row]; /* * If there is only one item on this row, increasing the * number of rows won't help. */ if (numItemsOnRow == 1) { itemPos++; continue; } for (int itemsOnRowCounter = numItemsOnRow; itemsOnRowCounter > 0; itemsOnRowCounter--) { View child = getChildAt(itemPos++); LayoutParams lp = (LayoutParams) child.getLayoutParams(); if (lp.maxNumItemsOnRow < numItemsOnRow) { return false; } } } return true; } /** * Adds an IconMenuItemView to this icon menu view. * @param itemView The item's view to add */ private void addItemView(IconMenuItemView itemView) { // Set ourselves on the item view itemView.setIconMenuView(this); // Apply the background to the item view itemView.setBackgroundDrawable(mItemBackground.getConstantState().newDrawable()); // This class is the invoker for all its item views itemView.setItemInvoker(this); addView(itemView, itemView.getTextAppropriateLayoutParams()); } /** * Creates the item view for the 'More' button which is used to switch to * the expanded menu view. This button is a special case since it does not * have a MenuItemData backing it. * @return The IconMenuItemView for the 'More' button */ private IconMenuItemView createMoreItemView() { LayoutInflater inflater = mMenu.getMenuType(MenuBuilder.TYPE_ICON).getInflater(); final IconMenuItemView itemView = (IconMenuItemView) inflater.inflate( com.android.internal.R.layout.icon_menu_item_layout, null); Resources r = getContext().getResources(); itemView.initialize(r.getText(com.android.internal.R.string.more_item_label), mMoreIcon); // Set up a click listener on the view since there will be no invocation sequence // due to the lack of a MenuItemData this view itemView.setOnClickListener(new OnClickListener() { public void onClick(View v) { // Switches the menu to expanded mode MenuBuilder.Callback cb = mMenu.getCallback(); if (cb != null) { // Call callback cb.onMenuModeChange(mMenu); } } }); return itemView; } public void initialize(MenuBuilder menu, int menuType) { mMenu = menu; updateChildren(true); } public void updateChildren(boolean cleared) { // This method does a clear refresh of children removeAllViews(); final ArrayList<MenuItemImpl> itemsToShow = mMenu.getVisibleItems(); final int numItems = itemsToShow.size(); final int numItemsThatCanFit = mMaxItems; // Minimum of the num that can fit and the num that we have final int minFitMinus1AndNumItems = Math.min(numItemsThatCanFit - 1, numItems); MenuItemImpl itemData; // Traverse through all but the last item that can fit since that last item can either // be a 'More' button or a sixth item for (int i = 0; i < minFitMinus1AndNumItems; i++) { itemData = itemsToShow.get(i); addItemView((IconMenuItemView) itemData.getItemView(MenuBuilder.TYPE_ICON, this)); } if (numItems > numItemsThatCanFit) { // If there are more items than we can fit, show the 'More' button to // switch to expanded mode if (mMoreItemView == null) { mMoreItemView = createMoreItemView(); } addItemView(mMoreItemView); // The last view is the more button, so the actual number of items is one less than // the number that can fit mNumActualItemsShown = numItemsThatCanFit - 1; } else if (numItems == numItemsThatCanFit) { // There are exactly the number we can show, so show the last item final MenuItemImpl lastItemData = itemsToShow.get(numItemsThatCanFit - 1); addItemView((IconMenuItemView) lastItemData.getItemView(MenuBuilder.TYPE_ICON, this)); // The items shown fit exactly mNumActualItemsShown = numItemsThatCanFit; } } /** * The positioning algorithm that gets called from onMeasure. It * just computes positions for each child, and then stores them in the child's layout params. * @param menuWidth The width of this menu to assume for positioning * @param menuHeight The height of this menu to assume for positioning */ private void positionChildren(int menuWidth, int menuHeight) { // Clear the containers for the positions where the dividers should be drawn if (mHorizontalDivider != null) mHorizontalDividerRects.clear(); if (mVerticalDivider != null) mVerticalDividerRects.clear(); // Get the minimum number of rows needed final int numRows = mLayoutNumRows; final int numRowsMinus1 = numRows - 1; final int numItemsForRow[] = mLayout; // The item position across all rows int itemPos = 0; View child; IconMenuView.LayoutParams childLayoutParams = null; // Use float for this to get precise positions (uniform item widths // instead of last one taking any slack), and then convert to ints at last opportunity float itemLeft; float itemTop = 0; // Since each row can have a different number of items, this will be computed per row float itemWidth; // Subtract the space needed for the horizontal dividers final float itemHeight = (menuHeight - mHorizontalDividerHeight * (numRows - 1)) / (float)numRows; for (int row = 0; row < numRows; row++) { // Start at the left itemLeft = 0; // Subtract the space needed for the vertical dividers, and divide by the number of items itemWidth = (menuWidth - mVerticalDividerWidth * (numItemsForRow[row] - 1)) / (float)numItemsForRow[row]; for (int itemPosOnRow = 0; itemPosOnRow < numItemsForRow[row]; itemPosOnRow++) { // Tell the child to be exactly this size child = getChildAt(itemPos); child.measure(MeasureSpec.makeMeasureSpec((int) itemWidth, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec((int) itemHeight, MeasureSpec.EXACTLY)); // Remember the child's position for layout childLayoutParams = (IconMenuView.LayoutParams) child.getLayoutParams(); childLayoutParams.left = (int) itemLeft; childLayoutParams.right = (int) (itemLeft + itemWidth); childLayoutParams.top = (int) itemTop; childLayoutParams.bottom = (int) (itemTop + itemHeight); // Increment by item width itemLeft += itemWidth; itemPos++; // Add a vertical divider to draw if (mVerticalDivider != null) { mVerticalDividerRects.add(new Rect((int) itemLeft, (int) itemTop, (int) (itemLeft + mVerticalDividerWidth), (int) (itemTop + itemHeight))); } // Increment by divider width (even if we're not computing // dividers, since we need to leave room for them when // calculating item positions) itemLeft += mVerticalDividerWidth; } // Last child on each row should extend to very right edge if (childLayoutParams != null) { childLayoutParams.right = menuWidth; } itemTop += itemHeight; // Add a horizontal divider to draw if ((mHorizontalDivider != null) && (row < numRowsMinus1)) { mHorizontalDividerRects.add(new Rect(0, (int) itemTop, menuWidth, (int) (itemTop + mHorizontalDividerHeight))); itemTop += mHorizontalDividerHeight; } } } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { if (mHasStaleChildren) { mHasStaleChildren = false; // If we have stale data, resync with the menu updateChildren(false); } int measuredWidth = resolveSize(Integer.MAX_VALUE, widthMeasureSpec); calculateItemFittingMetadata(measuredWidth); layoutItems(measuredWidth); // Get the desired height of the icon menu view (last row of items does // not have a divider below) final int desiredHeight = (mRowHeight + mHorizontalDividerHeight) * mLayoutNumRows - mHorizontalDividerHeight; // Maximum possible width and desired height setMeasuredDimension(measuredWidth, resolveSize(desiredHeight, heightMeasureSpec)); // Position the children positionChildren(mMeasuredWidth, mMeasuredHeight); } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { View child; IconMenuView.LayoutParams childLayoutParams; for (int i = getChildCount() - 1; i >= 0; i--) { child = getChildAt(i); childLayoutParams = (IconMenuView.LayoutParams)child .getLayoutParams(); // Layout children according to positions set during the measure child.layout(childLayoutParams.left, childLayoutParams.top, childLayoutParams.right, childLayoutParams.bottom); } } @Override protected void onDraw(Canvas canvas) { if (mHorizontalDivider != null) { // If we have a horizontal divider to draw, draw it at the remembered positions for (int i = mHorizontalDividerRects.size() - 1; i >= 0; i--) { mHorizontalDivider.setBounds(mHorizontalDividerRects.get(i)); mHorizontalDivider.draw(canvas); } } if (mVerticalDivider != null) { // If we have a vertical divider to draw, draw it at the remembered positions for (int i = mVerticalDividerRects.size() - 1; i >= 0; i--) { mVerticalDivider.setBounds(mVerticalDividerRects.get(i)); mVerticalDivider.draw(canvas); } } } public boolean invokeItem(MenuItemImpl item) { return mMenu.performItemAction(item, 0); } @Override public LayoutParams generateLayoutParams(AttributeSet attrs) { return new IconMenuView.LayoutParams(getContext(), attrs); } @Override protected boolean checkLayoutParams(ViewGroup.LayoutParams p) { // Override to allow type-checking of LayoutParams. return p instanceof IconMenuView.LayoutParams; } /** * Marks as having stale children. */ void markStaleChildren() { if (!mHasStaleChildren) { mHasStaleChildren = true; requestLayout(); } } /** * @return The number of actual items shown (those that are backed by an * {@link MenuView.ItemView} implementation--eg: excludes More * item). */ int getNumActualItemsShown() { return mNumActualItemsShown; } public int getWindowAnimations() { return mAnimations; } /** * Returns the number of items per row. * <p> * This should only be used for testing. * * @return The length of the array is the number of rows. A value at a * position is the number of items in that row. * @hide */ public int[] getLayout() { return mLayout; } /** * Returns the number of rows in the layout. * <p> * This should only be used for testing. * * @return The length of the array is the number of rows. A value at a * position is the number of items in that row. * @hide */ public int getLayoutNumRows() { return mLayoutNumRows; } @Override public boolean dispatchKeyEvent(KeyEvent event) { if (event.getKeyCode() == KeyEvent.KEYCODE_MENU) { if (event.getAction() == KeyEvent.ACTION_DOWN && event.getRepeatCount() == 0) { removeCallbacks(this); postDelayed(this, ViewConfiguration.getLongPressTimeout()); } else if (event.getAction() == KeyEvent.ACTION_UP) { if (mMenuBeingLongpressed) { // It was in cycle mode, so reset it (will also remove us // from being called back) setCycleShortcutCaptionMode(false); return true; } else { // Just remove us from being called back removeCallbacks(this); // Fall through to normal processing too } } } return super.dispatchKeyEvent(event); } @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); requestFocus(); } @Override protected void onDetachedFromWindow() { setCycleShortcutCaptionMode(false); super.onDetachedFromWindow(); } @Override public void onWindowFocusChanged(boolean hasWindowFocus) { if (!hasWindowFocus) { setCycleShortcutCaptionMode(false); } super.onWindowFocusChanged(hasWindowFocus); } /** * Sets the shortcut caption mode for IconMenuView. This mode will * continuously cycle between a child's shortcut and its title. * * @param cycleShortcutAndNormal Whether to go into cycling shortcut mode, * or to go back to normal. */ private void setCycleShortcutCaptionMode(boolean cycleShortcutAndNormal) { if (!cycleShortcutAndNormal) { /* * We're setting back to title, so remove any callbacks for setting * to shortcut */ removeCallbacks(this); setChildrenCaptionMode(false); mMenuBeingLongpressed = false; } else { // Set it the first time (the cycle will be started in run()). setChildrenCaptionMode(true); } } /** * When this method is invoked if the menu is currently not being * longpressed, it means that the longpress has just been reached (so we set * longpress flag, and start cycling). If it is being longpressed, we cycle * to the next mode. */ public void run() { if (mMenuBeingLongpressed) { // Cycle to other caption mode on the children setChildrenCaptionMode(!mLastChildrenCaptionMode); } else { // Switch ourselves to continuously cycle the items captions mMenuBeingLongpressed = true; setCycleShortcutCaptionMode(true); } // We should run again soon to cycle to the other caption mode postDelayed(this, ITEM_CAPTION_CYCLE_DELAY); } /** * Iterates children and sets the desired shortcut mode. Only * {@link #setCycleShortcutCaptionMode(boolean)} and {@link #run()} should call * this. * * @param shortcut Whether to show shortcut or the title. */ private void setChildrenCaptionMode(boolean shortcut) { // Set the last caption mode pushed to children mLastChildrenCaptionMode = shortcut; for (int i = getChildCount() - 1; i >= 0; i--) { ((IconMenuItemView) getChildAt(i)).setCaptionMode(shortcut); } } /** * For each item, calculates the most dense row that fully shows the item's * title. * * @param width The available width of the icon menu. */ private void calculateItemFittingMetadata(int width) { int maxNumItemsPerRow = mMaxItemsPerRow; int numItems = getChildCount(); for (int i = 0; i < numItems; i++) { LayoutParams lp = (LayoutParams) getChildAt(i).getLayoutParams(); // Start with 1, since that case does not get covered in the loop below lp.maxNumItemsOnRow = 1; for (int curNumItemsPerRow = maxNumItemsPerRow; curNumItemsPerRow > 0; curNumItemsPerRow--) { // Check whether this item can fit into a row containing curNumItemsPerRow if (lp.desiredWidth < width / curNumItemsPerRow) { // It can, mark this value as the most dense row it can fit into lp.maxNumItemsOnRow = curNumItemsPerRow; break; } } } } @Override protected Parcelable onSaveInstanceState() { Parcelable superState = super.onSaveInstanceState(); View focusedView = getFocusedChild(); for (int i = getChildCount() - 1; i >= 0; i--) { if (getChildAt(i) == focusedView) { return new SavedState(superState, i); } } return new SavedState(superState, -1); } @Override protected void onRestoreInstanceState(Parcelable state) { SavedState ss = (SavedState) state; super.onRestoreInstanceState(ss.getSuperState()); if (ss.focusedPosition >= getChildCount()) { return; } View v = getChildAt(ss.focusedPosition); if (v != null) { v.requestFocus(); } } private static class SavedState extends BaseSavedState { int focusedPosition; /** * Constructor called from {@link IconMenuView#onSaveInstanceState()} */ public SavedState(Parcelable superState, int focusedPosition) { super(superState); this.focusedPosition = focusedPosition; } /** * Constructor called from {@link #CREATOR} */ private SavedState(Parcel in) { super(in); focusedPosition = in.readInt(); } @Override public void writeToParcel(Parcel dest, int flags) { super.writeToParcel(dest, flags); dest.writeInt(focusedPosition); } public static final Parcelable.Creator<SavedState> CREATOR = new Parcelable.Creator<SavedState>() { public SavedState createFromParcel(Parcel in) { return new SavedState(in); } public SavedState[] newArray(int size) { return new SavedState[size]; } }; } /** * Layout parameters specific to IconMenuView (stores the left, top, right, bottom from the * measure pass). */ public static class LayoutParams extends ViewGroup.MarginLayoutParams { int left, top, right, bottom; int desiredWidth; int maxNumItemsOnRow; public LayoutParams(Context c, AttributeSet attrs) { super(c, attrs); } public LayoutParams(int width, int height) { super(width, height); } } }