Иконка со счётчиком в верхнем тулбаре: пример разнообразия подходов к одной задаче

В жизни каждого разработчика бывает момент, когда, увидев интересное решение в чужом приложении, хочется реализовать его в своём. Это же логично и должно быть довольно просто. И наверняка заботливые люди из «корпорации добра» написали по этому поводу какой-нибудь гайд или сделали обучающее видео, где на пальцах показано, как вызвать пару нужных методов для достижения желаемого результата. Зачастую бывает именно так.

Но бывает и совсем по-другому: ты видишь реализацию чего-то в каждом втором приложении, а когда доходит до реализации того же у себя — оказывается, что лёгких решений для этого, как ни странно, до сих пор нет…

Так и случилось со мной, когда возникла необходимость добавить в верхнюю панель иконку со счётчиком. Я был очень удивлён, когда выяснилось, что для реализации такого привычного и востребованного элемента UI нет простого решения. Но так бывает, к сожалению. И я решил обратиться к знаниям всемирной сети. Вопрос размещения иконки со счётчиком в верхнем тулбаре, как выяснилось, волновал довольно многих. Проведя на просторах интернета некоторое время, я нашёл массу разных решений. В целом все они рабочие и имеют право на жизнь. Более того, результат моего исследования наглядно показывает, как по-разному можно подойти к решению задач в Android.

В этой статье я расскажу о нескольких реализациях иконки со счётчиком. Здесь представлено 4 примера. Если мыслить чуть шире, то речь пойдёт о практически любом кастомном элементе, который мы хотим разместить в верхнем тулбаре. Итак, начнём.

Решение первое

Концепция


Каждый раз при необходимости отрисовки или обновлении счётчика на иконке нужно создавать Drawable на основе файла разметки и отрисовывать его на тулбаре в качестве иконки.

Реализация


Создаём в res/layouts файл разметки badge_with_counter_icon:

<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"   android:layout_width="wrap_content"   android:layout_height="@dimen/menu_item_icon_size"   >        <ImageView         android: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"/>           <TextView         android: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> 

Здесь сам счётчик мы привязываем к левому краю иконки и указываем фиксированный отступ: это нужно для того, чтобы при увеличении длины текста значения счётчика основная иконка у нас не перекрывалась сильнее — это некрасиво.

В res/values/dimens добавляем:

<dimen name="menu_item_icon_size">24dp</dimen> <dimen name="counter_left_margin">14dp</dimen> <dimen name="counter_badge_radius">6dp</dimen> <dimen name="counter_text_size">9sp</dimen> <dimen name="counter_text_horizontal_padding">4dp</dimen>

Размер иконки в соответствии с гайдом по Material Design.

В res/values/colors добавляем:

<color name="counter_background_color">@android:color/holo_red_light</color> <color name="counter_text_color">@android:color/white</color> 

В res/values/styles добавляем:

<style name="CounterText">   <item name="android:fontFamily">sans-serif</item>   <item name="android:textSize">@dimen/counter_text_size</item>   <item name="android:textColor">@color/counter_text_color</item>   <item name="android:textStyle">normal</item> </style> 

Создаём в res/drawable/ ресурс counter_background.xml:

<?xml version="1.0" encoding="utf-8"?> <shape xmlns:android="http://schemas.android.com/apk/res/android"       android:shape="rectangle">   <solid android:color="@color/counter_background_color"/>   <corners android:radius="@dimen/counter_badge_radius"/> </shape> 

В качестве иконки берём свою картинку, называем её icon и укладываем в ресурсы.

В res/menu создаём файл menu_main.xml:

<?xml version="1.0" encoding="utf-8"?> <menu xmlns:android="http://schemas.android.com/apk/res/android"      xmlns:app="http://schemas.android.com/apk/res-auto">    <item         android:id="@+id/action_counter_1"         android:icon="@drawable/icon"         android:title="icon"         app:showAsAction="ifRoom"/>  </menu> 

Создаём класс, конвертирующий разметку в 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;  public class LayoutToDrawableConverter {     public static 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);      return new BitmapDrawable(context.getResources(), bitmap);   } } 

Далее, в нужной нам Activity добавляем:

  private int mCounterValue1 = 0;    @Override     public boolean onCreateOptionsMenu(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));         return true; }  @Override public boolean onOptionsItemSelected(final MenuItem item) {   switch (item.getItemId()) {      case R.id.action_counter_1:         updateFirstCounter(mCounterValue1 + 1);         return true;      default:         return super.onOptionsItemSelected(item);   } }   private void updateFirstCounter(int newCounterValue){     mCountrerValue1 = newCounterValue;     invalidateOptionsMenu(); } 

Теперь при необходимости обновления счётчика вызываем метод updateFirstCounter, передавая в него актуальное значение. Здесь я повесил увеличение значения счётчика при нажатии на иконку. С остальными реализациями буду поступать так же.

Нужно обратить внимание на следующее: мы формируем изображение, которое потом скармливаем элементу меню — все необходимые отступы формируются автоматически, нам их учитывать не надо.

Решение второе

Концепция


В этой реализации мы формируем иконку на основе многослойного элемента, описанного в LayerList, в котором в нужный момент отрисовываем непосредственно сам счётчик, оставляя иконку без изменений.

Реализация


Здесь и далее я буду постепенно добавлять ресурсы и код для всех реализаций.

В res/drawable/ создаём ic_layered_counter_icon.xml:

<?xml version="1.0" encoding="utf-8"?> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">     <item      android:drawable="@drawable/icon" android:gravity="center" />     <item      android:id="@+id/ic_counter" android:drawable="@android:color/transparent" />  </layer-list> 

В res/menu/menu_main.xml добавляем:

<item   android:id="@+id/action_counter_2"   android:icon="@drawable/ic_layered_counter_icon"   android:title="layered icon"   app:showAsAction="ifRoom"/> 

В res/values/dimens добавляем:

<dimen name="counter_text_vertical_padding">2dp</dimen> 

Создаём файл 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;  public class CounterDrawable extends Drawable {     private Paint mBadgePaint;   private Paint mTextPaint;   private Rect mTxtRect = new Rect();     private String mCount = "";   private boolean mWillDraw;     private Context mContext;     public CounterDrawable(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);   }     @Override   public void draw(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);   }     public void setCount(String count) {      mCount = count;           mWillDraw = !count.equalsIgnoreCase("0");      invalidateSelf();   }     @Override   public void setAlpha(int alpha) {      // do nothing   }     @Override   public void setColorFilter(ColorFilter cf) {      // do nothing   }     @Override   public int getOpacity() {      return PixelFormat.UNKNOWN;   } } 

Этот класс будет заниматься отрисовкой счётчика в верхнем правом углу нашей иконки. Самый простой способ отрисовки бэкграунда счётчика — просто отрисовать прямоугольник со скругленными углами, вызвав canvas.drawRoundRect, но данный способ подходит для версии API выше 21-й. Хотя и для более ранних версий API это делается не особо сложно.

Далее, в нашей Activity добавляем:

private int mCounterValue2 = 0; private LayerDrawable mIcon2;  private void initSecondCounter(Menu menu){   MenuItem menuItem = menu.findItem(R.id.action_counter_2);   mIcon2 = (LayerDrawable) menuItem.getIcon();     updateSecondCounter(mCounterValue2); }  private void updateSecondCounter(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); } 

Добавляем код в onOptionsItemSelected. С учётом кода для первой реализации этот метод будет выглядеть так:

@Override public boolean onOptionsItemSelected(final MenuItem item) {   switch (item.getItemId()) {      case R.id.action_counter_1:         updateFirstCounter(mCounterValue1 + 1);         return true;      case R.id.action_counter_2:         updateSecondCounter(++mCounterValue2);         return true;      default:         return super.onOptionsItemSelected(item);   } } 

Вот и всё, вторая реализация готова. Как и в прошлый раз, обновление счётчика я повесил на нажатие по иконке, но его можно инициализировать откуда угодно, вызвав метод updateSecondCounter. Как видно, мы отрисовываем счётчик на канвасе руками, но можно придумать и что-то более интересное — всё зависит от вашей фантазии или от пожелания заказчика.

Решение третье

Концепция


Для элемента меню используем не изображение, а элемент с произвольной разметкой. Затем находим компоненты этого элемента и сохраняем ссылки на них.

В данном случае нас интересует ImageView иконки и TextView счётчика, но на деле это может быть и что-то более кастомное. Тут же прикручиваем обработку нажатия на данный элемент. Это необходимо сделать, так как для кастомных элементов в тулбаре метод onOptionsItemSelected не вызывается.

Реализация

Создаём в res/layouts файл разметки badge_with_counter.xml:

<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"   android:layout_width="wrap_content"   android:layout_height="wrap_content">     <RelativeLayout      android:layout_width="@dimen/menu_item_size"      android:layout_height="@dimen/menu_item_size">           <ImageView         android: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" />           <TextView         android: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> 

В res/values/dimens добавляем:

<dimen name="menu_item_size">48dp</dimen> 

Добавляем в res/menu/menu_main.xml:

<item   android:id="@+id/action_counter_3"   app:actionLayout="@layout/badge_with_counter"   android:title="existing action view"   app:showAsAction="ifRoom"/> 

Далее, в нашей Activity добавляем:

private int mCounterValue3 = 0;  private ImageView mIcon3; private TextView mCounterText3;  private void initThirdCounter(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); }  private void onThirdCounterClick(){   updateThirdCounter(++mCounterValue3); }  private void updateThirdCounter(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));   } } 

В onPrepareOptionsMenu добавляем:

initThirdCounter(menu);

Теперь, с учётом предыдущих изменений, этот метод выглядит так:

@Override public boolean onPrepareOptionsMenu(final Menu menu) {     // the second counter   initSecondCounter(menu);   // the third counter   initThirdCounter(menu);     return super.onPrepareOptionsMenu(menu); } 

Готово! Обратите внимание, что для нашего элемента мы взяли разметку, в которой самостоятельно указали все необходимые размеры и отступы — в данном случае система за нас этого делать не будет.

Решение четвёртое

Концепция


То же самое, что и в предыдущем варианте, но здесь мы создаём и добавляем наш элемент прямо из кода.

Реализация


В Activity добавляем:

private int mCounterValue4 = 0;  private void addFourthCounter(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); }  private void onFourthCounterClick(){   updateFourthCounter(++mCounterValue4); }  private void updateFourthCounter(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));   } } 

В данном варианте добавление нашего элемента в меню нужно делать уже в onCreateOptionsMenu

С учётом предыдущих изменений этот метод теперь выглядит так:

@Override public boolean onCreateOptionsMenu(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);   return true; } 

Готово!

На мой взгляд, последние два решения — самые простые и элегантные, к тому же самые короткие: мы просто выбираем необходимую нам разметку элемента и закидываем её в тулбар, а содержание обновляем как при работе с обычной View.

Казалось бы, почему мне просто не описать данный подход и не остановиться на этом? Причин тут две:

  • во-первых, мне хочется показать, что у одной задачи может быть несколько решений;
  • во-вторых, каждый из рассмотренных вариантов имеет право на жизнь.

Помните, я писал, что можно относиться к этим решениям не только как к реализации иконки со счётчиком, а использовать их в каком-то очень сложном и интересном кастомном элементе для тулбара, для которого одно из предложенных решений окажется наиболее подходящим? Приведу пример.

Из всех рассмотренных способов самый спорный — первый, так как он довольно сильно нагружает систему. Его использование может быть оправдано в том случае, когда у нас есть требование скрыть детали формирования иконки и передавать в тулбар уже сформированное изображение. Однако следует учитывать, что при частом обновлении иконки таким способом мы можем нанести серьёзный удар по производительности.

Второй способ нам подойдёт тогда, когда нужно отрисовать что-то на канвасе самостоятельно. Третья и четвёртая реализации наиболее универсальны для классических задач: поменять значение текстового поля вместо формирования отдельного изображения будет вполне удачным решением.

Когда возникает необходимость реализовать какую-то непростую графическую фичу, я обычно говорю себе: «Нет ничего невозможного — вопрос лишь в том, сколько времени и сил нужно потратить на реализацию».

Теперь у вас есть несколько вариантов для достижения поставленной задачи и, как видно, сил и времени на реализацию каждого варианта нужно совсем немного.

FavoriteLoadingДобавить в избранное
Posted in Без рубрики

Добавить комментарий