Кастомные view для пунктов меню в приложении андроид

В этом уроке вы узнаете, как создавать интерактивные значки для пунктов меню, отображаемых в тулбаре android-приложения.

Android framework делает многое, чтобы помочь нам создать и взаимодействовать с элементами меню действий (это маленькие значки на правой стороне панели инструментов). Путем вызова нескольких методов установки framework будет автоматически обрабатывать три вещи для нас.

  1. Вставка view в панели инструментов, обеспечивая правильное размещение, размер изображения и пространство между соседями
  2. Добавление слушателя нажатия view
  3. Определение визуальной обратной связи при щелчке (например, изменение цвета фона или пульсации)

Все, что остается сделать — определить текст заголовка и значок drawable в нашем  файле макета меню, инфлейтить этот макет в onCreateOptionsMenu() и обрабатывать щелчки в onOptionsItemSelected(). Если вы когда-либо работали с пунктами меню — для вас здесь нет ничего нового. Если не работали — смотрите этот урок.

Разметка пункта меню:

<?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/activity_main_update_menu_item"
        android:icon="@drawable/ic_refresh_white_24dp"
        android:title="Update"
        app:showAsAction="ifRoom"/>

</menu>

Реализация в коде активити:

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);
        }

    }
}

Вот так это выглядит:

Но зачем мы хотим использовать кастомную view, а не просто иконку? Допустим, у нас есть представление, которое отображает количество предупреждений, полученных в нашем приложении. Нам нужно обновить значок, чтобы показать или скрыть красный круг с числом внутри. Допустим, что наш пункт меню «Обновить» инициирует вызов для выборки последнего числа оповещений и обновляет элемент оповещения меню. Наше окончательное решение будет выглядеть следующим образом:

Первое, что приходит в голову — динамично менять картинку drawable, используемую в пункте меню. Мы можем схитрить и просто подставлять 11 различных символов drawables для нашего приложения в цикле:

  • 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»

Это могло бы быть проще для нас как разработчиков (но больше работы для нашего дизайнера), но дополнительные ресурсы начнут раздувать размер нашего apk. Вместо этого мы можем использовать кастомные view, чтобы достигнуть того же самого эффекта с меньшим количеством ресурсов.

Определение Custom View

Ключевым моментом для кастомного view является использование app:actionLayout вместо android:icon в нашем файле ресурсов меню.

<?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/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>

Далее мы сделаем макет кастомного view в папке layouts.

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://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>

Теперь у нас есть красный круг — FrameLayout, который содержит TextView нашего счетчика оповещения . Мы также имеем ImageView нашего значка предупреждения. Наконец, мы должны обернуть все в корневой FrameLayout. Еще нам необходимо жестко закодировать размер нашего значка согласно правил материального дизайна. Мы определяем размеры иконки в файле dimens.xml.

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

Если мы запустим приложение, мы увидим новый значок, но возникают две проблемы:

  1. onOptionsItemSelected не вызывается при нажатии на кастомный пункт меню
  2. Значок визуально не реагирует на клики (т. е. нет пульсаций)

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

Использование Custom View

Мы хотим настраивать кастомное view в нашем меню при каждом отображении. Таким образом, вместо настройки его в onCreateOptionsMenu, мы будем делать некоторую работу внутри onPrepareOptionsMenu. Так как наш пункт меню — просто заполненный макет, мы можем работать с ним как с любым макетом. Например, мы можем найти view 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);
    }

    ...
}

Мы получаем доступ к корневому view-элементу этого пункта меню, находим сначала пункт меню, а затем вызываем getActionView. Теперь мы сможем найти красный круг и счетчик оповещений в поле textview.

Затем мы обновляем значок оповещения, когда пользователь нажимает на кнопку «обновить» пункта меню:

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);
    }
}

У нас теперь есть обновление пункта меню:

Фиксим траблы

Как я сказал прежде, у нас еще есть две проблемы:

  1. onOptionsItemSelected не вызывается при нажатии на кастомный пункт меню
  2. Значок визуально не реагирует на клики (т. е. нет пульсаций)

Давайте сначала займемся первой. По некоторым причинам, когда наш пункт меню зависит от  app:actionLayout вместо  android:icon, onOptionsItemSelected не будет вызываться для кастомного элемента меню. это известная проблема. Решение – просто добавить наш собственный слушатель ClickListener корневому view и вручную вызвать onOptionsItemSelected. Будем также сбрасывать счетчик предупреждений, когда пользователь щелкает пункт оповещения в меню:

 @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);
        }
    }

Теперь если вы запустите приложение, то обнаружите, что нужно нажать значок предупреждения несколько раз, прежде чем он отреагирует. Выберем «Показать границы макета» в параметрах разработчика и сразу же увидим проблему:

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

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://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>

Возвращаясь к рекомендациям по Материальному дизайну, мы должны установить новые размеры для корневого view 48dp высоты и ширины.

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

Это успешно увеличивает нашу область щелчка.

Последнее, что нам нужно сделать, это включить визуальную обратную связь при выборе пункта меню. Для Lollipop+ устройства это означает рябь; для более старых устройств это означает изменение цвета фона.К счастью для нас, эта функциональность уже содержится в  attr/selectableItemBackgroundBorderless. Поэтому все, что нам нужно это новое view в нашем файле макета, которому мы можем задать этот атрибут:

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

<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://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>

Идеально. Все выглядит хорошо. Пульсация хорошо смотрится на нашем устройстве Android 22 и изменение цвета фона выглядит хорошо на нашем устройстве Android 19.

API 19 device
API 22 device
API 22 device

Последний штрих

Прежде чем мы можем закончить этот код, обратите внимание, что это не совсем хорошо работает на устройствах API 23+. Границы ряби на нашем пункте меню гораздо больше, чем на стандартном меню:

Ripple границы на стандартном меню
Ripple границы на стандартном меню
Ripple границы кастомного меню
Ripple границы кастомного меню

Чтобы исправить это, нам нужно сделать несколько проб и ошибок, чтобы выяснить, правильный размер границы ряби. Мы его будем предоставлять для API 23+ устройств. Наконец, мы будем обновлять макет, чтобы использовать этот новый размер (вместо того, чтобы просто установить свойство match_parent для ripple view). Вы можете проверить самостоятельно, но на API 23+ Эта ряби граница должна быть 28dp.

<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>
<resources>
    <dimen name="menu_item_ripple_size">28dp</dimen>
</resources>
<?xml version="1.0" encoding="utf-8"?>

<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://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>

Теперь у нас эффект ряби кастомного пункта меню такого же размера, как и у стандартного пункта меню.

Вот так. Наше решение в настоящее время работает на всех последних версий Android. Не стесняйтесь загрузить этот образец здесь.

API 19 device
API 19 device
API 22 device

 

API 24 device

Перевод источника

Понравилась статья? Поделиться с друзьями:
Добавить комментарий