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.
- Inserting a view into the Toolbar, ensuring correct placement, image size, and padding between neighbors
- Adding a click listener to the view
- 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);
}
}
}
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:
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
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:
onOptionsItemSelected
isn’t being called when clicking on the custom menu item- The icon isn’t visually responding to clicks (i.e. no ripple)
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:
Fixing Problems aka Making it Perfect
As I said before we still have two problems:
onOptionsItemSelected
isn’t being called when clicking on the custom menu item- 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:
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.
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.
API 19 device
API 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:
Ripple bounds on standard menu item
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.
That’s it. Our solution is now working on all of the latest versions of Android. Feel free to download this working sample here.
API 19 device
API 22 device
API 24 device
Great article buddy. its been of much help.
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.
Very helpful, thanks!
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!
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.