Як ми зробили Rich Text Editor з підтримкою співавторства під Android

малюнок
«Мобілізація» робочих процесів в компаніях означає, що на телефон або планшет переноситься все більше функцій для спільної роботи. Wrike, як міжплатформового сервісу управління проектами, важливо, щоб функціонал мобільного додатка був абсолютно повноцінним, зручним і не обмежував користувачів в роботі. І коли постало завдання створити Rich Text Editor з підтримкою спільного редагування опису завдань, ми, оцінивши можливості існуючих WebView компонентів, вирішили піти своїм шляхом і реалізували власний нативний інструмент.




Для початку трохи про історію продукту. Однією з базових функцій Wrike спочатку була інтеграція з поштою. З самої першої версії задачі можна було створювати і оновлювати через e-mail, а потім працювати над ними разом з іншими співробітниками. Тіло листа перетворювалося на опис завдання, а все подальше обговорення йшло в коментарях до неї.

Оскільки в пошті можна використовувати HTML форматування, у ранніх версіях продукту ми використовували CKEditor для подальшої роботи з описом завдання. Але в середовищі, орієнтованої на спільну роботу, це дуже незручно – необхідно блокувати весь документ або його частину, щоб підготовлене опис завдання не затер хтось інший. У підсумку ми вирішили заглибитися в практику Operation Transformation (OT) і зробити інструмент для цієї спільної роботи. У цій статті я не буду детально розглядати теорію і реалізацію OT для rich text документів, про це є вже досить матеріалів. Я розгляну лише складнощі, з якими зіткнулася наша команда при розробці мобільного додатку.

співавторство на смартфоні — але навіщо?

Можливо і нема чого, якщо, звичайно, це не є ключовою функцією вашого продукту. Крім загальної мети забезпечити максимум базової функціональності на всіх платформах, був ряд більш конкретних причин, по яким нам довелося про це задуматися:
  1. Реалізація OT вимагає зберігати документ у визначеному форматі, що підтримує спільне редагування. У разі простого тексту особливого формату тут немає — це може бути просто рядок. Але у випадку з Rich Text (текст з форматуванням), формат зберігання стає складніше.
  2. Нам потрібен спосіб зберігати зміни, зроблені мобільним клієнтом, не зламавши документ і не створивши конфлікт із змінами, які могли внести в той же проміжок часу, що інші користувачі. Це завдання, які вирішуються алгоритмами OT.
  3. Раз нам потрібно перенести алгоритм OT на мобільну платформу, щоб виконати умови з пункту 2, то зробити повноцінне редагування вже не вимагає значних додаткових зусиль.
Отже, у нас є rich text опис завдання як базовий функціонал, необхідність підтримувати специфічний формат документу та протоколу синхронізації, тому візьмемося за пошук рішення.

Варіанти реалізації

З реалізацією компонента для спільної роботи досвід вже був, а ось з тим, як перенести його на Android, належало розібратися. Багато що залежало від вимог до редактора і їх, за великим рахунком, було два:
  1. Підтримка базового форматування списків, вставка зображень і таблиць,
  2. API, що дозволяє вносити і відстежувати зміни як у самому тексті, так і в його форматування.


Спосіб 1: використовувати існуючий компонент з Web продукту
Дійсно, ми могли б використовувати компонент, який у нас вже є, і обернути його в WebView. З плюсів — простота інтеграції, оскільки фактично весь код редактора знаходиться в скриптах, і Android/iOS розробника залишається тільки реалізувати WebView wrapper.

Досить швидко стало зрозуміло, що існуючий компонент з основного додатка, що працює з ContentEditable документом, функціонує досить нестабільно в залежності від версії ОС і від вендора. Екзотичність багів місцями зашкалювала, але в основному вони спливали навколо функцій виділення і введення тексту, а також пропадає фокусу і клавіатури.

Щоб обійти проблеми ContentEditable, ми пробували використовувати CodeMirror як фронтенд для редактора, при цьому він помітно краще і стабільніше працює на Android, оскільки обробляє всі події від клавіатури і відображення самостійно. Були, звичайно, і мінуси, але як швидкий workaround він працював дуже непогано до тих пір, поки не з'явилося відоме зміна в обробці подій натискання клавіш в IME — досить докладно ця проблема обговорюється здесь. Якщо в двох словах — при використанні LatinIME, він не відправляє подія для KEYCODE_DEL.

Що це означає для користувача? При натисканні на Delete нічого не відбувається, тобто редактор працює коректно, можна вводити текст, застосовувати форматування… от тільки текст не можна видалити, як би це абсурдно не звучало. Єдиний варіант вирішення даної проблеми, крім усього іншого, включав в себе наступний код:

@Override
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
BaseInputConnection baseInputConnection = new BaseInputConnection(this, 'false') {
@Override
public boolean sendKeyEvent(KeyEvent event) {
if (needsKeyboardFix() && event.getAction() == KeyEvent.ACTION_MULTIPLE && event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN) {
passUnicodeCharToEditor(event);
return true;
}
return super.sendKeyEvent(event);
}

@Override
public boolean deleteSurroundingText(int beforeLength, int afterLength) {
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) && (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) &&
(beforeLength == 1 && afterLength == 0)) {
// Send Backspace key down and up events
return super.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL))
&& super.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL));
}
else {
return super.deleteSurroundingText(beforeLength, afterLength);
}
}
};

outAttrs.inputType = InputType.TYPE_NULL;

return baseInputConnection;
}

InputType.TYPE_NULL при цьому перекладав IME в «спрощений» вид, сигналізуючи, що InputConnection працює в обмеженому режимі, що означає відсутність copy/paste, autocorrect/autocomplete, а також введення тексту за допомогою жестів, але при цьому він дозволяє обробляти всі події клавіатури.

У підсумку, в останній реалізації редактора, який використовував веб-інтерфейс, були наступні недоліки:
  • повільна швидкість завантаження;
  • відсутність доступу до розширених можливостей IME (copy/paste, autocomplete/autocorrect, gesture input);
  • у деяких випадках нестабільна робота, у зв'язку з різною реалізацією WebView на різних версіях API і модифікації цього компонента деякими вендорами;
  • зазвичай WebView довго не тримається в пам'яті, особливо на девайсах з невеликим об'ємом пам'яті, і, якщо згорнути програму і через деякий час запустити заново, то в більшості випадків WebView доведеться знову ініціалізувати;
  • численні милиці в коді, кількість яких з часом лише зростала.
Усвідомивши, що підтримувати подібну імплементацію редактора непросто, та враховуючи описані недоліки і обмеження, було вирішено розробити нативний компонент, який би давав можливість працювати з форматованим текстом.

Спосіб 2: нативна реалізація
Для нативної реалізації необхідно вирішити два завдання:
  1. UI редактор, тобто відображення тексту з урахуванням форматування та його редагування.
  2. Робота з форматом документа, відстеження змін, а також обмін даними з сервером.
Для того, щоб вирішити першу задачу, не потрібно винаходити колесо — Android надає необхідні інструменти, а саме компонент EditText і інтерфейс Spannable, що описує маркування тексту.

Друга задача вирішується перенесенням алгоритмів OT з JavaScript на Java, і процес тут досить прозорий.

Відображення Rich Text в EditText

В Android є чудовий інтерфейс Spannable, який дозволяє задати розмітку тексту. Сам процес формування розмітки досить простий — потрібно скористатися спеціальним класом SpannableStringBuilder, який дозволяє задавати/змінювати текст, так і встановлювати стилі для заданих ділянок тексту через метод

setSpan(Object what, int start, int end, int flags). 

Перший параметр як раз задає стиль. Він повинен бути екземпляром класу, який реалізує один або декілька інтерфейсів з пакету android.text.style: CharacterStyle, UpdateAppearance, UpdateLayout, ParagraphStyle і т. д. Набір дефолтних стилів досить широкий — від зміни формату символів (StyleSpan, UnderlineSpan), завдання розміру тексту (RelativeSizeSpan) і зміни його положення (AlignmentSpan) до підтримки зображень (ImageSpan) і кликабельного тексту (ClickableSpan).

Останній параметр задає прапори, роль яких буде розказано трохи нижче. Наприклад, ось так можна поміняти колір тексту:

SpannableStringBuilder ssb = new SpannableStringBuilder(text);
ssb.setSpan(new ForegroundColorSpan(Color.BLUE), 0, text.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
textView.setText(ssb, TextView.BufferType.SPANNABLE);

Отже, на вході є текст в якомусь форматі, а на виході потрібно отримати його подання у вигляді Spannable об'єкта та передати його в EditText. У нашому випадку з сервера документ приходить в особливому форматі у вигляді атрибутированной рядки – необхідно розпарсити цю рядок, використовуючи нашу бібліотеку для OT, і застосувати атрибути до заданих ділянках тексту. Залежно від стилю, потрібно виставити коректний прапор, щоб маркування тексту відповідала очікуванням користувача.

Якщо позначити стиль прапором SPAN_EXCLUSIVE_INCLUSIVE, то він буде застосований до введеного в кінці інтервалу тексту, але не буде застосовуватися на початку. Наприклад, є інтервал [10, 20], для якого виставлений стиль UnderlineSpan + SPAN_EXCLUSIVE_INCLUSIVE. В цьому випадку при введенні тексту в позицію 9, до нього стиль UnderlineSpan застосовуватися не буде, але якщо почати вводити текст у позиції 20, то інтервал, який покриває стиль, розшириться і стане [10, 21]. Природно, це корисно для inline форматування (bold / italic / underline тощо).

При використанні прапора SPAN_EXCLUSIVE_EXCLUSIVE, інтервал стилю обмежується з обох кінців. Це підходить, наприклад, для посилань — якщо почати вставляти текст відразу після посилання, стиль посилання до нього застосовуватися не повинен.

Використовуючи прапори SPAN_EXLUSIVE_INCLUSIVE і SPAN_EXCLUSIVE_EXCLUSIVE можна управляти поведінкою форматування при введенні тексту в залежності від очікувань користувача. Наприклад, якщо ви включили режим форматування Bold, то вводиться текст повинен залишатися жирним. А якщо ви зробили посилання, дописування тексту в кінці не повинно розширювати межі посилання.

Для відображення елементів списку можна скористатися BulletSpan, але він підійде тільки для ненумерованих списків. Якщо ж необхідна нумерація, то можна написати свій клас, що реалізує інтерфейси LeadingMarginSpan і UpdateAppearance, отрисовывая індикатор списку на свій розсуд у методі drawLeadingMargin.

Обробка користувальницьких стилів

Зрозуміло, що редактор повинен давати користувачеві можливість застосовувати форматування, це включає:
  1. Додавання нового стилю до вибраного тексту,
  2. Вставку нового стилю в позиції курсору
  3. Застосування поточного стилю при редагуванні.
У першу чергу потрібно десь розмістити кнопки для підтримуваних редактором стилів. Поміщати їх в тулбарі Activity було не практично до виходу Android Marshmallow. За замовчуванням цей же тулбар використовується для контекстного меню при виділенні тексту, і таким чином вибрати стиль виділеного тексту неможливо. Тому можна помістити їх на панель інструментів внизу екрану. При натисканні на кнопку стилю необхідно визначитися з поточним станом редактора і застосувати стиль до вибраного тексту, або запам'ятати цей стиль як тимчасовий в позиції курсору.

private void onApplyInlineAttributeToSelection(int selectionStart, int selectionEnd, TextAttribute attribute) {
int selectionStart = mEditText.getSelectionStart();
int selectionEnd = mEditText.getSelectionEnd();

if (!mEditText.hasSelection()) {
// if there's no selection, insert/delete empty span for the appropriate attribute,
// but only in case the cursor is present
if (selectionStart == selectionEnd && selectionStart != -1) {
if (mTempAttributes == null || mTempAttributes.getPos() != selectionStart) {
mTempAttributes = new TempAttributes(selectionStart);
}

Set<Object> attributeSpans = getAttributeSpans(selectionStart, selectionEnd, attribute);
if (attributeSpans.size() > 0) {
attribute.nullify();
}

mTempAttributes.addAttribute(attribute);
}
return;
}

if (attribute == null) {
return;
}

boolean changed = applyInlineAttributeToSelection(selectionStart, selectionEnd, attribute);
// if nothing changed, then there's no need to build any changesets and send updates to server
if (!changed) {
return;
}

// ...

}

mTempAttributes — екземпляр класу TempAttributes. Він визначає набір атрибутів в даній позиції, обраних користувачем. Ця змінна обнуляється або після використання, або при зміні позиції курсору.

static class TempAttributes {
private final int mPos;
private final Map<AttributeName, TextAttribute> mAttributeMap = new HashMap<>();

public TempAttributes(int pos) {
mPos = pos;
}

public int getPos() {
return mPos;
}

public Collection<TextAttribute> getAttributes() {
return mAttributeMap.values();
}

public void addAttribute(TextAttribute attribute) {
AttributeName name = attribute.getAttributeName();
TextAttribute oldAttribute = mAttributeMap.get(name);
if (oldAttribute != null && !oldAttribute.isNull()) {
attribute.nullify();
}
mAttributeMap.put(name, attribute);
}
}

Якщо користувач натискає на кнопку, відповідну певного стилю, на тулбарі, але при цьому текст не вибраний, в цьому випадку потрібно зберегти цей стиль як «тимчасовий» в поточній позиції курсору і застосувати його при введенні тексту в цій позиції. Детальніше про це трохи нижче.

Коли текст був обраний, потрібно визначити, чи є вже цей стиль у вибраному інтервалі чи ні. Якщо немає або є частково, то необхідно об'єднати всі існуючі span'и і покрити інтервал цим стилем повністю. Якщо ж є, то видалити відповідні span'и з інтервалу, при необхідності розбивши його.

Приклад 1
Є текст: Quick brown fox.
В ньому 2 span-а: bold [0,4] і bold [12,14]. Якщо користувач виділяє весь текст та застосовує до нього стиль bold, то в підсумку він повинен покривати весь інтервал. Для цього можна або видалити обидва span'а і додати новий bold [0, 14], або видалити другий і продовжити перший до кінця інтервалу.

Приклад 2
Є текст: Quick brown fox.
В ньому один span: bold [0, 14]. Якщо користувач виділяє текст [4, 12] і вибирає стиль bold в тулбарі, то стиль потрібно видалити з інтервалу, так як він повністю присутній у виділенні. Для цього потрібно розбити інтервал на дві частини: вкоротити весь інтервал [0, 14] до початку виділення ([0, 4]) і додати новий інтервал від кінця виділення до кінця тексту[4, 12]).

Відстеження змін в документі

Щоб коректно відслідковувати зміни користувача та «згодовувати» їх алгоритмом OT, редактор повинен вміти їх відслідковувати. Для цього використовується інтерфейс TextWatcher — кожен раз, коли в EditText відбуваються якісь зміни, послідовно викликаються методи beforeTextChanged, onTextChanged і afterTextChanged цього інтерфейсу, дозволяючи визначити, що і де змінилося.

private boolean mIgnoreNextTextChange = false;
private int mCurrentPos;
private String mOldStr = null;
private String mNewStr = null;

// ...

public void ignoreNextTextChange(boolean ignore) {
mIgnoreNextTextChange = ignore;
}

public void beforeTextChanged(CharSequence s, int start, int count, int after){
if (mIgnoreNextTextChange) {
return;
}

mOldStr = null;
mCurrentPos = start;
if (s.length() > 0 && count > 0) {
mOldStr = s.subSequence(start, start + count).toString();
}
}

public void onTextChanged(CharSequence s, int start, int before, int count) {
if (mIgnoreNextTextChange) {
return;
}

mNewStr = null;

if (s.length() > 0 && count > 0) {
mNewStr = s.subSequence(start, start + count).toString();
}
}

public void afterTextChanged(Editable s) {
// ...
}

Важливо врахувати, що при початковій установці тексту редактор через setText(CharSequence), TextWatcher також отримає повідомлення про це, тому програмна установка тексту обертається в:

mEditTextWatcher.ignoreNextTextChange(true);
mEditText.setText(builder);
mEditTextWatcher.ignoreNextTextChange(false);

У змінних mOldStr і mNewStr зберігаються стара рядок і нова рядок відповідно, mCurrentPos вказує на позицію, починаючи з якої відбулися зміни. Наприклад, якщо користувач додав символ «a» у позиції 10,

mOldStr = null;
mNewStr = "a";
mCurrentPos = 10;

Однак є невеликий нюанс — при вставці тексту з-за автокорекції ці значення можуть включати початок слова. Наприклад, якщо текст починається зі слова «Text», і користувач замінює третій символ «s», то IME може рапортувати це зміна як:

mOldStr = "Tex";
mNewStr = "Tes";
mCurrentPos = 0;

В цьому випадку потрібно відрізати однакові послідовності символів від початку рядка.

В кінцевому підсумку, використовуючи TextWatcher, можна однозначно визначити, що конкретно сталося — текст був замінений, видалений або доданий. Якщо користувач вводить текст у позиції або замінює частину наявного тексту на текст з буфера, необхідно застосувати до доданого тексту ті атрибути, які знаходяться в позиції курсору. Для цього потрібно знайти всі Spannable об'єкти в позиції курсору, при цьому не забувши виключити ті, які стали порожніми (s.getSpanStart(span) == s.getSpanEnd(span)), видаливши при цьому самі об'єкти Spannable і відфільтрувавши тільки за inline атрибутів (bold, italic, etc.). Додатково додаються ті атрибути, яким відповідають стилі, вибрані користувачем на панелі інструментів (mTempAttributes).

public void afterTextChanged(Editable s) {

// ...

Object[] spans = s.getSpans(mCurrentPos, mCurrentPos, Object.class);

Map<Object, TextAttribute> spanAttrMap = new LinkedHashMap<>();
for (Object span : spans) {
TextAttribute attr = AttributeManager.attributeForSpan(span);
if (attr != null) {
spanAttrMap.put(span, attr);
}
}

if (!TextUtils.isEmpty(mOldStr)) {
Iterator<Map.Entry<Object, TextAttribute>> iterator = spanAttrMap.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<Object, TextAttribute> entry = iterator.next();
Object span = entry.getKey();
TextAttribute attr = entry.getValue();

// ...

if (s.getSpanStart(span) == s.getSpanEnd(span)) {
s.removeSpan(span);
iterator.remove();
}
}
}

// ...

Set<TextAttribute> attributes = new HashSet<>();
if (!TextUtils.isEmpty(mNewStr)) {
// determine all inline attributes at current position
for (Map.Entry<Object, TextAttribute> entry : spanAttrMap.entrySet()) {
TextAttribute attr = entry.getValue();

if (AttributeManager.isInlineAttribute(attr)) {
attributes.add(attr);
}
}
}

if (mCallbacks != null) {
mCallbacks.onTextChanged(mCurrentPos, mOldStr, mNewStr, attributes);
}
}

В підсумку є позиція, в якій відбулися зміни, відомі старий і новий тексти в цій позиції, а також inline атрибути, які необхідно застосувати до нового тексту. Після цього можна додати додаткову обробку. Наприклад, якщо користувач вставив переклад рядка в кінці останнього елемента списку, можна вставити новий елемент списку в поточній позиції курсору, щоб продовжити список. В кінцевому підсумку за цим даними складається список змін і відправляється на сервер.

Варто зауважити, що при відстеженні змін у редакторі гарною практикою буде використання обгорток для всіх дефолтних стилів. Наприклад, замість UnderlineSpan використовувати клас CustomUnderlineSpan, який успадковується від UnderlineSpan, але при цьому ніякі методи в ньому не перевизначені. Такий підхід дозволить по класу однозначно відокремити «свої» стилі від тих, які застосовує EditText. Наприклад, якщо включена підтримка автозаміни, то при редагуванні слова EditText додає йому стиль UnderlineSpan, і візуально слово підкреслюється на момент редагування.

Про сумісність з різними версіями API

На версіях API до Android KitKat існує проблема з накладенням Spannable тексту при редагуванні. Вона вирішується відключенням апаратного прискорення TextView (можливо, є інші способи це виправити — пропозиції у коментарях гаряче вітаються):

mEditText.setLayerType(View.LAYER_TYPE_SOFTWARE, null);

Однак у такому вигляді TextView не можна помістити в ScrollView, так як вся View буде рендеритись в пам'яті («View too large to fit into drawing cache»), тому потрібно включати прокручування в самому TextView.

mEditText.setVerticalScrollBarEnabled(true);
mEditText.setScrollBarStyle(View.SCROLLBARS_OUTSIDE_OVERLAY);


Висновок

Намучавшись з реалізацією редактора на webview і усвідомивши безвихідність даного підходу, нам вдалося розробити нативний компонент, який вирішує непросту, але досить цікаву задачу спільного редагування тексту. Це дозволило поліпшити юзабіліті програми та підвищити продуктивність наших користувачів. Отриманий результат можна оцінити, завантаживши наш додаток Google Play.



Джерело: Хабрахабр

0 коментарів

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