最近有個需求:評論@人。網上已經有一些文章分享了相似功能實現邏輯,可是幾乎都是擴展EditText類,這種實現方式確定不能進入個人首發陣容。你覺得是由於它不符合面向對象六大原則?錯,只由於它不夠優雅!不夠優雅!不夠優雅!java
那麼,只有飲水機代碼怎麼辦?固然是android
read the fuking source codegit
功夫不負有心人,我讀了一遍EditText源碼,而後就造出了這個「優雅的」輪子(開玩笑,EditText源碼怎麼能叫fuking source code,他有一個爸爸叫TextView)。廢話很少說,上酸菜。github
在此以前,你須要記住一個跟文本相關的思想:一切皆Spancanvas
全部人都知道文本樣式與Spannable有關。這裏一樣使用Spannable,我定義了一個DataBindingSpan<T>接口,主要有兩個功能:安全
interface DataBindingSpan<T> { fun spannedText(): CharSequence fun bindingData(): T }
示例代碼:微信
class SpannableData(private val spanned: String): DataBindingSpan<String> { override fun spannedText(): CharSequence { return SpannableString(spanned).apply { setSpan(ForegroundColorSpan(Color.RED), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } } override fun bindingData(): String { return spanned } }
這個類僅僅包裝了一個字符串,spannedText()返回一個改變標籤文本顏色爲紅色的字符串,同時 bindingData()將該字符串做爲業務數據返回。app
你也能夠把它換成其餘的,user對象不錯。spannedText()返回username,bindingData()返回userId,你就能夠輕鬆實現@人功能業務數據綁定相關的邏輯了。ide
當咱們把Span綁定到文本上之後,咱們須要在文本發生變化時,保證文本和數據的安全性,可靠性,一致性。ui
其實從DataBindingSpan開始,咱們就在處理這個事情了。正如SpannableData所展示的同樣,當spannedText()返回的是一個Spannable對象時,使用Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
做爲flag。它不能在頭部和尾部擴展Span的範圍,只容許中間插入。同時,當Span覆蓋的文本被刪除時,Span也會被刪除。也就是說,它天生具備必定數據安全可靠的屬性。這會爲咱們省掉不少事情。
固然,Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
並不具有徹底的安全性。畢竟它不能阻止中間插入。這個事情得咱們本身來作。那麼,爲了禁止中間插入,咱們應該怎麼作呢?
這個需求又產生了兩個問題:
對於第一個問題,我在網上看到過一種思路。維護一個Span起始位置管理器SpanRangeManager,而後利用TextWather監聽文本變化,文本的任何變化都會致使SpanRangeManager從新測算Span的位置。
固然,若是我使用這種方式,就不會有這篇博客了。其實Android SDK便有一個優秀的Span管理器,那就是SpannableStringBuilder。同時SDK提供了一個偵聽器SpanWatcher偵聽SpannableStringBuilder中Span的變化。有興趣的同窗能夠去看一看他的源碼。
第二個問題,咱們要保證文本與數據的一致性,禁止光標插入到Span覆蓋的文本中間。有三種作法:
微博、微信的方法都必需要對軟鍵盤刪除鍵、文本變化、光標活動、文本選中狀態以及span變化進行監聽和處理。QQ就簡單多了,後面會講到。
對於光標活動和選中狀態偵聽,若是採用繼承EditText的方式實現標籤文本功能,重寫onSelectionChanged(int selStart, int selEnd)方法便可以偵聽光標活動。可是,這種方式怎麼能算優雅呢?
要想「優雅地」實現怎麼辦?仍是那句話:
read the fuking source code
兩個角色:
若是有一篇文章叫作《Selection如何管理文本光標活動和選中狀態?》,那麼它必定能回答這個問題。這裏不會詳細講述Selection內部實現,你只須要知道兩點:
既然選中狀態的實現是Span,它就是與View無關的,而與Spannable有關。也就是說,咱們能夠不使用EditText自身的API卻可以管理它的光標活動和選中狀態(請注意這幾句話,他是「優雅實現」的基石)。
Selection管理光標活動。那麼,SpanWatcher又是什麼?前面說了,它是SpannableStringBuidler中用於偵聽Span變化的監聽器。有個東西和它很像,TextWatcher。沒錯,他倆有同一個爹NoCopySpan。他倆一個偵聽文本變化,一個偵聽Span變化。下面是SpanWatcher的源碼:
/** * When an object of this type is attached to a Spannable, its methods * will be called to notify it that other markup objects have been * added, changed, or removed. */ public interface SpanWatcher extends NoCopySpan { /** * This method is called to notify you that the specified object * has been attached to the specified range of the text. */ public void onSpanAdded(Spannable text, Object what, int start, int end); /** * This method is called to notify you that the specified object * has been detached from the specified range of the text. */ public void onSpanRemoved(Spannable text, Object what, int start, int end); /** * This method is called to notify you that the specified object * has been relocated from the range <code>ostart…oend</code> * to the new range <code>nstart…nend</code> of the text. */ public void onSpanChanged(Spannable text, Object what, int ostart, int oend, int nstart, int nend); }
咱們已經知道光標是一種Span。也就是說,咱們能夠經過SpanWatcher偵聽光標活動,經過Selection實現當光標移動到Span內部時,讓它從新移動到Span最近的邊緣位置,Span內部永遠沒法插入光標。這樣便可以實現把標籤文本(spanned text)看做一個總體的思路。下面是代碼實現:
package com.iyao import android.text.Selection import android.text.SpanWatcher import android.text.Spannable import kotlin.math.abs import kotlin.reflect.KClass class SelectionSpanWatcher<T: Any>(private val kClass: KClass<T>): SpanWatcher { private var selStart = 0 private var selEnd = 0 override fun onSpanChanged(text: Spannable, what: Any, ostart: Int, oend: Int, nstart: Int, nend: Int) { if (what === Selection.SELECTION_END && selEnd != nstart) { selEnd = nstart text.getSpans(nstart, nend, kClass.java).firstOrNull()?.run { val spanStart = text.getSpanStart(this) val spanEnd = text.getSpanEnd(this) val index = if (abs(selEnd - spanEnd) > abs(selEnd - spanStart)) spanStart else spanEnd Selection.setSelection(text, Selection.getSelectionStart(text), index) } } if (what === Selection.SELECTION_START && selStart != nstart) { selStart = nstart text.getSpans(nstart, nend, kClass.java).firstOrNull()?.run { val spanStart = text.getSpanStart(this) val spanEnd = text.getSpanEnd(this) val index = if (abs(selStart - spanEnd) > abs(selStart - spanStart)) spanStart else spanEnd Selection.setSelection(text, index, Selection.getSelectionEnd(text)) } } } override fun onSpanRemoved(text: Spannable?, what: Any?, start: Int, end: Int) { } override fun onSpanAdded(text: Spannable?, what: Any?, start: Int, end: Int) { } }
如今,咱們只須要在setText()以前把這個Span添加到文本上就能夠了。
如今已經把Span覆蓋的文本做爲一個總體,且沒法插入光標,可是當咱們從Span尾部刪除文本,還是逐字刪除。咱們的要求是刪除Span文本時,可以總體刪除整個Span,這就須要監聽鍵盤刪除鍵。
package com.iyao import android.text.Selection import android.text.Spannable class KeyCodeDeleteHelper private constructor(){ companion object { fun onDelDown(text: Spannable): Boolean { val selectionStart = Selection.getSelectionStart(text) val selectionEnd = Selection.getSelectionEnd(text) text.getSpans(selectionStart, selectionEnd, DataBindingSpan::class.java).firstOrNull { text.getSpanEnd(it) == selectionStart }?.run { return (selectionStart == selectionEnd).also { val spanStart = text.getSpanStart(this) val spanEnd = text.getSpanEnd(this) Selection.setSelection(text, spanStart, spanEnd) } } return false } } }
讓咱們使用它
editText.setOnKeyListener { v, keyCode, event -> if (keyCode == KeyEvent.KEYCODE_DEL && event.action == KeyEvent.ACTION_DOWN) { return@setOnKeyListener KeyCodeDeleteHelper.onDelDown((v as EditText).text) } return@setOnKeyListener false } //取數據 val strings = editText.text.let { it.getSpans(0, it.length, DataBindingSpan::class.java) }.map { it.bindingData() }
如今就能夠實現微博同樣效果了。一切都那麼順利。
然而,當你運行起來會發現,SelectionSpanWatcher徹底沒有效果。輪子都造好了,你告訴我軸承斷了。
而且,當你打印EditText文本上的Span時,你找不到SelectionSpanWatcher。這說明SelectionSpanWatcher在setText()
過程當中被清除掉了。那咱們能不能把它放在setText()以後設置呢?若是你這麼作,你會發現一個新問題。setText()添加的文本沒有效果。彷佛咱們不能經過setText()添加內容,只能使用getText()追加內容。不只如此,咱們必須徹底禁用setText(),由於每一次調用,都會清除掉SelectionSpanWatcher。
這種方式看起來還不錯,可是換一個不熟悉這個特性的人來使用怎麼辦?告訴他不能用setText()方法?或者用內聯方法或繼承的方式爲EditText新增一個方法? 這些均可以,惟一的缺點是,它不是我想要的優雅。我要讓它就像使用普通EditText同樣正常使用setText()方法。
須要思考的問題是,SelectionSpanWatcher在哪裏消失了?我要從新找回這個軸承。
SelectionSpanWatcher在setText()方法中消失了。我須要去閱讀它的源碼。
EditText重寫了getText()
、setText(CharSequence text, BufferType type)
方法。
@Override public Editable getText() { CharSequence text = super.getText(); // This can only happen during construction. if (text == null) { return null; } if (text instanceof Editable) { return (Editable) super.getText(); } super.setText(text, BufferType.EDITABLE); return (Editable) super.getText(); } @Override public void setText(CharSequence text, BufferType type) { super.setText(text, BufferType.EDITABLE); }
從源碼上看,重寫的惟一目的是將BufferType設置爲BufferType.EDITABLE
。
咱們都知道TextView有三種文本模式:
這裏不具體講這三種模式相關的內容。只須要知道EditText的模式是BufferType.EDITABLE。
那麼,BufferType.EDITABLE與「軸承」又有什麼關係呢? 確實有關係。
閱讀上面的源碼片斷時,不知道有沒有人注意到setText(CharSequence)
傳入一個CharSequence對象,TextView#getText()
返回的是CharSequence對象, EditText#getText()
卻返回一個Editable對象。它是在何時,如何完成的轉換呢?它會不會是一個突破口?
從Editable getText()源碼看,它是在super.setText(text, BufferType.EDITABLE)中完成轉換的。
在TextView源碼中,setText(CharSequence text, BufferType type, boolean notifyBefore, int oldlen)
有這樣一個流程分支:
private void setText(CharSequence text, BufferType type, boolean notifyBefore, int oldlen) { if (type == BufferType.EDITABLE || getKeyListener() != null|| needEditableForNotification) { ... Editable t = mEditableFactory.newEditable(text); text = t; ... } ... mBufferType = type; setTextInternal(text); ... }
因而可知,咱們賦值給EditText的CharSequence對象先通過mEditableFactory轉換爲Editable對象,最終被真正賦值給EditText,mEditableFactory的類型正是Editable.Factory,這是一個靜態內部類。咱們看看Editable.Factory的具體實現是什麼。
/** * Factory used by TextView to create new {@link Editable Editables}. You can subclass * it to provide something other than {@link SpannableStringBuilder}. * * @see android.widget.TextView#setEditableFactory(Factory) */ public static class Factory { private static Editable.Factory sInstance = new Editable.Factory(); /** * Returns the standard Editable Factory. */ public static Editable.Factory getInstance() { return sInstance; } /** * Returns a new SpannedStringBuilder from the specified * CharSequence. You can override this to provide * a different kind of Spanned. */ public Editable newEditable(CharSequence source) { return new SpannableStringBuilder(source); } }
很簡單的轉換,它將CharSequence對象轉換爲Editable的子類SpannableStringBuilder的對象。咱們看一看這個構造器。
public SpannableStringBuilder(CharSequence text, int start, int end) { ... mText = ArrayUtils.newUnpaddedCharArray(GrowingArrayUtils.growSize(srclen)); ... if (text instanceof Spanned) { Spanned sp = (Spanned) text; Object[] spans = sp.getSpans(start, end, Object.class); for (int i = 0; i < spans.length; i++) { if (spans[i] instanceof NoCopySpan) { continue; } ... setSpan(false, spans[i], st, en, fl, false); } restoreInvariants(); } }
這就是軸承斷掉的緣由所在。
前面提到SpanWatcher繼承自NoCopySpan,而NoCopySpan是一個標記接口。它的做用就是標記一個Span沒法被拷貝。SpannableStringBuilder在構造的時候,會忽略掉全部NoCopySpan及其子類。所以,SelectionSpanWatcher沒有被賦值給EditText的文本。
既然NoCopySpan不被複制,那咱們等SpannableStringBuilder構造好後從新設置便好了。Editable.Factory的註釋讓我看到了但願。他能夠被重寫,並被從新注入EditText。
android.widget.TextView#setEditableFactory(Factory)
下面是重寫的Editable.Factory,做用是從新把NoCopySpan設置到SpannableStringBuilder上。
package com.iyao import android.text.Editable import android.text.NoCopySpan import android.text.SpannableStringBuilder import android.text.Spanned import android.text.style.BackgroundColorSpan class NoCopySpanEditableFactory(private vararg val spans: NoCopySpan): Editable.Factory() { override fun newEditable(source: CharSequence): Editable { return SpannableStringBuilder.valueOf(source).apply { spans.forEach { setSpan(it, 0, source.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) } } } }
沒錯,算空行一共17行代碼。它就是這個輪子的新軸承。如今咱們從新使用它。經過editText.setEditableFactory()
換上新的軸承,讓輪子跑起來。
editText.setEditableFactory(NoCopySpanEditableFactory(SelectionSpanWatcher(DataBindingSpan::class))) editText.setOnKeyListener { v, keyCode, event -> if (keyCode == KeyEvent.KEYCODE_DEL && event.action == KeyEvent.ACTION_DOWN) { return@setOnKeyListener KeyCodeDeleteHelper.onDelDown((v as EditText).text) } return@setOnKeyListener false }
一個「優雅的」實現誕生了,你能夠像微博同樣在評論中使用@人了。
微博效果.gif
微信的處理方式要簡單一些,他們不由止在Span覆蓋的文本中插入光標,而是當Span覆蓋的文本改變後清除Span以及數據。他們一樣要監聽刪除鍵實現Span總體刪除,只是表現上與微博稍有區別。
微信的三部曲。
首先,定義一個接口用來判斷Span是否失效。
package com.iyao import android.text.Spannable interface RemoveOnDirtySpan { fun isDirty(text: Spannable): Boolean }
其次,讓SpannableData實現此接口。固然,你也可讓RemoveOnDirtySpan繼承DataBindingSpan,儘管我以爲這樣不符合「六大」。
class SpannableData(private val spanned: String): DataBindingSpan<String>, RemoveOnDirtySpan { override fun spannedText(): CharSequence { return SpannableString(spanned).apply { setSpan(ForegroundColorSpan(Color.RED), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } } override fun bindingData(): String { return spanned } override fun isDirty(text: Spannable): Boolean { val spanStart = text.getSpanStart(this) val spanEnd = text.getSpanEnd(this) return spanStart >= 0 && spanEnd >= 0 && text.substring(spanStart, spanEnd) != spanned } }
最後,從新寫一個DirtySpanWatcher用來刪除失效的Span
package com.iyao import android.text.SpanWatcher import android.text.Spannable class DirtySpanWatcher(private val removePredicate: (Any) -> Boolean) : SpanWatcher { override fun onSpanChanged(text: Spannable, what: Any, ostart: Int, oend: Int, nstart: Int, nend: Int) { if (what is RemoveOnDirtySpan && what.isDirty(text)) { val spanStart = text.getSpanStart(what) val spanEnd = text.getSpanEnd(what) text.getSpans(spanStart, spanEnd, Any::class.java).filter { removePredicate.invoke(it) }.forEach { text.removeSpan(it) } } } override fun onSpanRemoved(text: Spannable, what: Any, start: Int, end: Int) { } override fun onSpanAdded(text: Spannable, what: Any, start: Int, end: Int) { } }
如今,咱們讓微信也跑起來。
editText.setEditableFactory(NoCopySpanEditableFactory(DirtySpanWatcher{ it is ForegroundColorSpan || it is RemoveOnDirtySpan })) editText.setOnKeyListener { v, keyCode, event -> if (keyCode == KeyEvent.KEYCODE_DEL && event.action == KeyEvent.ACTION_DOWN) { KeyCodeDeleteHelper.onDelDown((v as EditText).text) } return@setOnKeyListener false }
須要注意,微信和微博有一點小區別,微博有二次確認刪除選中,微信沒有。代碼上的差異僅僅是微信少了一個return@setOnKeyListener
微信效果.gif
QQ的作法太簡單,我不太想講它。這裏寫一個簡單的Demo演示一下。
QQ一樣須要用到DataBindingSpan<T>
,甚至你也能夠不用。它的核心是ImageSpan。
class SpannableData(private val spanned: String): DataBindingSpan<String> { override fun spannedText(): CharSequence { return SpannableString("@$spanned ").apply { setSpan(ImageSpan(LabelDrawable("@$spanned", color = Color.LTGRAY), spanned), 0, length-1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } } override fun bindingData(): String { return spanned } }
如今只須要實現一個繪製文字的Drawable,這裏我取名叫LabelDrawable,也許並不許確。
class LabelDrawable(val text: CharSequence, private val textPaint: TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply { textSize = 42f this.color = Color.DKGRAY textAlign = Paint.Align.CENTER }, color: Int): ColorDrawable(color) { init { calculateBounds() } override fun draw(canvas: Canvas) { super.draw(canvas) canvas.drawText(text, 0, text.length, bounds.centerX().toFloat(), bounds.centerY().toFloat() + getBaselineOffset(textPaint.fontMetrics), textPaint) } private fun calculateBounds() { textPaint.getTextBounds(text.toString(), 0, text.length, bounds) bounds.inset(-8, -4) bounds.offset(8, 0) } private fun getBaselineOffset(fontMetrics: Paint.FontMetrics): Float { return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent } }
就像普通的Span同樣使用他就好了。
QQ效果.gif
若是想要作的更好一點,你須要處理多行文本measure、layout、draw等問題。給個小提示,TextView截屏也是一個Drawable。若是有一個View,即便它並未attach到Window上,咱們也能夠手動調用measure()、layout()、draw()方法獲取一個View的截圖Drawable用來添加到ImageSpan中使用,不過這樣沒法響應觸摸事件。
val strings = editText.text.let { it.getSpans(0, it.length, DataBindingSpan::class.java) }.map { it.bindingData() }
做者:貓爸iYao 連接:https://www.jianshu.com/p/83176fb89aed 來源:簡書 簡書著做權歸做者全部,任何形式的轉載都請聯繫做者得到受權並註明出處。