Icon with a counter in the upper toolbar: an example of the diversity of approaches to a single task

In the life of every developer, there is a moment when, after seeing an interesting solution in another application, I want to implement it in my own. This is logical and should be pretty simple. And surely caring people from the “corporation of good” wrote a guide on this or made an instructional video, where they show on their fingers how to call a couple of necessary methods to achieve the desired result. It often happens that way.
But it happens in a completely different way: you see the realization of something in every second application, and when it comes to implementing the same in yourself, it turns out that there are still no easy solutions for it ...
It happened to me when it became necessary to add an icon with a counter to the top panel. I was very surprised when it turned out that to implement such a familiar and sought-after UI element there is no simple solution. But it happens, unfortunately. And I decided to turn to the knowledge of the world wide web. The issue of placing an icon with a counter in the upper toolbar, as it turned out, was quite a matter of concern. After spending some time on the Internet, I found a lot of different solutions. In general, they are all workers and have the right to life. Moreover, the result of my research clearly shows how different ways you can approach the solution of problems in Android.
In this article I will talk about several implementations of the counter icon. Here are 4 examples. If you think a little wider, then we will focus on almost any custom element that we want to place in the upper toolbar. So, let's begin.
Solution one
Concept
Each time you need to draw or update the counter on the icon, you need to create it
Drawable
on the basis of the markup file and draw it on the toolbar as an icon.Implementation
Create
res/layouts
a markup file badge_with_counter_icon
:<?xml version="1.0" encoding="utf-8"?><RelativeLayoutxmlns:android="http://schemas.android.com/apk/res/android"android:layout_width="wrap_content"android:layout_height="@dimen/menu_item_icon_size"
><ImageViewandroid:id="@+id/icon_badge"android:layout_width="@dimen/menu_item_icon_size"android:layout_height="@dimen/menu_item_icon_size"android:scaleType="fitXY"android:src="@drawable/icon"android:layout_alignParentStart="true"/><TextViewandroid:id="@+id/counter"android:layout_width="wrap_content"android:layout_height="wrap_content"android:layout_alignStart="@id/icon_badge"android:layout_alignTop="@+id/icon_badge"android:layout_gravity="center"android:layout_marginStart="@dimen/counter_left_margin"android:background="@drawable/counter_background"android:gravity="center"android:paddingLeft="@dimen/counter_text_horizontal_padding"android:paddingRight="@dimen/counter_text_horizontal_padding"android:text="99"android:textAppearance="@style/CounterText" /></RelativeLayout>
Here, the counter itself, we bind to the left edge of the icon and specify a fixed indent: this is necessary so that as the length of the text of the counter value increases, the main icon does not overlap more strongly — this is ugly.
In
res/values/dimens
add:<dimenname="menu_item_icon_size">24dp</dimen><dimenname="counter_left_margin">14dp</dimen><dimenname="counter_badge_radius">6dp</dimen><dimenname="counter_text_size">9sp</dimen><dimenname="counter_text_horizontal_padding">4dp</dimen>
The size of the icon is in accordance with the material design guide .
In
res/values/colors
add:<colorname="counter_background_color">@android:color/holo_red_light</color><colorname="counter_text_color">@android:color/white</color>
In
res/values/styles
add:<stylename="CounterText"><itemname="android:fontFamily">sans-serif</item><itemname="android:textSize">@dimen/counter_text_size</item><itemname="android:textColor">@color/counter_text_color</item><itemname="android:textStyle">normal</item></style>
Create a
res/drawable/
resource counter_background.xml
:<?xml version="1.0" encoding="utf-8"?><shapexmlns:android="http://schemas.android.com/apk/res/android"android:shape="rectangle"><solidandroid:color="@color/counter_background_color"/><cornersandroid:radius="@dimen/counter_badge_radius"/></shape>
As an icon, take your picture, call it
icon
and put it into resources. In
res/menu
creating the file menu_main.xml
:<?xml version="1.0" encoding="utf-8"?><menuxmlns:android="http://schemas.android.com/apk/res/android"xmlns:app="http://schemas.android.com/apk/res-auto"><itemandroid:id="@+id/action_counter_1"android:icon="@drawable/icon"android:title="icon"app:showAsAction="ifRoom"/></menu>
Create a class that converts markup to
Drawable
:LayoutToDrawableConverter.java
package com.example.counters.counters;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;
publicclassLayoutToDrawableConverter{
publicstatic Drawable convertToImage(Context context, int count, int drawableId){
LayoutInflater inflater = LayoutInflater.from(context);
View view = inflater.inflate(R.layout.badge_with_counter_icon, null);
((ImageView) view.findViewById(R.id.icon_badge)).setImageResource(drawableId);
TextView textView = view.findViewById(R.id.counter);
if (count == 0) {
textView.setVisibility(View.GONE);
} else {
textView.setText(String.valueOf(count));
}
view.measure(View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
view.layout(0, 0, view.getMeasuredWidth(), view.getMeasuredHeight());
view.setDrawingCacheEnabled(true);
view.setDrawingCacheQuality(View.DRAWING_CACHE_QUALITY_HIGH);
Bitmap bitmap = Bitmap.createBitmap(view.getDrawingCache());
view.setDrawingCacheEnabled(false);
returnnew BitmapDrawable(context.getResources(), bitmap);
}
}
Next, in the right we
Activity
add:privateint mCounterValue1 = 0;
@OverridepublicbooleanonCreateOptionsMenu(Menu menu){
getMenuInflater().inflate(R.menu.menu_main, menu);
MenuItem menuItem = menu.findItem(R.id.action_with_counter_1);
menuItem.setIcon(LayoutToDrawableConverter.convertToImage(this, mCounterValue1, R.drawable.icon));
returntrue;
}
@OverridepublicbooleanonOptionsItemSelected(final MenuItem item){
switch (item.getItemId()) {
case R.id.action_counter_1:
updateFirstCounter(mCounterValue1 + 1);
returntrue;
default:
returnsuper.onOptionsItemSelected(item);
}
}
privatevoidupdateFirstCounter(int newCounterValue){
mCountrerValue1 = newCounterValue;
invalidateOptionsMenu();
}
Now, if you need to update the counter, we call the method
updateFirstCounter
, passing in it the current value. Here I hung the increase in the counter value when I clicked on the icon. With other implementations I will do the same. It is necessary to pay attention to the following: we form an image, which we then feed to the menu item - all necessary indents are formed automatically, we do not need to take them into account.
Solution two
Concept
In this implementation, we create an icon based on the layered element described in
LayerList
, in which at the right time we draw the counter itself directly, leaving the icon unchanged.Implementation
Hereinafter, I will gradually add resources and code for all implementations.
In
res/drawable/
create ic_layered_counter_icon.xml
:<?xml version="1.0" encoding="utf-8"?><layer-listxmlns:android="http://schemas.android.com/apk/res/android"><itemandroid:drawable="@drawable/icon"android:gravity="center" /><itemandroid:id="@+id/ic_counter"android:drawable="@android:color/transparent" /></layer-list>
In
res/menu/menu_main.xml
add:<itemandroid:id="@+id/action_counter_2"android:icon="@drawable/ic_layered_counter_icon"android:title="layered icon"app:showAsAction="ifRoom"/>
In
res/values/dimens
add:<dimenname="counter_text_vertical_padding">2dp</dimen>
Create a file
CounterDrawable.java
:package com.example.counters.counters;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.support.v4.content.ContextCompat;
publicclassCounterDrawableextendsDrawable{
private Paint mBadgePaint;
private Paint mTextPaint;
private Rect mTxtRect = new Rect();
private String mCount = "";
privateboolean mWillDraw;
private Context mContext;
publicCounterDrawable(Context context){
mContext = context;
float mTextSize = context.getResources()
.getDimension(R.dimen.counter_text_size);
mBadgePaint = new Paint();
mBadgePaint.setColor(ContextCompat.getColor(context.getApplicationContext(), R.color.counter_background_color));
mBadgePaint.setAntiAlias(true);
mBadgePaint.setStyle(Paint.Style.FILL);
mTextPaint = new Paint();
mTextPaint.setColor(ContextCompat.getColor(context.getApplicationContext(), R.color.counter_text_color));
mTextPaint.setTypeface(Typeface.DEFAULT);
mTextPaint.setTextSize(mTextSize);
mTextPaint.setAntiAlias(true);
mTextPaint.setTextAlign(Paint.Align.CENTER);
}
@Overridepublicvoiddraw(Canvas canvas){
if (!mWillDraw) {
return;
}
float radius = mContext.getResources()
.getDimension(R.dimen.counter_badge_radius);
float counterLeftMargin = mContext.getResources()
.getDimension(R.dimen.counter_left_margin);
float horizontalPadding = mContext.getResources()
.getDimension(R.dimen.counter_text_horizontal_padding);
float verticalPadding = mContext.getResources()
.getDimension(R.dimen.counter_text_vertical_padding);
mTextPaint.getTextBounds(mCount, 0, mCount.length(), mTxtRect);
float textHeight = mTxtRect.bottom - mTxtRect.top;
float textWidth = mTxtRect.right - mTxtRect.left;
float badgeWidth = Math.max(textWidth + 2 * horizontalPadding, 2 * radius);
float badgeHeight = Math.max(textHeight + 2 * verticalPadding, 2 * radius);
canvas.drawCircle(counterLeftMargin + radius, radius, radius, mBadgePaint);
canvas.drawCircle(counterLeftMargin + radius, badgeHeight - radius, radius, mBadgePaint);
canvas.drawCircle(counterLeftMargin + badgeWidth - radius, badgeHeight - radius, radius, mBadgePaint);
canvas.drawCircle(counterLeftMargin + badgeWidth - radius, radius, radius, mBadgePaint);
canvas.drawRect(counterLeftMargin + radius, 0, counterLeftMargin + badgeWidth - radius, badgeHeight, mBadgePaint);
canvas.drawRect(counterLeftMargin, radius, counterLeftMargin + badgeWidth, badgeHeight - radius, mBadgePaint);
// for API 21 and more://canvas.drawRoundRect(counterLeftMargin, 0, counterLeftMargin + badgeWidth, badgeHeight, radius, radius, mBadgePaint);
canvas.drawText(mCount, counterLeftMargin + badgeWidth / 2, verticalPadding + textHeight, mTextPaint);
}
publicvoidsetCount(String count){
mCount = count;
mWillDraw = !count.equalsIgnoreCase("0");
invalidateSelf();
}
@OverridepublicvoidsetAlpha(int alpha){
// do nothing
}
@OverridepublicvoidsetColorFilter(ColorFilter cf){
// do nothing
}
@OverridepublicintgetOpacity(){
return PixelFormat.UNKNOWN;
}
}
This class will be engaged in drawing the counter in the upper right corner of our icon. The easiest way to draw the counter background is to simply draw a rectangle with rounded corners, invoking
canvas.drawRoundRect
, but this method is suitable for the API version above the 21st. Although for earlier versions of the API, this is not particularly difficult. Next, in our
Activity
add:privateint mCounterValue2 = 0;
private LayerDrawable mIcon2;
privatevoidinitSecondCounter(Menu menu){
MenuItem menuItem = menu.findItem(R.id.action_counter_2);
mIcon2 = (LayerDrawable) menuItem.getIcon();
updateSecondCounter(mCounterValue2);
}
privatevoidupdateSecondCounter(int newCounterValue){
CounterDrawable badge;
Drawable reuse = mIcon2.findDrawableByLayerId(R.id.ic_counter);
if (reuse != null && reuse instanceof CounterDrawable) {
badge = (CounterDrawable) reuse;
} else {
badge = new CounterDrawable(this);
}
badge.setCount(String.valueOf(newCounterValue));
mIcon2.mutate();
mIcon2.setDrawableByLayerId(R.id.ic_counter, badge);
}
Add code to
onOptionsItemSelected
. Given the code for the first implementation, this method will look like this:@OverridepublicbooleanonOptionsItemSelected(final MenuItem item){
switch (item.getItemId()) {
case R.id.action_counter_1:
updateFirstCounter(mCounterValue1 + 1);
returntrue;
case R.id.action_counter_2:
updateSecondCounter(++mCounterValue2);
returntrue;
default:
returnsuper.onOptionsItemSelected(item);
}
}
That's it, the second implementation is ready. Like last time, I hung the update of the counter by clicking on the icon, but it can be initialized from anywhere by calling the method
updateSecondCounter
. As you can see, we draw the counter on the canvas with our hands, but you can come up with something more interesting - it all depends on your imagination or on the wishes of the customer.Third decision
Concept
For the menu item, we use not an image, but an element with arbitrary markup. Then we find the components of this element and save the links to them.
In this case, we are interested in the
ImageView
icons and the TextView
counter, but in fact it may be something more custom. Immediately fasten the processing of clicking on this item. This must be done, since the method is onOptionsItemSelected
not called for custom elements in the toolbar .Implementation
Create
res/layouts
a markup file badge_with_counter.xml
:<?xml version="1.0" encoding="utf-8"?><FrameLayoutxmlns:android="http://schemas.android.com/apk/res/android"android:layout_width="wrap_content"android:layout_height="wrap_content"><RelativeLayoutandroid:layout_width="@dimen/menu_item_size"android:layout_height="@dimen/menu_item_size"><ImageViewandroid:id="@+id/icon_badge"android:layout_width="@dimen/menu_item_icon_size"android:layout_height="@dimen/menu_item_icon_size"android:layout_centerInParent="true"android:scaleType="fitXY"android:src="@drawable/icon" /><TextViewandroid:id="@+id/counter"android:layout_width="wrap_content"android:layout_height="wrap_content"android:layout_alignStart="@id/icon_badge"android:layout_alignTop="@+id/icon_badge"android:layout_gravity="center"android:layout_marginStart="@dimen/counter_left_margin"android:background="@drawable/counter_background"android:gravity="center"android:paddingLeft="@dimen/counter_text_horizontal_padding"android:paddingRight="@dimen/counter_text_horizontal_padding"android:text="99"android:textAppearance="@style/CounterText" /></RelativeLayout></FrameLayout>
In
res/values/dimens
add:<dimenname="menu_item_size">48dp</dimen>
Add to
res/menu/menu_main.xml
:<itemandroid:id="@+id/action_counter_3"app:actionLayout="@layout/badge_with_counter"android:title="existing action view"app:showAsAction="ifRoom"/>
Next, in our
Activity
add:privateint mCounterValue3 = 0;
private ImageView mIcon3;
private TextView mCounterText3;
privatevoidinitThirdCounter(Menu menu){
MenuItem counterItem = menu.findItem(R.id.action_counter_3);
View counter = counterItem.getActionView();
mIcon3 = counter.findViewById(R.id.icon_badge);
mCounterText3 = counter.findViewById(R.id.counter);
counter.setOnClickListener(v -> onThirdCounterClick());
updateThirdCounter(mCounterValue3);
}
privatevoidonThirdCounterClick(){
updateThirdCounter(++mCounterValue3);
}
privatevoidupdateThirdCounter(int newCounterValue){
if (mIcon3 == null || mCounterText3 == null) {
return;
}
if (newCounterValue == 0) {
mIcon3.setImageResource(R.drawable.icon);
mCounterText3.setVisibility(View.GONE);
} else {
mIcon3.setImageResource(R.drawable.icon);
mCounterText3.setVisibility(View.VISIBLE);
mCounterText3.setText(String.valueOf(newCounterValue));
}
}
In
onPrepareOptionsMenu
add:initThirdCounter(menu);
Now, with the previous changes, this method looks like this:
@OverridepublicbooleanonPrepareOptionsMenu(final Menu menu){
// the second counter
initSecondCounter(menu);
// the third counter
initThirdCounter(menu);
returnsuper.onPrepareOptionsMenu(menu);
}
Done! Please note that for our element we took a markup in which we independently specified all the necessary dimensions and indents - in this case the system will not do this for us.
Fourth solution
Concept
The same as in the previous version, but here we create and add our element directly from the code.
Implementation
In
Activity
add:privateint mCounterValue4 = 0;
privatevoidaddFourthCounter(Menu menu, Context context){
View counter = LayoutInflater.from(context)
.inflate(R.layout.badge_with_counter, null);
counter.setOnClickListener(v -> onFourthCounterClick());
mIcon4 = counter.findViewById(R.id.icon_badge);
mCounterText4 = counter.findViewById(R.id.counter);
MenuItem counterMenuItem = menu.add(context.getString(R.string.counter));
counterMenuItem.setActionView(counter);
counterMenuItem.setShowAsActionFlags(MenuItem.SHOW_AS_ACTION_ALWAYS);
updateFourthCounter(mCounterValue4);
}
privatevoidonFourthCounterClick(){
updateFourthCounter(++mCounterValue4);
}
privatevoidupdateFourthCounter(int newCounterValue){
if (mIcon4 == null || mCounterText4 == null) {
return;
}
if (newCounterValue == 0) {
mIcon4.setImageResource(R.drawable.icon);
mCounterText4.setVisibility(View.GONE);
} else {
mIcon4.setImageResource(R.drawable.icon);
mCounterText4.setVisibility(View.VISIBLE);
mCounterText4.setText(String.valueOf(newCounterValue));
}
}
In this variant, the addition of our element to the menu should be done already in
onCreateOptionsMenu
Taking into account the previous changes, this method now looks like this:
@OverridepublicbooleanonCreateOptionsMenu(Menu menu){
getMenuInflater().inflate(R.menu.menu_main, menu);
MenuItem menuItem = menu.findItem(R.id.action_counter_1);
// the first counter
menuItem.setIcon(LayoutToDrawableConverter.convertToImage(this, mCounterValue1, R.drawable.icon));
// the third counter
addFourthCounter(menu, this);
returntrue;
}
Done!
In my opinion, the last two solutions are the simplest and most elegant, and also the shortest: we simply select the element markup we need and throw it into the toolbar, and update the content as when working with a normal View.
It would seem, why I simply did not describe this approach and did not dwell on it? There are two reasons for this:
- First, I want to show that one problem can have several solutions;
- secondly, each of the options considered has the right to life.
Remember, I wrote that you can treat these solutions not only as an implementation of an icon with a counter, but to use them in some very complex and interesting custom element for a toolbar, for which one of the proposed solutions will be the most appropriate? I will give an example.
Of all the considered methods, the most controversial is the first, since it loads the system quite heavily. Its use can be justified in the case when we have a requirement to hide the details of the formation of the icon and transfer to the toolbar the already formed image. However, it should be borne in mind that with frequent updates of the icon in this way, we can deal a serious blow to performance.
The second method will suit us when you need to draw something on the canvas yourself. The third and fourth implementations are the most universal for classical tasks: changing the value of a text field instead of forming a separate image will be a perfectly successful solution.
When there is a need to implement some difficult graphic feature, I usually say to myself: “Nothing is impossible - the only question is how much time and effort you need to spend on implementation”.
Now you have several options to achieve the task and, as you can see, you need very little time and energy to implement each option.