2 февраля в 09:46

Как подружить Custom View и клавиатуру

Введение


«МойОфис» работает на большинстве современных платформ: это Web-клиент, настольные версии приложения для Windows, MacOS и Linux, а также мобильные приложения для iOS, Android, Tizen. И если в разработке компьютерных приложений уже давно есть основные правила подхода к дизайну интерфейсов, то при создании приложений для мобильных устройств требуется отдельная проработка многих особенностей.



При разработке текстового редактора мы отказались от стандартного EditText и сделали свою реализацию компонента для ввода и форматирования текста, основываясь на компоненте TextureView. Стандартный механизм не давал нам возможности добавлять таблицы, изображения, применять стили, цвета, работу со списками, отступами и многое другое. Использование своего компонента дает нам гибкость при добавлении новой функциональности и позволяет оптимизировать производительность компонента.

Одна из задач компонента — дать пользователю возможность вводить данные с клавиатуры, редактировать текст, применять возможности автозамены и корректировки текста. Далее будет рассказано, как реализовать кастомный элемент, взаимодействующий с клавиатурой, получить введенный текст и отправить изменения в клавиатуру. За рамки данной статьи выходит создание custom keyboard (можно почитать тут, тут или тут).


CustomView и клавиатура


Весь процесс взаимодействия View-элементов с клавиатурой происходит через интерфейс InputConnection. В Android SDK уже существует базовая реализация — BaseInputConnection, позволяющая получать события от клавиатуры, обрабатывать их и взаимодействовать с интерфейсом Editable, который является результатом полученных данных для компонента.

Но начнем по порядку. Чтобы связаться с клавиатурой, прежде всего необходимо у разрабатываемого компонента определить реализацию интерфейса для взаимодействия — подписаться на события от клавиатуры. Помимо этого, в саму клавиатуру можно передать ряд настроек, которые влияют на тип клавиатуры и ее поведение. В итоге нужно переопределить метод компонента — onCreateInputConnection(...), возвращающий реализацию. В качестве атрибута приходят параметры клавиатуры, которые можно модифицировать.

@Override
   public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
       // конфигурация параметров клавиатуры
       outAttrs.inputType = InputType.TYPE_CLASS_TEXT;
       outAttrs.imeOptions = EditorInfo.IME_ACTION_DONE;
       outAttrs.initialSelEnd = outAttrs.initialSelStart = 0;
       
       // создание своего коннектора для клавиатуры
       return new BaseInputConnection(this, true);
   }

В указанном примере метод возвращает базовую реализацию интерфейса. А в EditorInfo указываются параметры для клавиатуры. О том, какие именно, можно посмотреть в документации тут. Например, с помощью inputType можно указать цифровой или текстовый ввод для клавиатуры.

Стоит отметить, что если в inputType передать значение TYPE_NULL, то у софтверной клавиатуры отключается автокомплит и все события будут приходить во View.onKeyDown (так же как и при работе с физической).

Если возникает необходимость изменить конфигурацию клавиатуры, когда она уже показывается (например, изменился тип ввода), то следует вызвать метод restartInput. В этом случае onCreateInputConnection будет вызван повторно и в EditorInfo можно будет передать новые значения. Но стоит учитывать, что при этом произойдет пересоздание и самого InputConnection.

Следующий шаг — вызов метода setFocusableInTouchMode(true) (например, из метода onFinishInflate() или с помощью атрибута в разметке). С его помощью компонент указывает, что может перехватывать фокус. А взаимодействие с клавиатурой может быть только у того элемента, который сейчас в фокусе. Если этого не сделать, метод onCreateInputConnection вызываться не будет.

@Override
   protected void onFinishInflate() {
       super.onFinishInflate();
 
       setFocusableInTouchMode(true);
       ...
   }

Дополнительно стоит отметить, что при таче на компонент нужно инициировать открытие клавиатуры. Автоматически это не произойдет, поэтому об этом нужно позаботиться. Один из вариантов, как это можно сделать:

 setOnClickListener(new OnClickListener() {
           @Override
           public void onClick(View v) {
               InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
               imm.showSoftInput(v, 0);
           }
       });

Последнее, что нужно выполнить, — переопределить метод onCheckIsTextEditor и возвращать значение TRUE (по умолчанию false). Согласно документации — является подсказкой для системы, чтобы автоматически показать клавиатуру на экране.

public class CustomView extends View {
   ...
   @Override
   public boolean onCheckIsTextEditor() {
       return true;
   }
   …
}

В итоге, чтобы наладить взаимодействие с клавиатурой, необходимо:

1. Переопределить метод onCreateInputConnection, указав реализацию интерфейса взаимодействия, а также параметры для клавиатуры.
2. Вызвать setFocusableInTouchMode(true) при инициализации компонента.
3. Вызывать imm.showSoftInput(...) для отображения клавиатуры при таче на компонент.
4. Возвращать TRUE в методе onCheckIsTextEditor.

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

Input Connection


Ранее уже было отмечено, что в Android SDK существует базовая имплементация интерфейса InputConnection — BaseInputConnection. В ней добавлены основная логика, позволяющая взаимодействовать с клавиатурой, и делегирование полученных событий в объект Editable, с которым и должен в будущем работать кастомный компонент. Для разработки рекомендуется отнаследоваться от него и переопределить метод getEditable(), передавая туда свою реализацию Editable.

 public class TestInputConnection extends BaseInputConnection {
       ...
       @Override
       public Editable getEditable() {
           return mCustomView.getEditable();
       }
       ...
   }

Более подробно про интерфейс Editable будет рассказано чуть ниже. Пока же хочется рассмотреть некоторые методы InputConnection, которые могут оказаться кому-то полезными. С полной документацией по всем методам можно ознакомиться тут. При этом стоит отметить, что последовательность вызовов методов, значение параметров, с которыми они вызываются, зависит от реализации клавиатуры, используемой в момент ввода на устройстве, и могут отличаться.

beginBatchEdit() и endBatchEdit()


Информирует о начале и окончании набора действий с клавиатуры. Например, с клавиатуры в режиме Т9 вводится пробел после текста. В этом случае произойдет последовательно вызов finishComposingText() и commitText() в рамках одного batch-события. То есть последовательность будет примерно такой:

beginBatchEdit
finishComposingText
beginBatchEdit
endBatchEdit
commitText
beginBatchEdit
endBatchEdit
endBatchEdit

Обратите внимание, что допускается вложенность batch’ей. То есть необходимо считать количество начавшихся вызовов и количество закончившихся, чтобы определить, закончился ли процесс или нет. К примеру, можно заглянуть в реализацию EditableInputConnection — реализация для TextView, где как раз происходит инкрементация при каждом begin и декремент при end.

Важно! До тех пор пока не закончился batch, не рекомендуется отправлять события от редактора в клавиатуру (например, изменение положения курсора).

setComposingText()


Метод вызывается в тех случаях, когда с клавиатуры вводится так называемый составной текст. Например, голосовой ввод, ввод текста в режиме автозамены и т.д. То есть тот текст, который может быть откорректирован/заменен с клавиатуры.

Пример ввода слова test:

setComposingText t 1
setComposingText te 1
setComposingText tes 1
setComposingText test 1

В качестве параметров метода приходит новое значение составного текста. Далее это значение передается в Editable и отмечается с помощью специальных spans (метка начала и окончания composing text). При каждом новом составном тексте предыдущий удаляется согласно отмеченным spans. Таким образом происходит автозамена текста.

finishComposingText()


Тут все довольно просто, метод будет вызван в тот момент, когда клавиатура решает, что текст далее не будет корректироваться и пользователем введена финальная версия. При этом в Editable удаляется вся информация о composing Text.

commitText()


Вызывается метод с параметрами CharSequence text, int newCursorPosition, когда добавляемый текст утвержден, то есть его корректировка не планируется. Например, с клавиатуры выбирается suggest. В этом случае приходит значение текста, которое должно быть добавлено на место текущего курсора или вместо compose-текста, если он был. А также информация по новому положению курсора для редактора. Значение > 0 (например, 1) будет означать положение курсора в конце нового текста. Любое другое значение — в начале.


deleteSurroundingText()


Метод вызывается с 2 параметрами — int beforeLength, int afterLength и информирует о том, что необходимо удалить часть текста до текущего положения курсора и после. При этом если есть выделение текста, то данные символы игнорируются.

Например, данный метод вызывается, когда пользователь в режиме Т9 нажимает на текст в редакторе и заменяет выбранное слово из списка подсказок.

Реализация Editable


BaseInputConnection тесно взаимодействует с интерфейсом Editable, реализацию которого нужно передавать в методе getEditable(). Все методы интерфейса можно разделить на 3 типа:

* модификация и получение текста;
* работа со spans;
* применение фильтров.

Если заглянуть в реализацию TextView, то видно, что метод getText() возвращает Editable. А точнее, реализацию SpannableStringBuilder, являющуюся основной и готовой для хранения и модификации текста, работы с фильтрами и со spans.

Если по каким-то причинам стандартная реализация не подходит, можно реализовать свою. Основным методом работы с изменением текста является replace(...). Все insert, append, delete и тд. вызывают замену текста определенного участка на новый. Но стоит не забывать, что перед заменой надо применить набор фильтров для текста. Далее важно корректно реализовать работу со spans, которые позволяют вешать метки: положение курсора, выделение текста, composing region (начало и конец региона для автозамены) и т.д.

TextWatcher


Допустим, что нас устроят стандартная реализация взаимодействия с клавиатурой и стандартный Editable. Теперь вернемся к разрабатываемому компоненту и подпишемся на изменения в Editable. Делается это довольно просто, добавлением специального spans с объектом TextWatcher.

mEditable.setSpan(mMyTextWatcher, 0, mEditable.length(),
Spanned.SPAN_INCLUSIVE_INCLUSIVE);

После этого при любом изменении editable будут приходить уведомления. Важно указать флаг — SPAN_INCLUSIVE_INCLUSIVE, позволяющий не удалять слушателя при вызове метода clearSpans() (например, вызывается, когда происходит finishComposingText).

Получая уведомления, можно взять следующую информацию:

* mEditable.toString() вернет весь текст. Его можно отображать на UI — это то, что введено пользователем.
* Методы класса Selection нужны для получения информации о курсоре и выделении.

setText()


Предположим у компонента есть метод setText(). Нужно обновить значение Editable разрабатываемого компонента и уведомить клавиатуру о том, что предыдущий текст в буфере клавиатуры не валиден. Делается это созданием нового объекта Editable и вызовом метода restartInput.

 public void setText(@NonNull String newText) {
       mEditable = Editable.Factory.getInstance().newEditable(newText);
       mImm.restartInput(this);
   }

Изменение позиции курсора


Для полноценного взаимодействия с клавиатурой необходимо добавить поддержку позиционирования курсора. В случае ввода текста в методах setComposingText() и commitText() приходит значение в параметре cursorPosition, который определяет, в начале или в конце добавляемого текста будет располагаться курсор. В случае реализации через BaseInputConnection нет необходимости заботиться о положении курсора, логика уже реализована внутри. Достаточно воспользоваться методом Selection.getSelectionStart и getSelectionEnd, чтобы узнать позицию.

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

Для оповещения используется метод updateSelection, куда передается информация о новом положении пинов. Стоит не забывать, что до тех пор, пока не закончится batch в InputConnection, уведомление отправлять не стоит.

Замена слова с помощью T9


Теперь небольшой пример, когда пользователь использует клавиатуру с Т9. Допустим, что в редакторе введено несколько слов и происходит нажатие на одно из них. При работе со стандартным EditText в клавиатуре будет показана подсказка, при нажатии на которую слово полностью заменится.


С EditText клавиатура получает информацию о новом положении курсора, отправит обратно в редактор информацию о текущем выбранном composing text через метод setComposingRegion, тем самым помечая слово, с которым будет дальше работать, а именно слово под курсором. Теперь если выбрать одну из подсказок, то вызовется метод: удалить текущее слово и вставить новое.

Но как показывают исследования, одного вызова метода updateSelection недостаточно. Слово не заменяется, а добавляется в текущее положение по курсору, так как не вызывается setComposingRegion.

Чтобы найти решение, стоит посмотреть на последовательность вызываемых методов InputMethodManager при работе с EditText, которая получается примерно такой:

inputMethodManager.viewClicked(view);
inputMethodManager.updateSelection(view, cursorPosition, cursorPosition, cursorPosition, cursorPosition);
// В данном случае -1 сбрасывает composing region в клавиатуре.
inputMethodManager.updateSelection(view, cursorPosition, cursorPosition, -1, -1);

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

Пример кода


public class CustomView extends View {
 
   @NonNull
   private Editable mEditable;
 
   @NonNull
   private final InputMethodManager mImm;
 
   @NonNull
   private final MyTextWatcher mMyTextWatcher = new MyTextWatcher();
 
   /**
    * Используется для проверки, в батче мы или нет. Если > 0  - в батче.
    */
   private int mBatch;
 
   public CustomView(Context context, AttributeSet attrs, int defStyleAttr) {
       super(context, attrs, defStyleAttr);
 
       mImm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
 
       // Инициализация editable.
       mEditable = Editable.Factory.getInstance().newEditable("");
       Selection.setSelection(mEditable, 0);
 
       // Начать перехватывать фокус
       setFocusableInTouchMode(true);
       // Показывать клавиатуру по нажатию
       setOnClickListener(v -> mImm.showSoftInput(v, 0));
 
       // Добавить слушателя на изменение контента в Editable
       mEditable.setSpan(mMyTextWatcher, 0, mEditable.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
   }
 
   @Override
   public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
       outAttrs.inputType = InputType.TYPE_CLASS_TEXT;
       outAttrs.imeOptions = EditorInfo.IME_ACTION_DONE;
       outAttrs.initialSelEnd = outAttrs.initialSelStart = 0;
 
       // Создание коннектора к клавиатуре.
       return new BaseInputConnection(this, true) {
           @Override
           public Editable getEditable() {
               return mEditable;
           }
 
           @Override
           public boolean endBatchEdit() {
               mBatch++;
               return super.endBatchEdit();
           }
 
           @Override
           public boolean beginBatchEdit() {
               mBatch--;
               return super.beginBatchEdit();
           }
       };
   }
 
   @Override
   public boolean onCheckIsTextEditor() {
       return true;
   }
 
   /**
    * Добавление нового текста и перезапуск коннектора.
    */
   public void setText(@NonNull String newText) {
       mEditable = Editable.Factory.getInstance().newEditable(newText);
       mImm.restartInput(this);
   }
 
   @Override
   public boolean onTouchEvent(MotionEvent event) {
       if (event.getAction() == MotionEvent.ACTION_UP && mBatch == 0) {
           int cursorPosition = 0; // Новое положение курсора (отдельная логика по вычислению)
 
           // notify keyboard that cursor position has changed.
           mImm.viewClicked(this);
           mImm.updateSelection(this, cursorPosition, cursorPosition, cursorPosition, cursorPosition);
           mImm.updateSelection(this, cursorPosition, cursorPosition, -1, -1);
       }
       return super.onTouchEvent(event);
   }
 
   private class MyTextWatcher implements TextWatcher {
 
       @Override
       public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {}
 
       @Override
       public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {
           Log.d("CustomView", "Current text: " + mEditable);
       }
 
       @Override
       public void afterTextChanged(Editable editable) {}
   }
}

Итог


Создание своего компонента-редактора, который не унаследован от EditText, довольно редкая задача. Но если с ней сталкиваешься, то приходится заниматься поддержкой клавиатуры. Как уже было написано в статье, самый простой способ — использовать уже готовую реализацию, которая есть в SDK. Но если по какой-то причине она не подходит, то стоит прежде всего опираться на методы, описанные в статье, — это основа. Дальше можно изучить подробнее документацию. А самый результативный способ — заглянуть в исходный код TextView.
Автор: @myoffice_ru
Похожие публикации

Комментарии (0)

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

Самое читаемое Разработка