Pull to refresh

Floating toolbar for text selection в Android Marshmallow: разбор нововведения

Reading time 9 min
Views 13K

В Andriod при выделении текста появляется меню с действиями, которые можно выполнить: «Вырезать», «Скопировать», «Отправить». В Android Marshmallow (SDK 23) появилась возможность расширять это меню и давать пользователю легкий доступ к дополнительным возможностям при работе с текстом: «Перевести», «Прокомментировать», «Процитировать».

В процессе подготовке к выступлению на конференции GDG в Нижнем Новгороде я обнаружил, что эта новая возможность крайне плохо документирована, единственная доступная статья не во всем соответствует действительности, и в сети находится исчезающе мало примеров использования этой возможности. Пришлось разбираться самому. Результатами проведенного исследования и хочу поделиться. Это может сэкономить вам достаточно много времени.

Поскольку различные меню в Android появлялись эволюционно, проще всего рассказ начать «от печки». Разработчики с опытом могут смело листать сразу в раздел «Новое» без риска что-нибудь пропустить. Если что – потом вернетесь. Разработчики помоложе могут под спойлерами найти полезные на практике примеры.

Старое


Первое. Меню вульгарис


Рассказ про самое старое обычное меню. С примерами и скриншотами
Меню в Android устроены достаточно однотипно: они описываются с помощью XML, затем в нужный момент в соответствии с этим описанием с помощью MenuInflater создаются соответствующие объекты в памяти, это меню отображается, и информация о нажатии на соответствующий элемент приходит в колбэк.

Обычное меню, которое во времена оно вызывалось нажатием на аппаратную клавишу «Menu», описывается в ресурсах приложения в виде XML-файла.

<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item
        android:id="@+id/action_foo"
        android:orderInCategory="100"
        android:title="@string/word_foo"/>
    <item
        android:id="@+id/action_foobar"
        android:orderInCategory="101"
        android:title="@string/word_bar"/>
    <item
        android:id="@+id/action_baz"
        android:orderInCategory="102"
        android:title="@string/word_baz"/>
</menu>

Пункты меню будут отсортированы по полю android:orderInCategory.

Создается меню вызовом инфлейтера в методе onCreateOptionsMenu().

@Override
public boolean onCreateOptionsMenu(Menu menu) {
    MenuInflater inflater = getMenuInflater();
    inflater.inflate(R.menu.my_menu, menu);
    return true;
}

Информация о выборе пункта меню попадает в метод onOptionsItemSelected().

@Override
public boolean onOptionsItemSelected(MenuItem item) {
    // Handle item selection
    switch (item.getItemId()) {
        case R.id.action_foo:
            toast("foo");
            return true;
        case R.id.action_foobar:
            toast("bar");
            return true;
        case R.id.action_baz:
            toast("baz");
            return true;
        default:
            return super.onOptionsItemSelected(item);
    }
}

Все описанное выше приводит к появлению такого меню:



Все просто и привычно.

Второе. Контекстное меню


Рассказ про контекстное меню. С примерами и скриншотами
Контекстное меню – это плавающее меню, которое появляется при длительном нажатии на какой-либо элемент интерфейса. Этот элемент должен быть предварительно зарегистрирован для работы с контекстным меню.

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

Рассмотрим простой пример.

Зарегистрировать элемент можно, например, в onCreate():

@Override
protected void onCreate(Bundle savedInstanceState) {
    ...
    registerForContextMenu(findViewById(R.id.text_view_one));
}

Дальше все аналогично предыдущему пункту, только внутри других методов (onCreateContextMenu() и onContextItemSelected()):

@Override
public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) {
    super.onCreateContextMenu(menu, v, menuInfo);
    MenuInflater inflater = getMenuInflater();
    inflater.inflate(R.menu.my_menu, menu);
}

@Override
public boolean onContextItemSelected(MenuItem item) {
    switch (item.getItemId()) {
        case R.id.action_foo:
            toast("foo");
            return true;
        case R.id.action_foobar:
            toast("bar");
            return true;
        case R.id.action_baz:
            toast("baz");
            return true;
        default:
            return super.onContextItemSelected(item);
    }
}

При долгом нажатии на элемент с текстом появляется контекстное меню.

Скриншот



Третье. Меню в AppBar и Toolbar


Рассказ про меню с Toolbar. С примерами и скриншотами
Начиная с Android Honeycomb (он же 3.0, он же SDK 11, то есть тоже достаточно давно) аппаратная кнопка Menu была упразднена, а меню стало отображаться в «строке действий», она же «application bar». Появилась возможность часть пунктов меню показать в этой строке в виде иконок, а часть оставить скрытыми до нажатия на находящийся справа значок меню.

Поля для описания иконок и расположения дополнили XML с описанием меню. В остальном это осталось все то же меню с известными нам onCreateOptionsMenu() и onOptionsItemSelected().

Меню с иконками и расположением. Добавились android:icon и android:orderInCategory
<?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_foo"
        android:icon="@android:drawable/ic_media_previous"
        android:orderInCategory="100"
        android:title="@string/word_foo"
        app:showAsAction="ifRoom"
        />
    <item
        android:id="@+id/action_foobar"
        android:icon="@android:drawable/ic_media_next"
        android:orderInCategory="101"
        android:title="@string/word_bar"
        app:showAsAction="ifRoom"
        />
    <item
        android:id="@+id/action_baz"
        android:icon="@android:drawable/ic_media_pause"
        android:orderInCategory="102"
        android:title="@string/word_baz"
        app:showAsAction="never"/>
</menu>



Видимая часть меню

Скрытая часть меню,
если нажать на «три точки»


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


Четвертое. Режим контекстных действий (Contextual action mode)


Рассказ про contextual action mode. С примерами и скриншотами
Режим контекстных действий, он же «Contextual action mode» позволяет показать пользователю набор действий, которые можно выполнить над выбранным элементом. Как и контекстное меню, этот инструмент удобен при работе со списками, но списки в этой статье мы не рассматриваем.

Для того, чтобы рассмотреть работу этого режима, нам потребуется элемент, который можно «выбрать». Возьмем для примера ToggleButton. Данный режим приложение активирует само, соответственно, сделаем это при изменении статуса ToggleButton:

checkedListener =
        new CompoundButton.OnCheckedChangeListener() {
            @Override
            public void onCheckedChanged(CompoundButton buttonView,
                                         boolean isChecked)
            {
                if (isChecked) {
                    actionMode = startActionMode(actionModeCallback);
                    actionMode.setTitle("Action Mode");
                } else {
                    if (actionMode != null) {
                        actionMode.finish();
                    }
                }
            }
        };
toggleButton.setOnCheckedChangeListener(checkedListener);

actionModeCallback – это экземпляр класса ActionMode.Callback, который содержит колбэки для работы с меню. Меню при этом осталось все тем же, все с той же старой доброй механикой:

actionModeCallback = new ActionMode.Callback() {
    @Override
    public boolean onCreateActionMode(ActionMode mode, Menu menu) {
        MenuInflater inflater = mode.getMenuInflater();
        inflater.inflate(R.menu.my_menu_two, menu);
        return true;
    }
    ...

    @Override
    public void onDestroyActionMode(ActionMode mode) {
        actionMode = null;
        toggleButton.setChecked(false);
    }
};

Обратите внимание на значение, которое мы сохраняем в переменной actionMode. Это экземпляр класса ActionMode, нам он нужен для того, чтобы была возможность изменить заголовок (actionMode.setTitle()), подзаголовок (actionMode.setSubtitle()), а также завершить этот режим (actionMode.finish()). После выполнения действия режим автоматически не завершается, и если нам нужно, то завершить его мы должны сами.

Обрабатывается выбор пользователя тоже привычным способом:

actionModeCallback = new ActionMode.Callback() {
    ... 

    @Override
    public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
        switch (item.getItemId()) {
            case R.id.action_compass:
                toast("compass");
                return true;
            case R.id.action_camera:
                toast("camera");
                return true;
            default:
                return false;
        }
    }
};

Меню для контекстных действий, очевидно, должно отличаться от основного, поэтому создадим еще один XML c описанием.

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_camera"
        android:icon="@android:drawable/ic_menu_camera"
        android:orderInCategory="100"
        android:title="@string/word_camera"
        app:showAsAction="ifRoom"
        />
    <item
        android:id="@+id/action_compass"
        android:icon="@android:drawable/ic_menu_compass"
        android:orderInCategory="101"
        android:title="@string/word_compass"
        app:showAsAction="ifRoom"
        />
</menu>


Получаем вот такое поведение:



Все старенькое, то есть известное по крайней мере с SDK 11 теперь закончилось, началось неизвестное.


Новое


В Android Marshmallow (он же 6.0, он же SDK 23) в работе меню появилось два новшества. Оба эти новшества еще не поддерживаются Support Library и работают только на устройствах с SDK 23. Поэтому прежде чем вызывать появившиеся методы, необходимо проверять номер SDK устройства, на котором запущено приложение.

Для удобства рассказа мы избавимся от этих проверок, указав minSdkVersion 23.

Пятое. Еще одно контекстное меню


Метод для создания «Режима контекстных действий» (описан выше) был расширен. Вторым параметром startActionMode() можно передать константу, которая задает тип отображения.

Значение ActionMode.TYPE_PRIMARY соответствует старому поведению. То есть startActionMode(actionModeCallback) и startActionMode(actionModeCallback, ActionMode.TYPE_PRIMARY) – одно и тоже.

Если задать тип ActionMode.TYPE_FLOATING, то меню приобретает следующий вид:



Иконок нет. Расположение – горизонтальное.

Если пунктов много настолько, что не влезают по ширине, то появятся уже знакомые «три точки»:



Все остальное – точно также, как в «Contextual action mode» (см. выше).

Шестое. Расширение меню при выделении текста


Ну и, наконец, то, что было обещано в статье о нововведениях в Android Marshmallow. Появилась возможность дополнить меню, появляющееся при выделении текста, своими пунктами. Анимированная картинка этого чуда была в самом начале статьи, но если хотите, она еще раз под спойлером.

Анимированная картинка с меню


Реализация


Чтобы добиться такого эффекта, сначала нужно создать унаследованный от ActionMode.Callback колбэк. То есть полностью аналогичный тому, что мы создавали двумя разделами выше для «Contextual action mode».

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

textView.setTextIsSelectable(true);
textView.setCustomSelectionActionModeCallback(actionModeCallback);

editText.setCustomSelectionActionModeCallback(actionModeCallback);
editText.setCustomInsertionActionModeCallback(actionModeCallback);

Колбэк, указанный в setCustomSelectionActionModeCallback(), будет использоваться, если есть выделенный текст. Указаный в setCustomInsertionActionModeCallback() – если текста нет. Разделили их потому, что не все действия имеют смысл, когда ничего не выделено, и, соответственно, содержимое появляющихся меню должно быть разным.



На пустом EditText выглядит вот так:




Ой. Куда делось мое меню?


Посмотрите на скриншоты ниже. Есть идея, куда делись все добавленные пункты меню?



Подсказку можно найти здесь:



Поясню. Достаточно велика вероятность то, что во вторую, вертикальную часть меню оставшиеся пункты тоже не поместятся. И там появится скролл. На скриншотах вверху так и случилось.

Проблема в том, никаих признаков скролла пользователю не видно: значок скролла почти сразу исчезает, а край непоместившегося пункта не выглядывает. Завел багу, посмотрим, что создатели этой фичи скажут. code.google.com/p/android/issues/detail?id=195043

Куда опять делось мое меню?




На этот раз другая проблема: меню при fullscreen input mode описанным выше способом не расширяется. Воркэраунд я нашел только один: выключить fullscreen режим с помощью android:imeOptions=«flagNoExtractUi».

Как разместить свой пункт перед стандартными?


Родные пункты меню имеют параметры order от 1 до 5. Поэтому с помощью android:orderInCategory в описании меню задать положение перед родными пунктами нельзя. Но можно изменить порядок пунктов в уже сформированном меню, например, так:

@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
    // родные пункты меню "нумеруются" с 1, 
    // дополнительные я "пронумеровал" со 100
    while (menu.getItem(0).getOrder() < 100) {
        MenuItem item = menu.getItem(0);
        menu.removeItem(item.getItemId());
        // теперь родные будут "нумероваться" с 200, то есть
        // станут после дополнительных
        menu.add(item.getGroupId(), item.getItemId(),
                item.getOrder() + 200, item.getTitle());
    }
    return true;
}

Получаем результат:



Странности в документации


Нормальной документации на расширение меню при выделении текста нет. Есть уже упоминавшаяся статья статья о новинках в Android Marshmallow.

Я несколько десятков раз перечитывал это место, но так и не смог соотнести то, что там написано, с практикой. Рассказываю по порядку.



Если вы прочитали эту статью или попробовали сами, то заметили, что startActionMode(callback, ActionMode.TYPE_FLOATING) действительно создает floating toolbar, но совсем не для selection. А для selection его создает setCustomSelectionActionModeCallback().



Вторая непонятная инструкция. Метод setCustomSelectionActionModeCallback() класса android.widget.TextView ожидает получить ActionMode.Callback.



android.googlesource.com/platform/frameworks/base/+/master/core/java/android/widget/TextView.java

Если посмотреть в android.widget.Editor, то упомянутое поле mCustomSelectionActionModeCallback также имеет тип ActionMode.Callback:



И нигде в этом коде не ожидается, что custom callback будет типа ActionMode.Callback2.
android.googlesource.com/platform/frameworks/base/+/master/core/java/android/widget/Editor.java

Статья же продолжает упорно рассказывать о том, как использовать ActionMode.Callback2:



Мое предположение, что в статью по недосмотру редактора попал фрагмент какой-то внутренней документации по ActionMode.Callback2. Есть у вас есть другие гипотезы, напишите об этом.

Пример


Целиком код из этой статьи можно найти на GitHub.
Tags:
Hubs:
+12
Comments 2
Comments Comments 2

Articles