company_banner

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


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

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

    Так и случилось со мной, когда возникла необходимость добавить в верхнюю панель иконку со счётчиком. Я был очень удивлён, когда выяснилось, что для реализации такого привычного и востребованного элемента 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.

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

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

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

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

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

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

    Теперь у вас есть несколько вариантов для достижения поставленной задачи и, как видно, сил и времени на реализацию каждого варианта нужно совсем немного.
    • +35
    • 7.5k
    • 7
    FunCorp
    323.32
    Разработка развлекательных сервисов
    Share post

    Comments 7

      +1

      Нагенереные иконки подходят для ограниченного количества состояний, ну и к тому же каждый графический ресурс добавляет объем apk-файлу и увеличивает нагрузку на дизайнера ). Кстати, если уж использовать нагенереные иконки со значениями счётчика, то можно использовать второй способ, чтобы всю иконку не генерить.

        +5
        Вы бы хоть скриншот добавили, о чем речь вообще, что за иконка со счетчиком и где «в каждом втором приложении» она встречается. Тут ведь не все из мира андроид-разработки.
          +1

          Я так понимаю, речь о маленьком счётчике в углу бутерброда (кнопки меню). Но вот зачем такие сложности делать для такой действительно простой задачи — не понятно.
          Ставим ImageView бутерброда, у него лучше прописать margin'ы, берем TextView, у него background — #FFF, радиус скругления, нужные margin для положения и все. Далее только в текст-вью значение

            –1
            Класс! Жаль не могу плюсануть.

            Но вот мне всегда интересно было, часто ли в реальной жизни необходимы подобные вещи? Ну ведь гораздо проще просто нагенерить несколько готовых иконок с номерами типа '1', '2', '3', '4', '5', 'Много'.

            Ну ведь реально, в таком месте как тулбар ничего кроме индикатора делать не стоит.

            Хотя конечно решения любопытные.
              –1

              Непонятно, зачем вы используете до сих пор Relative Layou.

                0
                Интересно, а кто и за что минусанул? или я не прав насчет Relative? Можете аргументировать?
                  0
                  «у одной задачи может быть несколько решений» — кэповость, нет?

                  Only users with full accounts can post comments. Log in, please.