Developers Club geek daily blog

1 year, 11 months ago
рисунок
"Mobilization" of worker processes in the companies means that more and more functions for collaboration are transferred to phone or the tablet. For Wrike as cross-platform service of project management, it is important that the functionality of mobile application was absolutely full, convenient and did not limit users in work. And when there was a task to create Rich Text Editor with support of joint editing the description of tasks, we, having evaluated possibilities of the existing WebView of components, decided to go in the way and implemented own native tool.




For a start it is a little about product stories. Integration with mail was one of the basic Wrike functions initially. From the very first version of a task it was possible to create and update through e-mail, and then to work on them together with other employees. The body of the letter turned into the description of a task, and all further discussion went in comments to it.

As in mail it is possible to use HTML formatting, in early versions of a product we used CKEditor for further work with the description of a task. But in the environment oriented to collaboration it is very inconvenient – it is necessary to block all document or its part that the prepared description of a task someone did not jam another. As a result we decided to go deep into practice of Operation Transformation (OT) and to make the tool for this collaboration. In this article I will not consider in detail the theory and implementation of OT for rich text of documents, about it there are already enough materials. I will consider only difficulties which our command when developing mobile application faced.

Joint editing on the smartphone — but what for?


Perhaps there is also no need if, of course, it is not key function of your product. In addition to a common goal to provide a maximum of basic functionality on all platforms, there was a number of more specific reasons for which we had to think of it:
  1. Implementation of OT demands to store the document in a certain format, supporting joint editing. In case of a plain text of a special format is not present here — it can be just a line. But in a case with Rich Text (the text with formatting), the format of storage becomes more difficult.
  2. We need a method to save the changes made by the mobile client without having broken the document and without having created the conflict with changes which other users could enter to the same period. These are problems which just and are solved by algorithms of OT.
  3. Time we need to transfer algorithm of OT to a mobile platform to satisfy conditions from point 2, does not demand to make full joint editing considerable additional efforts any more.

So, we have rich text the description of a task as basic functionality, need to support the specific document format and the protocol of synchronization therefore we will undertake search of a solution.

Implementation options


With implementation of a component for collaboration experience already was, and here how to transfer it to Android, it was necessary to understand. A lot of things depended on requirements to the editor and them, by and large, there were two:
  1. Support of basic formatting, lists, insert of pictures and tables,
  2. API allowing to enter and monitor changes both in the text, and in its formatting.


Method 1: to use the existing component from product Web

Really, we could use a component which we already have and to envelop it in WebView. From pluses — simplicity of integration as actually all code of the editor is in scripts, and Android/iOS the developer needs only to implement WebView wrapper.

It became quickly enough clear that the existing component from the main application working with ContentEditable the document functions very unstably depending on the version of OS and from vendor. Exoticism of bugs in places read off scale, but generally they emerged around functions of selection and text entering, and also the vanishing focus and the keyboard.

To bypass ContentEditable problems, we tried to use CodeMirror as a frontend for the editor, at the same time it much better and more steadily works at Android as processes all events from the keyboard and drawing independently. Were, of course, and minuses but as fast workaround it worked not bad until well-known change in event handling of clicking of keys in IME appeared — quite in detail this problem is discussed here. If briefly — when using LatinIME, it does not send an event for KEYCODE_DEL.

What does it mean to the user? When clicking Delete nothing occurs, that is the editor works correctly, it is possible to enter the text, to apply formatting … here only the text cannot be deleted, as if it is absurd sounded. The only option of a solution of this problem in addition included the following code:

@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 at the same time transferred IME to the "simplified" type, signaling that InputConnection works in the limited mode that means lack of copy/paste, autocorrect/autocomplete, and also text entering by means of gestures, but at the same time it allows to process all events of the keyboard.

As a result, in the last implementation of the editor who used the web interface there were following shortcomings:
  • slow speed of loading;
  • lack of access to enhanced features of IME (copy/paste, autocomplete/autocorrect, gesture input);
  • in certain cases unstable work, in connection with different implementation of WebView on different API versions and modification of this component by some vendors;
  • WebView long is normal does not keep in memory, especially on devices with a small memory size, and if to contract the application and after a while to start again, then in most cases WebView should be initialized again;
  • numerous crutches in a code which number only increased over time.

Having realized that to support similar implementation of the editor not easy, and considering the described shortcomings and restrictions, it was decided to develop a native component which would give the chance to work with the formatted text.

Method 2: native implementation

For native implementation it is necessary to solve two problems:
  1. The UI editor, that is display of the text taking into account formatting and its editing.
  2. Work with the document format, change tracking, and also data exchange with the server.

To solve the first problem, it is not necessary to invent a wheel — Android provides necessary tools, namely the EditText component and the Spannable interface describing marking of the text.

The second problem is solved by transfer of algorithms of OT from JavaScript on Java, and process is rather transparent here.

The Rich Text display in EditText


In Android there is a remarkable Spannable interface which allows to set a text marking. Process of forming of a marking is quite simple — it is necessary to use the special class SpannableStringBuilder which allows both to set/change the text, and to set styles for the set text sections through a method

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

The first parameter just sets style. It has to be a copy of a class which implements one or several interfaces from android.text.style packet: CharacterStyle, UpdateAppearance, UpdateLayout, ParagraphStyle, etc. The set of default styles is quite wide — from change of a format of characters (StyleSpan, UnderlineSpan), tasks of the size of the text (RelativeSizeSpan) and change of its provision (AlignmentSpan) before support of images (ImageSpan) and the clickable text (ClickableSpan).

The last parameter sets flags about which role will be told slightly below. For example, here it is so possible to change color of all text:

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

So, on an input there is a text in somebody a format, and on an output it is necessary to gain its impression in the form of Spannable of object and to transfer him in EditText. In our case the document comes from the server in a special format in the form of the attributed line – it is necessary to rasparsit this line, using our library for OT and to apply attributes to the set text sections. Depending on style, it is necessary to expose a correct flag that marking of the text met expectations of the user.

If to mark style with SPAN_EXCLUSIVE_INCLUSIVE flag, then it will be applied to the text entered at the end of an interval, but will not be applied at the beginning. For example, there is an interval [10, 20] for which style UnderlineSpan + SPAN_EXCLUSIVE_INCLUSIVE is exposed. In this case on the text entering in a position 9, UnderlineSpan style will not be applied to it, but if to begin to enter the text into positions 20, then the interval which covers style will extend and will become [10, 21]. Naturally, it is useful for formatting inline (bold/italic/underline, etc.).

When using a flag of SPAN_EXCLUSIVE_EXCLUSIVE, the interval of style is limited since both ends. It is suitable, for example, for links — if to begin to insert the text right after the link, then style of the link should not be applied to it.

Using flags of SPAN_EXLUSIVE_INCLUSIVE and SPAN_EXCLUSIVE_EXCLUSIVE it is possible to manage behavior of formatting on the text entering depending on expectations of the user. For example, if you included the mode of formatting Bold, then the entered text has to remain greasy. And if you made a reference, then the text dopisyvaniye should not expand limits of the link at the end.

For display of elements of the list it is possible to use BulletSpan, but it will be suitable only for not enumerated lists. If numbering is necessary, then it is possible to write the class implementing LeadingMarginSpan and UpdateAppearance interfaces, drawing the list indicator on the discretion in the drawLeadingMargin method.

Processing of the user styles


It is clear, that the editor has to give to the user the chance to apply formatting, it includes:
  1. Adding of new style to the selected text,
  2. Insert of new style in cursor position,
  3. Use of the current style when editing.

First of all it is necessary to place somewhere buttons for the styles maintained by the editor. To place them in a toolbar of Activity it was not practical to Android Marshmallow output. By default the same toolbar is used for a context menu at selection of the text, and thus it is impossible to select style for the selected text. Therefore it is possible to place them on a toolbar in the bottom of the screen. When clicking the button of style it is necessary to decide on a current status of the editor and or to apply style to the selected text, or to remember this style as temporary in cursor position.

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 — a class TempAttributes copy. It defines a set of the attributes in this position selected by the user. This variable is nullified either after use, or when changing cursor position.

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

If the user presses the button corresponding to some style on a toolbar, but at the same time no text is selected, in this case it is necessary to save this style as "temporary" in current position of the cursor and to apply it on the text entering in this position. In more detail about it is slightly lower.

When the text was selected, it is necessary to define whether there is already this style in the selected interval or not. If is not present or is partially, then it is necessary to integrate all existing span'y and to cover an interval with this style completely. If is, then to delete the corresponding span'y from an interval, if necessary having broken it.

Example 1
There is a text: Quick brown fox.
In it 2 span-and: bold [0,4] and bold [12,14]. If the user selects all text and applies to it bold style, then as a result it has to cover all interval. For this purpose it is possible or to delete both span'a and to add new bold [0, 14], or to delete the second and to prolong the first until the end of an interval.

Example 2
There is a text: Quick brown fox.
In it one span: bold [0, 14]. If the user selects the text [4, 12] and selects bold style in a toolbar, then style needs to be deleted from an interval as it completely is present at selection. For this purpose it is necessary to break an interval into two parts: to truncate all interval [0, 14] prior to selection ([0, 4]) and to add a new interval from the end of selection until the end of the text ([4, 12]).

Change tracking in the document


Correctly to monitor changes of the user and "to feed" to their algorithm OT, the editor has to be able to monitor them. The TextWatcher interface — every time when in EditText there are some changes is for this purpose used, the beforeTextChanged, onTextChanged and afterTextChanged methods of this interface are consistently called, allowing to define as where changed.

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) {
  // ...
}

It is important to consider that at initial installation of the text in the editor through setText(CharSequence), TextWatcher will also receive the notification on it therefore program installation of the text turns in:

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

The old line and new line respectively are stored in the mOldStr and mNewStr variables, mCurrentPos indicates a position since which there were changes. For example, if the user added the character of "a" to positions 10, then

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

However there is a small nuance — at text insertion because of self-correcting these values can include the beginning of the word. For example, if the text begins with the word "Text", and the user replaces the third character with "s", then IME can report this change as:

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

In this case it is necessary to cut off identical strings from the beginning of a line.

Finally, using TextWatcher, it is possible to define unambiguously what specifically occurred — the text was replaced, deleted or added. If the user adds the text to positions or replaces part of the available text with the text from the buffer, it is necessary to apply those attributes which are in cursor position to the added text. For this purpose it is necessary to find all Spannable objects in cursor position, at the same time without having forgotten to exclude those which became empty (s.getSpanStart(span) == s.getSpanEnd(span)), having deleted at the same time objects of Spannable and having filtered only on inline to attributes (bold, italic, etc.). Those attributes to which there correspond the styles selected by the user on a toolbar (mTempAttributes) are in addition added.

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

As a result there is a position in which there were changes, old and new texts in this position, and also inline attributes which need to be applied to the new text are known. After that it is possible to add additional processing. For example, if the user inserted line feed at the end of the last element of the list, it is possible to insert a new element of the list in current position of the cursor to continue the list. Finally according to these data the list of changes is formed and goes to the server.

It is worth noticing that at change tracking in the editor use of wrappers for all default styles will be good practice. For example, instead of UnderlineSpan to use the class CustomUnderlineSpan which is inherited from UnderlineSpan, but at the same time no methods in it are redefined. Such approach will allow to separate unambiguously on a class "the" styles from those which are applied by EditText. For example, if automatic replacement support is included, then when editing the word EditText adds to it UnderlineSpan style, and visually the word is underlined at the time of editing.

About compatibility with different API versions


On API versions to Android KitKat there is a problem with imposing of Spannable of the text when editing. It decides (perhaps, there are other methods it to correct shutdown of hardware acceleration of TextView — sentences in comments are hotly welcomed):

mEditText.setLayerType(View.LAYER_TYPE_SOFTWARE, null);

However in such type of TextView it is impossible to place in ScrollView as all View will be rendered in memory ("View too large to fit into drawing cache") therefore it is necessary to include scrolling in TextView.

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


Conclusion


Namuchavshis with implementation of the editor on webview and having realized deadlockness of this approach, we managed to develop a native component which solves difficult, but quite interesting challenge of joint text editing. It allowed to improve a usability applications and to increase productivity of our users. The turned-out result can be evaluated, having downloaded our application from Google Play.

As we made Rich Text Editor with support of joint editing under Android

This article is a translation of the original post at habrahabr.ru/post/273797/
If you have any questions regarding the material covered in the article above, please, contact the original author of the post.
If you have any complaints about this article or you want this article to be deleted, please, drop an email here: sysmagazine.com@gmail.com.

We believe that the knowledge, which is available at the most popular Russian IT blog habrahabr.ru, should be accessed by everyone, even though it is poorly translated.
Shared knowledge makes the world better.
Best wishes.

comments powered by Disqus