Nicolas
Nicolas

Reputation: 7121

Tint menu icons in overflow menu and submenus

I managed to show icons in the toolbar's overflow menu and submenus, but I couldn't find how to tint the icons according to their position. Here the code I'm using:

@Override
public boolean onCreateOptionsMenu(Menu menu) {
    getMenuInflater().inflate(R.menu.toolbar_main, menu);

    // Show icons in overflow menu
    if (menu instanceof MenuBuilder) {
        MenuBuilder m = (MenuBuilder) menu;
        m.setOptionalIconsVisible(true);
    }

    // Change icons color
    changeIconsColor(menu, colorNormal, colorInMenu, false);

    return super.onCreateOptionsMenu(menu);
}

public static void changeIconsColor(Menu menu, int colorNormal, int colorInMenu, boolean isInSubMenu) {
    // Change icons color
    for (int i = 0; i < menu.size(); i++) {
        MenuItem item = menu.getItem(i);
        Drawable icon = item.getIcon();
        if (icon != null) {
            int color = (((MenuItemImpl) item).requiresActionButton() ? colorNormal : colorInMenu);
            icon.setColorFilter(color, PorterDuff.Mode.SRC_IN);
            icon.setAlpha(item.isEnabled() ? 255 : 128);
        }

        if (item.hasSubMenu()) {
            changeIconsColor(item.getSubMenu(), colorNormal, colorInMenu, true);
        }
    }
}

The use of MenuItem.requiresActionButton() allows to know if an item has the values never or always in the showAsAction attribute in XML, but not if it has the ifRoom value. Because of this, I cannot use the ifRoom value in items if I want proper tinting, it's very restrictive.

I am perfectly fine with using reflection if there is no other way.

Upvotes: 4

Views: 664

Answers (3)

prom85
prom85

Reputation: 17878

Here's a solution that works with the material components MaterialToolbar:

Explanation

  • The code checks all sub views of the toolbar => those are the visible items
  • it iterates all menu items recursively and checks if the menu id is part of the visible view ids, if so, this means the menu item is on the toolbar, otherwise it is inside the overflow menu
  • it then tints the icons based on its position
  • it also tints the overflow icon
  • to tint the sub menu arrow indicator correctly, check out following issue: https://github.com/material-components/material-components-android/issues/553

Code

fun View.getAllChildrenRecursively(): List<View> {
    val result = ArrayList<View>()
    if (this !is ViewGroup) {
        result.add(this)
    } else {
        for (index in 0 until this.childCount) {
            val child = this.getChildAt(index)
            result.addAll(child.getAllChildrenRecursively())
        }
    }
    return result
}

@SuppressLint("RestrictedApi")
fun MaterialToolbar.tintAndShowIcons(colorOnToolbar: Int, colorInOverflow: Int) {
    (menu as? MenuBuilder)?.setOptionalIconsVisible(true)
    val c1 = ColorStateList.valueOf(colorOnToolbar)
    val c2 = PorterDuffColorFilter(colorInOverflow, PorterDuff.Mode.SRC_IN)
    val idsShowing = ArrayList<Int>()
    getAllChildrenRecursively().forEach {
        // Icon in Toolbar
        (it as? ActionMenuItemView)?.let {
            idsShowing.add(it.id)
        }
        // Overflow Icon
        (it as? ImageView)?.imageTintList = c1
    }
    menu.forEach {
        checkOverflowMenuItem(it, c2, idsShowing)
    }
}

private fun checkOverflowMenuItem(menuItem: MenuItem, iconColor: ColorFilter, idsShowing: ArrayList<Int>) {
    // Only change Icons inside the overflow
    if (!idsShowing.contains(menuItem.itemId)) {
        menuItem.icon?.colorFilter = iconColor
    }
    menuItem.subMenu?.forEach {
        checkOverflowMenuItem(it, iconColor, idsShowing)
    }
}

Upvotes: 0

Nicolas
Nicolas

Reputation: 7121

Thanks to @JaredRummler, I found a way to determine whether an icon is in the overflow menu or not. I posted the complete code here that gathers the elements of his answer. I also added a helper methods for getting the right colors for tinting icons. Here's what I currently use:

ThemeUtils

public final class ThemeUtils {

    /**
     * Obtain colors of a context's theme from attributes
     * @param context    themed context
     * @param colorAttrs varargs of color attributes
     * @return array of colors in the same order as the array of attributes
     */
    public static int[] getColors(Context context, int... colorAttrs) {
        TypedArray ta = context.getTheme().obtainStyledAttributes(colorAttrs);

        int[] colors = new int[colorAttrs.length];
        for (int i = 0; i < colorAttrs.length; i++) {
            colors[i] = ta.getColor(i, 0);
        }

        ta.recycle();

        return colors;
    }

    /**
     * Get the two colors needed for tinting toolbar icons
     * The colors are obtained from the toolbar's theme and popup theme
     * These themes are obtained from {@link R.attr#toolbarTheme} and {@link R.attr#toolbarPopupTheme}
     * The two color attributes used are:
     * - {@link android.R.attr#textColorPrimary} for the normal color
     * - {@link android.R.attr#textColorSecondary} for the color in a menu
     * @param context activity context
     * @return int[2]{normal color, color in menu}
     */
    public static int[] getToolbarColors(Context context) {
        // Get the theme and popup theme of a toolbar
        TypedArray ta = context.getTheme().obtainStyledAttributes(
                new int[]{R.attr.toolbarTheme, R.attr.toolbarPopupTheme});
        Context overlayTheme = new ContextThemeWrapper(context, ta.getResourceId(0, 0));
        Context popupTheme = new ContextThemeWrapper(context, ta.getResourceId(1, 0));
        ta.recycle();

        // Get toolbar colors from these themes
        int colorNormal = ThemeUtils.getColors(overlayTheme, android.R.attr.textColorPrimary)[0];
        int colorInMenu = ThemeUtils.getColors(popupTheme, android.R.attr.textColorSecondary)[0];

        return new int[]{colorNormal, colorInMenu};
    }

    /**
     * Change the color of the icons of a menu
     * Disabled items are set to 50% alpha
     * @param menu        targeted menu
     * @param colorNormal normal icon color
     * @param colorInMenu icon color for popup menu
     * @param isInSubMenu whether menu is a sub menu
     */
    private static void changeIconsColor(View toolbar, Menu menu, int colorNormal, int colorInMenu, boolean isInSubMenu) {
        toolbar.post(() -> {
            // Change icons color
            for (int i = 0; i < menu.size(); i++) {
                MenuItem item = menu.getItem(i);
                changeMenuIconColor(item, colorNormal, colorInMenu, isInSubMenu);

                if (item.hasSubMenu()) {
                    changeIconsColor(toolbar, item.getSubMenu(), colorNormal, colorInMenu, true);
                }
            }
        });
    }

    public static void changeIconsColor(View toolbar, Menu menu, int colorNormal, int colorInMenu) {
        changeIconsColor(toolbar, menu, colorNormal, colorInMenu, false);
    }

    /**
     * Change the color of a single menu item icon
     * @param item        targeted menu item
     * @param colorNormal normal icon color
     * @param colorInMenu icon color for popup menu
     * @param isInSubMenu whether item is in a sub menu
     */
    @SuppressLint("RestrictedApi")
    public static void changeMenuIconColor(MenuItem item, int colorNormal, int colorInMenu, boolean isInSubMenu) {
        if (item.getIcon() != null) {
            Drawable icon = item.getIcon().mutate();
            int color = (((MenuItemImpl) item).isActionButton() && !isInSubMenu ? colorNormal : colorInMenu);
            icon.setColorFilter(color, PorterDuff.Mode.SRC_IN);
            icon.setAlpha(item.isEnabled() ? 255 : 128);
            item.setIcon(icon);
        }
    }

}

ActivityUtils

public final class ActivityUtils {

    /**
     * Force show the icons in the overflow menu and submenus
     * @param menu target menu
     */
    public static void forceShowMenuIcons(Menu menu) {
        if (menu instanceof MenuBuilder) {
            MenuBuilder m = (MenuBuilder) menu;
            m.setOptionalIconsVisible(true);
        }
    }

    /**
     * Get the action bar or toolbar view in activity
     * @param activity activity to get from
     * @return the toolbar view
     */
    public static ViewGroup findActionBar(Activity activity) {
        int id = activity.getResources().getIdentifier("action_bar", "id", "android");
        ViewGroup actionBar = null;
        if (id != 0) {
            actionBar = activity.findViewById(id);
        }
        if (actionBar == null) {
            return findToolbar((ViewGroup) activity.findViewById(android.R.id.content).getRootView());
        }
        return actionBar;
    }

    private static ViewGroup findToolbar(ViewGroup viewGroup) {
        ViewGroup toolbar = null;
        for (int i = 0; i < viewGroup.getChildCount(); i++) {
            View view = viewGroup.getChildAt(i);
            if (view.getClass() == android.support.v7.widget.Toolbar.class ||
                    view.getClass() == android.widget.Toolbar.class) {
                toolbar = (ViewGroup) view;
            } else if (view instanceof ViewGroup) {
                toolbar = findToolbar((ViewGroup) view);
            }
            if (toolbar != null) {
                break;
            }
        }
        return toolbar;
    }

}

I also defined two attributes in attrs.xml: toolbarTheme and toolbarPopupTheme that I set on my toolbar layout in XML. Their values are defined in my app theme in themes.xml. These attributes are used by ThemeUtils.getToolbarColors(Context) to obtain the colors to use for tinting icons, because toolbars often use theme overlays. By doing this, I can change every toolbar's theme only by changing the value of these 2 attributes.

All that is left is calling the following in the activity's onCreateOptionsMenu(Menu menu):

ActivityUtils.forceShowMenuIcons(menu);  // Optional, show icons in overflow and submenus

View toolbar = ActivityUtils.findActionBar(this);  // Get the action bar view
int[] toolbarColors = ThemeUtils.getToolbarColors(this);  // Get the icons colors
ThemeUtils.changeIconsColor(toolbar, menu, toolbarColors[0], toolbarColors[1]);

The same can be done in a fragment by replacing this with getActivity().

When updating a MenuItem icon, another method can be called, ThemeUtils.changeMenuIconColor(). In this case, toolbar colors can be obtained in onCreate and stored globally to reuse them.

Upvotes: 2

Jared Rummler
Jared Rummler

Reputation: 38131

Unfortunately, there is no way to set the menu item icon color's tint using a theme or style. You need a method to check if the MenuItem is visible on the ActionBar or in the overflow menu. Both the native and support MenuItemImpl class have a method for this but they are either restricted to the library or hidden. This requires reflection. You can use the following method to check if the menu item is visible or not, and then set the color filter:

public static boolean isActionButton(@NonNull MenuItem item) {
  if (item instanceof MenuItemImpl) {
    return ((MenuItemImpl) item).isActionButton();
  } else {
    // Not using the support library. This is a native MenuItem. Reflection is needed.
    try {
      Method m = item.getClass().getDeclaredMethod("isActionButton");
      if (!m.isAccessible()) m.setAccessible(true);
      return (boolean) m.invoke(item);
    } catch (Exception e) {
      return false;
    }
  }
}

You also need to wait until the menu is inflated before tinting the items. To accomplish this you can get a reference to the ActionBar and tint the MenuItem after the ActionBar has been drawn.

Example:

@Override public boolean onCreateOptionsMenu(Menu menu) {
  getMenuInflater().inflate(R.menu.menu_main, menu);

  int id = getResources().getIdentifier("action_bar", "id", "android");
  ViewGroup actionBar;
  if (id != 0) {
    actionBar = (ViewGroup) findViewById(id);
  } else {
    // You must be using a custom Toolbar. Use the toolbar view instead.
    // actionBar = yourToolbar
  }

  actionBar.post(new Runnable() {
    @Override public void run() {
      // Add code to tint menu items here 
    }
  });

  return super.onCreateOptionsMenu(menu);
}

Here is a class I wrote to help with tinting menu item icons: https://gist.github.com/jaredrummler/7816b13fcd5fe1ac61cb0173a1878d4f

Upvotes: 3

Related Questions