Як подружити 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 можна вказати цифровий або текстовий enter для клавіатури.

Варто відзначити, що якщо в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 новий текст) {
mEditable = Editable.Factory.getInstance().newEditable(новий текст);
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 новий текст) {
mEditable = Editable.Factory.getInstance().newEditable(новий текст);
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.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.