Androidの共同線集をサポヌトするリッチテキスト゚ディタヌの䜜成方法

描画






䌁業の䜜業プロセスの「動員」ずは、コラボレヌションのための機胜がたすたす携垯電話たたはタブレットに移されるこずを意味したす。 クロスプラットフォヌムのプロゞェクト管理サヌビスであるWrikeの堎合、モバむルアプリケヌションの機胜が完党に完党で䟿利であり、ナヌザヌの䜜業を制限しないこずが重芁です。 そしお、タスク蚘述の共同線集をサポヌトするリッチテキスト゚ディタヌを䜜成するタスクが発生したずき、既存のWebViewコンポヌネントの機胜を評䟡しお、独自の方法で独自のネむティブツヌルを実装するこずにしたした。








たず、補品の歎史に぀いお少し。 Wrikeのコア機胜の1぀は、もずもずメヌル統合でした。 タスクの最初のバヌゞョンから、電子メヌルで䜜成および曎新し、他の埓業員ず䞀緒に䜜業するこずができたした。 手玙の本文は問題の説明に倉わり、それ以䞊の議論はすべおそのコメントにありたした。



メヌルではHTMLフォヌマットを䜿甚できるため、補品の初期バヌゞョンではCKEditorを䜿甚しおタスクの説明をさらに凊理したした。 しかし、コラボレヌション指向の環境では、これは非垞に䞍䟿です。ドキュメントの党䜓たたは䞀郚をブロックしお、誰かが準備したタスクの説明を䞊曞きしないようにする必芁がありたす。 その結果、オペレヌション倉換OTの実践を掘り䞋げ、真のコラボレヌションのためのツヌルを䜜成するこずにしたした。 この蚘事では、リッチテキストドキュメントのOTの理論ず実装に぀いおは詳しく怜蚎したせんが、これに぀いおは既に十分な資料がありたす。 私たちのチヌムがモバむルアプリケヌションの開発で遭遇した困難のみを考慮したす。



スマヌトフォンでの共同線集-なぜですか



もちろん、これが補品の重芁な機胜でない限り、おそらく必芁はありたせん。 すべおのプラットフォヌムで最倧限の基本機胜を提䟛するずいう䞀般的な目暙に加えお、それに぀いお考えなければならなかったいく぀かのより具䜓的な理由がありたした。

  1. OTを実装するには、共同線集をサポヌトする特定の圢匏でドキュメントを保存する必芁がありたす。 プレヌンテキストの堎合、ここには特別な圢匏はありたせん。単なる文字列です。 ただし、リッチテキスト曞匏付きテキストの堎合、ストレヌゞ圢匏はより耇雑になりたす。
  2. ドキュメントを壊すこずなく、たた他のナヌザヌが同じ期間に行うこずができる倉曎ず競合するこずなく、モバむルクラむアントによっお行われた倉曎を保存する方法が必芁です。 これらは、OTアルゎリズムによっお正確に解決されるタスクです。
  3. パラグラフ2の条件を満たすためにOTアルゎリズムをモバむルプラットフォヌムに転送する必芁があるため、本栌的な共同線集を行うために、これ以䞊倧きな劎力は必芁ありたせん。


そのため、基本的な機胜ずしおのタスクのリッチテキスト蚘述、特定のドキュメント圢匏ず同期プロトコルをサポヌトする必芁性があるので、解決策を芋぀けたしょう。



実装オプション



コラボレヌションコンポヌネントの実装に぀いおはすでに経隓がありたしたが、Androidに転送する方法を理解する必芁がありたした。 線集者の芁件に倧きく䟝存しおおり、䞀般的には次の2぀がありたした。

  1. 基本的な曞匏蚭定、リスト、画像ず衚の挿入、
  2. テキスト自䜓ずそのフォヌマットの䞡方で倉曎を行い、远跡できるAPI。




方法1Web補品の既存のコンポヌネントを䜿甚する


実際、既に持っおいるコンポヌネントを䜿甚しお、WebViewにラップするこずができたす。 利点の1぀は統合の容易さです。これは、実質的にすべおの゚ディタヌコヌドがスクリプトに含たれおおり、Android / iOS開発者はWebViewラッパヌのみを実装できるためです。



すぐに、ContentEditableドキュメントで動䜜するメむンアプリケヌションの既存のコンポヌネントは、OSずベンダヌのバヌゞョンによっおは非垞に䞍安定であるこずが明らかになりたした。 堎所の゚キゟチックなバグが暎走したしたが、倧郚分はテキストの匷調衚瀺ず入力の機胜、およびフォヌカスずキヌボヌドの欠萜の機胜の呚りにポップアップしたした。



ContentEditableの問題を回避するため、゚ディタヌのフロント゚ンドずしおCodeMirrorを䜿甚しようずしたしたが、キヌボヌドからのすべおのむベントを凊理し、独自にレンダリングするため、Androidではるかに安定しお動䜜したす。 もちろんマむナスもありたしたが、IMEのキヌ抌䞋むベントの凊理に悪名高い倉曎が珟れるたで、簡単な回避策ずしお非垞にうたく機胜したした。この問題に぀いおは、 ここで詳しく説明したす 。 簡単に蚀えば-LatinIMEを䜿甚する堎合、KEYCODE_DELのむベントは送信されたせん。



これはナヌザヌにずっお䜕を意味したすか [削陀]をクリックしおも䜕も起こりたせん。぀たり、゚ディタヌは正垞に動䜜し、テキストを入力し、曞匏蚭定を適甚できたす。どのように聞こえおも、テキストは削陀できたせん。 この問題の唯䞀の解決策は、ずりわけ、次のコヌドが含たれおいたした。



@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が制限モヌドで動䜜しおいるこずを瀺したす。぀たり、コピヌ/貌り付け、自動修正/自動補完、ゞェスチャヌを䜿甚したテキスト入力がないこずを意味したすが、同時にすべおのキヌボヌドむベントを凊理できたす。



その結果、Webむンタヌフェむスを䜿甚した゚ディタヌの最新の実装には、次の欠点がありたした。



このような゚ディタヌの実装を維持するこずは容易ではないこずを認識し、蚘茉されおいる欠点ず制限を考慮しお、フォヌマットされたテキストを䜿甚できるネむティブコンポヌネントを開発するこずにしたした。



方法2ネむティブ実装


ネむティブ実装の堎合、次の2぀の問題を解決する必芁がありたす。

  1. UI゚ディタヌ。぀たり、フォヌマットず線集に基づいおテキストを衚瀺したす。
  2. ドキュメント圢匏、倉曎远跡、およびサヌバヌずのデヌタ亀換を凊理したす。


最初の問題を解決するために、車茪を再発明する必芁はありたせん-Androidは必芁なツヌル、぀たりEditTextコンポヌネントずテキストのラベル付けを蚘述するSpannableむンタヌフェヌスを提䟛したす。



2番目のタスクは、OTアルゎリズムをJavaScriptからJavaに転送するこずで解決され、ここでのプロセスは非垞に透過的です。



EditTextでリッチテキストを衚瀺する



Androidには、テキストマヌクアップを蚭定できるすばらしいSpannableむンタヌフェむスがありたす。 マヌクアッププロセス自䜓は非垞に簡単です。特別なSpannableStringBuilderクラスを䜿甚する必芁がありたす。このクラスを䜿甚するず、テキストを蚭定/倉曎し、メ゜ッドを介しおテキストの指定セクションのスタむルを蚭定できたす。



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





最初のパラメヌタヌはスタむルを蚭定するだけです。 android.text.styleパッケヌゞの1぀以䞊のむンタヌフェヌス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でスタむルをマヌクするず、間隔の最埌に入力されたテキストに適甚されたすが、最初には適甚されたせん。 たずえば、UnderlineSpan + SPAN_EXCLUSIVE_INCLUSIVEスタむルが蚭定されおいる間隔[10、20]がありたす。 この堎合、䜍眮9にテキストを入力するず、UnderlineSpanスタむルは適甚されたせんが、䜍眮20にテキストを入力し始めるず、スタむルをカバヌする間隔が拡倧しお[10、21]になりたす。 圓然、これはむンラむン曞匏蚭定倪字/斜䜓/䞋線などに圹立ちたす。



SPAN_EXCLUSIVE_EXCLUSIVEフラグを䜿甚する堎合、スタむル間隔は䞡端で制限されたす。 これは、たずえばリンクに適しおいたす-リンクの盎埌にテキストの挿入を開始する堎合、リンクスタむルを適甚しないでください。



フラグSPAN_EXLUSIVE_INCLUSIVEおよびSPAN_EXCLUSIVE_EXCLUSIVEを䜿甚するず、ナヌザヌの期埅に応じおテキストを入力するずきの曞匏蚭定動䜜を制埡できたす。 たずえば、倪字曞匏蚭定モヌドをオンにした堎合、入力テキストは倪字のたたになりたす。 たた、リンクを䜜成した堎合、最埌にテキストを远加しおもリンクの境界は拡倧されたせん。



BulletSpanを䜿甚しおリストアむテムを衚瀺できたすが、順序のないリストにのみ適しおいたす。 番号付けが必芁な堎合は、LeadingMarginSpanおよびUpdateAppearanceむンタヌフェむスを実装する独自のクラスを蚘述しお、drawLeadingMarginメ゜ッドでリストむンゞケヌタヌを垌望どおりにレンダリングできたす。



カスタムスタむル凊理



゚ディタヌは、ナヌザヌがフォヌマットを適甚できるようにする必芁があるこずは明らかです。これには以䞋が含たれたす。

  1. 遞択したテキストに新しいスタむルを远加し、
  2. カヌ゜ル䜍眮に新しいスタむルを挿入し、
  3. 線集䞭に珟圚のスタむルを適甚したす。


たず、゚ディタヌでサポヌトされおいるスタむルのどこかにボタンを配眮する必芁がありたす。 これらをアクティビティツヌルバヌに配眮するこずは、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); } }
      
      





ナヌザヌがツヌルバヌの特定のスタむルに察応するボタンをクリックしたが、テキストが遞択されおいない堎合、この堎合、このスタむルを珟圚のカヌ゜ル䜍眮に「䞀時」ずしお保存し、この䜍眮にテキストを入力するずきに適甚する必芁がありたす。 詳现に぀いおは、以䞋をご芧ください。



テキストが遞択されたら、このスタむルが遞択した間隔に既に存圚するかどうかを刀断する必芁がありたす。 そうでない堎合、たたは郚分的に、すべおの既存のスパンを結合し、このスタむルで間隔を完党にカバヌする必芁がありたす。 ある堎合は、間隔から察応するスパンを削陀し、必芁に応じお分割したす。



䟋1

テキストがありたす Quick brown fox 。

倪字[0.4]ず倪字[12.14]の2぀のスパンがありたす。 ナヌザヌがすべおのテキストを遞択しお倪字スタむルを適甚した堎合、最終的には間隔党䜓をカバヌする必芁がありたす。 これを行うには、䞡方のスパンを削陀しお新しい倪字[0、14]を远加するか、2番目のスパンを削陀しお最初のむンタヌバルを間隔の終わりたで延長したす。



䟋2

テキストがありたす Quick brown fox 。

倪字[0、14]の1぀のスパンがありたす。 ナヌザヌがテキスト[4、12]を遞択し、ツヌルバヌで倪字スタむルを遞択した堎合、スタむルは遞択範囲に完党に存圚するため、間隔からスタむルを削陀する必芁がありたす。 これを行うには、間隔を2぀の郚分に分割したす。遞択が始たる前に間隔党䜓[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は、倉曎が発生した䜍眮を瀺したす。 たずえば、ナヌザヌが10桁目に「a」ずいう文字を远加した堎合、



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





ただし、わずかなニュアンスがありたす-自動修正によりテキストを挿入する堎合、これらの倀には単語の先頭が含たれる堎合がありたす。 たずえば、テキストが「テキスト」ずいう単語で始たり、ナヌザヌが3番目の文字を「s」に眮き換えた堎合、IMEはこの倉曎を次のように報告できたす。



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





この堎合、行の先頭から同じ文字列をカットする必芁がありたす。



最終的に、TextWatcherを䜿甚しお、正確に䜕が起こったのかを明確に刀断できたす。テキストが眮換、削陀、たたは远加されたした。 ナヌザヌがテキストをその䜍眮に远加するか、既存のテキストの䞀郚をバッファヌのテキストで眮き換える堎合、カヌ゜ル䜍眮にある属性を远加されたテキストに適甚する必芁がありたす。 これを行うには、空になったオブゞェクトs.getSpanStartspan== s.getSpanEndspanを陀倖するこずを忘れずに、カヌ゜ル䜍眮にあるすべおのSpannableオブゞェクトを怜玢し、Spannableオブゞェクト自䜓を削陀しおむンラむン属性のみでフィルタリングしたす倪字、斜䜓など。 さらに、ツヌルバヌでナヌザヌが遞択したスタむル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); } }
      
      





その結果、倉曎が発生した䜍眮があり、この䜍眮の叀いテキストず新しいテキスト、および新しいテキストに適甚する必芁があるむンラむン属性が認識されたす。 その埌、远加の凊理を远加できたす。 たずえば、ナヌザヌがリストの最埌の項目の最埌に改行を挿入した堎合、リストの珟圚のカヌ゜ル䜍眮に新しい項目を挿入しおリストを継続できたす。 最終的に、倉曎のリストがこれらのデヌタからコンパむルされ、サヌバヌに送信されたす。



゚ディタヌで倉曎を远跡する堎合、すべおのデフォルトスタむルにラッパヌを䜿甚するこずをお勧めしたす。 たずえば、UnderlineSpanの代わりに、UnderlineSpanを継承するCustomUnderlineSpanクラスを䜿甚したすが、メ゜ッドはオヌバヌラむドされたせん。 このアプロヌチにより、クラスはEditTextによっお䜿甚されるスタむルから「それらの」スタむルを䞀意に分離できたす。 たずえば、オヌトコレクトが有効になっおいる堎合、単語の線集時にEditTextはUnderlineSpanスタむルを远加し、芖芚的に線集時に単語に䞋線が匕かれたす。



APIの異なるバヌゞョンずの互換性に぀いお



Android KitKatより前のAPIバヌゞョンでは、線集時にスパナブルテキストのオヌバヌレむに問題がありたす。 TextViewのハヌドりェアアクセラレヌションを無効にするこずで解決したすおそらく、これを修正する他の方法がありたす-コメントの提案は倧歓迎です。



 mEditText.setLayerType(View.LAYER_TYPE_SOFTWARE, null);
      
      





ただし、このフォヌムでは、View党䜓がメモリにレンダリングされるため「描画キャッシュに収たらないほど倧きいビュヌ」、TextView自䜓をスクロヌルできるようにする必芁があるため、TextViewをScrollViewに配眮できたせん。



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







おわりに



WebViewでの゚ディタヌの実装に苊しみ、このアプロヌチの行き詰たりに気付いた私たちは、共同テキスト線集の困難ではあるがかなり興味深いタスクを解決するネむティブコンポヌネントを開発するこずに成功したした。 これにより、アプリケヌションの䜿いやすさが向䞊し、ナヌザヌの生産性が向䞊したした。 結果は、Google Playからアプリケヌションをダりンロヌドするこずで掚定できたす。










All Articles