In this post, we are going to talk about using custom views as menu items. The Android framework does a lot to help us create and interact with menu action items, those little icons on the right side of the toolbar. By calling just a few setup methods, the framework will automatically handle three things for us.

  1. Inserting a view into the Toolbar, ensuring correct placement, image size, and padding between neighbors
  2. Adding a click listener to the view
  3. Defining visual feedback when clicked (i.e. background color change or ripple)

The only requirement of us is that we define a title text and icon drawable within our menu layout file, inflate this layout in onCreateOptionsMenu() and respond to clicks in onOptionsItemSelected(). If you’ve ever worked with menu items before then this is nothing new.

R.menu.activity_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="https://schemas.android.com/apk/res/android"
      xmlns:app="https://schemas.android.com/apk/res-auto">

    <item
        android:id="@+id/activity_main_update_menu_item"
        android:icon="@drawable/ic_refresh_white_24dp"
        android:title="Update"
        app:showAsAction="ifRoom"/>

</menu>

MainActivity.java:

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        super.onCreateOptionsMenu(menu);
        getMenuInflater().inflate(R.menu.activity_main, menu);
        return true;
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
            case R.id.activity_main_update_menu_item:
                Toast.makeText(this, "update clicked", Toast.LENGTH_SHORT).show();
                return true;

            default:
                return super.onOptionsItemSelected(item);
        }

    }
}

first image.gif

But what happens when we want to use a custom view instead of defining just an icon drawable? This is where things get fun. Let’s say we have a view that displays the number of alerts our app has received. We need to update the icon to show or hide a red circle with a number in it. Let’s say that our “refresh” menu item triggers a call to fetch the latest number of alerts and updates the alert menu item. Our final solution will hopefully look like this:

second image.gif?noresize

It is pretty straight forward to dynamically swap the icon drawable used in a menu item. We could “cheat” and supply 11 different icon drawables for our app and then cycle through them:

  • icon with no red circle
  • icon with empty red circle
  • icon with red circle and “1”
  • icon with red circle and “2”
  • icon with red circle and “9”

While this might be easier for us as developers (but more work for our designer), these extra assets will start to add up and begin to bloat our apk. Instead, we can be nice to our users and rely on a custom view to achieve the same effect with fewer assets.

Related: See our take on how to seamlessly display loading indicators and RxJava

New call-to-action

Defining a Custom View

The key to using a custom view for our drawable is to rely on app:actionLayout instead of android:icon in our menu resource file.

R.menu.activity_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="https://schemas.android.com/apk/res/android"
      xmlns:app="https://schemas.android.com/apk/res-auto">

    <item
        android:id="@+id/activity_main_alerts_menu_item"
        android:title="Alerts"
        app:actionLayout="@layout/view_alertsbadge" <!-- important part -->
        app:showAsAction="ifRoom"/>

    <item
        android:id="@+id/activity_main_update_menu_item"
        android:icon="@drawable/ic_refresh_white_24dp"
        android:title="Update"
        app:showAsAction="ifRoom"/>

</menu>

Next we will layout our custom view in a normal layout file.

R.layout.view_alertsbadge.xml:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    xmlns:android="https://schemas.android.com/apk/res/android"
    xmlns:tools="https://schemas.android.com/tools"
    android:layout_width="32dp"
    android:layout_height="32dp"
    android:layout_gravity="center">

    <ImageView
        android:layout_width="@dimen/menu_item_icon_size"
        android:layout_height="@dimen/menu_item_icon_size"
        android:layout_gravity="center"
        android:src="@drawable/ic_warning_white_24dp"/>

    <FrameLayout
        android:id="@+id/view_alert_red_circle"
        android:layout_width="14dp"
        android:layout_height="14dp"
        android:layout_gravity="top|end"
        android:background="@drawable/circle_red"
        android:visibility="gone"
        tools:visibility="visible">

        <TextView
            android:id="@+id/view_alert_count_textview"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:textColor="@color/white"
            android:textSize="10sp"
            tools:text="3"/>

    </FrameLayout>

</FrameLayout>

Lastly, we define a dimension for our icon size. We can reference the Material Design guidelines for this:

dimens.xml:

<resources>
    <!-- general dimensions for all custom menu items -->
    <dimen name="menu_item_icon_size">24dp</dimen>
</resources>

We’ve got our red circle as a FrameLayout which contains our alert count TextView. We also have an ImageView that is our warning icon. Lastly we have to wrap everything in a root FrameLayout. It’s important to note that we hard-code the size of our icon to enforce Material guidelines.

Lastly we wire up our new menu item in our Activity:

@Override
public boolean onOptionsItemSelected(MenuItem item) {
    switch (item.getItemId()) {
        case R.id.activity_main_update_menu_item:
            // TODO update alert menu icon
            Toast.makeText(this, "update clicked", Toast.LENGTH_SHORT).show();
            return true;

        ...
    }
}

If we run the app now we’ll see the new icon, but two problems arise:

  1. onOptionsItemSelected isn’t being called when clicking on the custom menu item
  2. The icon isn’t visually responding to clicks (i.e. no ripple)

3rd image.gif

We’ll fix these problems in a minute, but first let’s write the code to get the icon to display our alert count when requested.

Using the Custom View

We want to configure the custom view in our menu item every time the view is drawn. So instead of configuring it in onCreateOptionsMenu, we’ll do some work inside onPrepareOptionsMenu. Since our menu item is just an inflated layout, we can work with it like any other layout. For example we can find views by id.

public class MainActivity extends AppCompatActivity {

    private FrameLayout redCircle;
    private TextView countTextView;
    private int alertCount = 0;

    ...
    
    @Override
    public boolean onPrepareOptionsMenu(Menu menu) {
        final MenuItem alertMenuItem = menu.findItem(R.id.activity_main_alerts_menu_item);
        FrameLayout rootView = (FrameLayout) alertMenuItem.getActionView();

        redCircle = (FrameLayout) rootView.findViewById(R.id.view_alert_red_circle);
        countTextView = (TextView) rootView.findViewById(R.id.view_alert_count_textview);

        return super.onPrepareOptionsMenu(menu);
    }

    ...
}

We get access to the root view of the menu item by first finding an item from the menu and then calling getActionView. We can then find our red circle FrameLayout and alert count TextView.

We’ll then update the alert icon any time user clicks on the “refresh” menu item:

public class MainActivity extends AppCompatActivity {

    private FrameLayout redCircle;
    private TextView countTextView;
    private int alertCount = 0;

    ...

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
            case R.id.activity_main_update_menu_item:
                alertCount = (alertCount + 1) % 11; // cycle through 0 - 10
                updateAlertIcon()
                return true;

            case R.id.activity_main_alerts_menu_item:
                // TODO update alert menu icon
                Toast.makeText(this, "count cleared", Toast.LENGTH_SHORT).show();

            default:
                return super.onOptionsItemSelected(item);
        }
    }

    private void updateAlertIcon() {
        // if alert count extends into two digits, just show the red circle
        if (0 < alertCount && alertCount < 10) {
            countTextView.setText(String.valueOf(alertCount));
        } else {
            countTextView.setText(""); 
        }

        redCircle.setVisibility((alertCount > 0) ? VISIBLE : GONE);
    }
}

We now have the menu item updating:

new 4th.gif

Fixing Problems aka Making it Perfect

As I said before we still have two problems:

  1. onOptionsItemSelected isn’t being called when clicking on the custom menu item
  2. The custom menu item isn’t visually responding to clicks (i.e. no ripple)

Let’s take care of the first one. For some reason, when our menu item relies on app:actionLayout instead of android:icon, onOptionsItemSelected will not be called for the custom menu item. This is a known problem. The solution is simply to add our own ClickListener to the root view and manually call onOptionsItemSelected. Let’s also reset the alert count when the user clicks on the alert menu item:

    @Override
    public boolean onPrepareOptionsMenu(Menu menu) {
        final MenuItem alertMenuItem = menu.findItem(R.id.activity_main_alerts_menu_item);
        FrameLayout rootView = (FrameLayout) alertMenuItem.getActionView();

        redCircle = (FrameLayout) rootView.findViewById(R.id.view_alert_red_circle);
        countTextView = (TextView) rootView.findViewById(R.id.view_alert_count_textview);

        rootView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                onOptionsItemSelected(alertMenuItem);
            }
        });

        return super.onPrepareOptionsMenu(menu);
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        switch (item.getItemId()) {
            case R.id.activity_main_update_menu_item:
                alertCount = (alertCount + 1) % 11; // rotate through 0 - 10
                updateAlertIcon();
                return true;

            case R.id.activity_main_alerts_menu_item:
                alertCount = 0;
                updateAlertIcon();
                return true;

            default:
                return super.onOptionsItemSelected(item);
        }
    }

Now if our users start using the app they’ll notice that something is “off.” It’ll take a while to figure out what it is. They’ll find themselves having to tap the alert icon several times before it responds. So like the meticulous developers we are we’ll flip on “show layout bounds” in the developer options and immediately see the problem:

5th image.png

Our custom menu item isn’t automatically given the same padding as a normal menu item. So the area that receives touch events is greatly reduced. Our users will be hunting around to click in just the right area. We can fix this by adding a FrameLayout to our custom view:

R.layout.view_alertsbadge.xml:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    xmlns:android="https://schemas.android.com/apk/res/android"
    xmlns:tools="https://schemas.android.com/tools"
    android:layout_width="@dimen/menu_item_size"
    android:layout_height="@dimen/menu_item_size">

    <FrameLayout
        android:layout_width="32dp"
        android:layout_height="32dp"
        android:layout_gravity="center">

        <ImageView
            android:layout_width="@dimen/menu_item_icon_size"
            android:layout_height="@dimen/menu_item_icon_size"
            android:layout_gravity="center"
            android:src="@drawable/ic_warning_white_24dp"/>

        <FrameLayout
            android:id="@+id/view_alert_red_circle"
            android:layout_width="14dp"
            android:layout_height="14dp"
            android:layout_gravity="top|end"
            android:background="@drawable/circle_red"
            android:visibility="gone"
            tools:visibility="visible">

            <TextView
                android:id="@+id/view_alert_count_textview"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center"
                android:textColor="@color/white"
                android:textSize="10sp"
                tools:text="3"/>

        </FrameLayout>

    </FrameLayout>

</FrameLayout>

Referring again to the Material Design guidelines, we need to set this new root view to 48dp height and width.

dimens.xml:

<resources>
    <!-- general dimensions for all custom menu items -->
    <dimen name="menu_item_icon_size">24dp</dimen>
    <dimen name="menu_item_size">48dp</dimen>
</resources>

This successfully increases our click area.

sixth image.png

The last thing we need to do is enable some visual feedback when the menu item is clicked. For Lollipop+ devices this means a ripple; for older devices this means a background color change. Luckily for us, this functionality is already contained in attr/selectableItemBackgroundBorderless. So all we need is a new view in our layout file that we can set this attribute on:

R.layout.view_alertsbadge.xml:

<?xml version="1.0" encoding="utf-8"?>

<FrameLayout
    xmlns:android="https://schemas.android.com/apk/res/android"
    xmlns:tools="https://schemas.android.com/tools"
    android:layout_width="@dimen/menu_item_size"
    android:layout_height="@dimen/menu_item_size">

    <!-- separate view to display ripple/color change when menu item is clicked -->
    <FrameLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="center"
        android:background="?attr/selectableItemBackgroundBorderless"/>
    
    ...

</FrameLayout>

Perfect. Things are looking good. The ripple looks good on our Android 22 device and background color change looks good on our Android 19 device.

7th image.gif API 19 device

eighth image.gifAPI 22 device

One Last Thing

Before we can ship this code we run the changes through our device farm and notice that something isn’t quite right on API 23+ devices. The ripple boundaries on our custom menu item are much larger than on a standard menu item:

10th image.png

Ripple bounds on standard menu item

11th image.png

Ripple bounds on custom menu item

To fix this we need to do some trial and error to figure out the right dimension for the ripple boundary. We’ll then supply a different dimension for API 23+ devices. Finally we’ll update the layout to use this new dimension (instead of just having our ripple view be match_parent). You can take my word for it but on API 23+ this ripple boundary should be 28dp.

values/dimens.xml:

<resources>
    <!-- general dimensions for all custom menu items -->
    <dimen name="menu_item_icon_size">24dp</dimen>
    <dimen name="menu_item_size">48dp</dimen>
    <dimen name="menu_item_ripple_size">48dp</dimen>
</resources>

values-v23/dimens.xml:

<resources>
    <dimen name="menu_item_ripple_size">28dp</dimen>
</resources>

R.layout.view_alertsbadge.xml:

<?xml version="1.0" encoding="utf-8"?>

<FrameLayout
    xmlns:android="https://schemas.android.com/apk/res/android"
    xmlns:tools="https://schemas.android.com/tools"
    android:layout_width="@dimen/menu_item_size"
    android:layout_height="@dimen/menu_item_size">

     <!-- separate view to display ripple/color change when menu item is clicked -->
    <FrameLayout
        android:layout_width="@dimen/menu_item_ripple_size"
        android:layout_height="@dimen/menu_item_ripple_size"
        android:layout_gravity="center"
        android:background="?attr/selectableItemBackgroundBorderless"/>

    ...

</FrameLayout>

We now have the custom menu item rippling to the same size as our standard menu item.

12th image.png

That’s it. Our solution is now working on all of the latest versions of Android. Feel free to download this working sample here.

13th image.gif API 19 device

14th image.gif API 22 device

15th image.gif API 24 device

 

  1. Lance Johnson

    Well done Sr. You really hit the mark on this one. I would just add one thing to support tooltip on long press. I got that via:

    TooltipCompat.setTooltipText(rootView, alertMenuItem .getTitle())

    in onPrepareOptionsMenu when you add the other click listener.

  2. Good Article, Thanks for writing this.
    i have tried this one in my Fragment, But it is getting error for invoking custom control
    “java.lang.NullPointerException: Attempt to invoke virtual method ‘android.view.View android.widget.FrameLayout.findViewById(int)’ on a null object reference”

    • Hi Faris,

      Without looking at your code there are a number of things that could cause that. My top guesses are: your menu items might not have the same id names, and you might not have called setHasOptionsMenu(true).

      I can say for certain that the FrameLayout you are calling findViewById() on is null and that is, somehow, the cause of the issue.
      This example uses Activity instead of Fragment, you’ll need to make the appropriate adjustments to the implementation.

      Hope this helps!

  3. I dont usually (never) comment on articles, but i felt like you deserve huge kudos for this. Following Material Design guidelines, elegant solution, using dimensions, embedded and informative animations in the article, clean code, you even adressed the issues with API 23! Perfection.

Leave a Reply

Your email address will not be published. Required fields are marked *