Android 如何優(yōu)雅地實(shí)現(xiàn)@人功能诅福?——仿微博匾委、仿QQ、仿微信氓润、零入侵赂乐、高擴(kuò)展性

最近有個(gè)需求:評(píng)論@人。網(wǎng)上已經(jīng)有一些文章分享了類(lèi)似功能實(shí)現(xiàn)邏輯咖气,但是幾乎都是擴(kuò)展EditText類(lèi)挨措,這種實(shí)現(xiàn)方式肯定不能進(jìn)入我的首發(fā)陣容。你以為是因?yàn)樗环厦嫦驅(qū)ο罅笤瓌t崩溪?錯(cuò)浅役,只因?yàn)樗?strong>不夠優(yōu)雅!不夠優(yōu)雅伶唯!不夠優(yōu)雅觉既!

那么,只有飲水機(jī)代碼怎么辦乳幸?當(dāng)然是

read the fuking source code

功夫不負(fù)有心人瞪讼,我讀了一遍EditText源碼,然后就造出了這個(gè)“優(yōu)雅的”輪子(開(kāi)玩笑粹断,EditText源碼怎么能叫fuking source code符欠,他有一個(gè)爸爸叫TextView)。廢話不多說(shuō)瓶埋,上酸菜希柿。

在此之前,你需要記住一個(gè)跟文本相關(guān)的思想:一切皆Span

一悬赏、添加標(biāo)簽文本樣式狡汉,并與標(biāo)簽的業(yè)務(wù)數(shù)據(jù)綁定

所有人都知道文本樣式與Spannable有關(guān)。這里同樣使用Spannable闽颇,我定義了一個(gè)DataBindingSpan<T>接口盾戴,主要有兩個(gè)功能:

  1. 讓用戶提供一個(gè)CharSequence對(duì)象作為標(biāo)簽,它決定了標(biāo)簽文本的樣式和內(nèi)容
  2. 提供一個(gè)方法返回DataBindingSpan<T>對(duì)象所綁定的業(yè)務(wù)數(shù)據(jù)兵多。
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
    }
}

這個(gè)類(lèi)僅僅包裝了一個(gè)字符串尖啡,spannedText()返回一個(gè)改變標(biāo)簽文本顏色為紅色的字符串,同時(shí) bindingData()將該字符串作為業(yè)務(wù)數(shù)據(jù)返回剩膘。

你也可以把它換成其他的衅斩,user對(duì)象不錯(cuò)。spannedText()返回username怠褐,bindingData()返回userId畏梆,你就可以輕松實(shí)現(xiàn)@人功能業(yè)務(wù)數(shù)據(jù)綁定相關(guān)的邏輯了。

二、保證文本上綁定的數(shù)據(jù)的安全可靠

當(dāng)我們把Span綁定到文本上以后奠涌,我們需要在文本發(fā)生變化時(shí)宪巨,保證文本和數(shù)據(jù)的安全性,可靠性溜畅,一致性捏卓。

其實(shí)從DataBindingSpan開(kāi)始,我們就在處理這個(gè)事情了慈格。正如SpannableData所展現(xiàn)的一樣怠晴,當(dāng)spannedText()返回的是一個(gè)Spannable對(duì)象時(shí),使用Spanned.SPAN_EXCLUSIVE_EXCLUSIVE作為flag浴捆。它不能在頭部和尾部擴(kuò)展Span的范圍蒜田,只允許中間插入。同時(shí)汤功,當(dāng)Span覆蓋的文本被刪除時(shí)物邑,Span也會(huì)被刪除溜哮。也就是說(shuō)滔金,它天生具有一定數(shù)據(jù)安全可靠的屬性。這會(huì)為我們省掉很多事情茂嗓。

當(dāng)然餐茵,Spanned.SPAN_EXCLUSIVE_EXCLUSIVE并不具備完全的安全性。畢竟它不能阻止中間插入述吸。這個(gè)事情得我們自己來(lái)做忿族。那么,為了禁止中間插入蝌矛,我們應(yīng)該怎么做呢道批?

這個(gè)需求又產(chǎn)生了兩個(gè)問(wèn)題:

  1. 當(dāng)普通文本發(fā)生變化后,如何監(jiān)控一個(gè)Span起始位置發(fā)生變化入撒?
  2. 如何禁止Span內(nèi)部插入光標(biāo)隆豹?

對(duì)于第一個(gè)問(wèn)題,我在網(wǎng)上看到過(guò)一種思路茅逮。維護(hù)一個(gè)Span起始位置管理器SpanRangeManager璃赡,然后利用TextWather監(jiān)聽(tīng)文本變化,文本的任何變化都會(huì)導(dǎo)致SpanRangeManager重新測(cè)算Span的位置献雅。

當(dāng)然碉考,如果我使用這種方式,就不會(huì)有這篇博客了挺身。其實(shí)Android SDK便有一個(gè)優(yōu)秀的Span管理器侯谁,那就是SpannableStringBuilder。同時(shí)SDK提供了一個(gè)偵聽(tīng)器SpanWatcher偵聽(tīng)SpannableStringBuilder中Span的變化。有興趣的同學(xué)可以去看一看他的源碼墙贱。

第二個(gè)問(wèn)題技扼,我們要保證文本與數(shù)據(jù)的一致性,禁止光標(biāo)插入到Span覆蓋的文本中間嫩痰。有三種做法:

  1. 普通文本剿吻,當(dāng)標(biāo)簽文本被破壞(刪除、插入串纺、追加文本)時(shí)丽旅,讓綁定的數(shù)據(jù)失效,這就是微信的做法纺棺。
  2. 普通文本榄笙,把標(biāo)簽文本作為一個(gè)整體,不能對(duì)標(biāo)簽內(nèi)部插入光標(biāo)祷蝌,杜絕數(shù)據(jù)被破壞的情況茅撞,這是微博的做法。
  3. 占位符巨朦,使用不可分割的Span(如ImageSpan)替換米丘,這是QQ的做法。

微博糊啡、微信的方法都必須要對(duì)軟鍵盤(pán)刪除鍵拄查、文本變化、光標(biāo)活動(dòng)棚蓄、文本選中狀態(tài)以及span變化進(jìn)行監(jiān)聽(tīng)和處理堕扶。QQ就簡(jiǎn)單多了,后面會(huì)講到梭依。

微博的做法

1. 偵聽(tīng)并處理光標(biāo)活動(dòng)稍算、選中狀態(tài)以及Span位置變化

對(duì)于光標(biāo)活動(dòng)和選中狀態(tài)偵聽(tīng),如果采用繼承EditText的方式實(shí)現(xiàn)標(biāo)簽文本功能役拴,重寫(xiě)onSelectionChanged(int selStart, int selEnd)方法便能夠偵聽(tīng)光標(biāo)活動(dòng)糊探。但是,這種方式怎么能算優(yōu)雅呢扎狱?

要想“優(yōu)雅地”實(shí)現(xiàn)怎么辦侧到?還是那句話:

read the fuking source code

兩個(gè)角色:

  1. Selection
  2. SpanWatcher

如果有一篇文章叫做《Selection如何管理文本光標(biāo)活動(dòng)和選中狀態(tài)?》淤击,那么它一定能回答這個(gè)問(wèn)題匠抗。這里不會(huì)詳細(xì)講述Selection內(nèi)部實(shí)現(xiàn),你只需要知道兩點(diǎn):

  1. 選中狀態(tài)具有起點(diǎn)(start)和終點(diǎn)(end)污抬,而start與end反映在文本中汞贸,其實(shí)是兩個(gè)NoCopySpan: START绳军, END。
  2. 光標(biāo)是一種特殊的選中狀態(tài)矢腻,start與end在同一位置门驾;

既然選中狀態(tài)的實(shí)現(xiàn)是Span,它就是與View無(wú)關(guān)的多柑,而與Spannable有關(guān)奶是。也就是說(shuō),我們可以不使用EditText自身的API卻能夠管理它的光標(biāo)活動(dòng)和選中狀態(tài)(請(qǐng)注意這幾句話竣灌,他是“優(yōu)雅實(shí)現(xiàn)”的基石)聂沙。

Selection管理光標(biāo)活動(dòng)。那么初嘹,SpanWatcher又是什么及汉?前面說(shuō)了,它是SpannableStringBuidler中用于偵聽(tīng)Span變化的監(jiān)聽(tīng)器屯烦。有個(gè)東西和它很像坷随,TextWatcher。沒(méi)錯(cuò)驻龟,他倆有同一個(gè)爹NoCopySpan温眉。他倆一個(gè)偵聽(tīng)文本變化,一個(gè)偵聽(tīng)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);
}

我們已經(jīng)知道光標(biāo)是一種Span芍殖。也就是說(shuō),我們可以通過(guò)SpanWatcher偵聽(tīng)光標(biāo)活動(dòng)谴蔑,通過(guò)Selection實(shí)現(xiàn)當(dāng)光標(biāo)移動(dòng)到Span內(nèi)部時(shí),讓它重新移動(dòng)到Span最近的邊緣位置龟梦,Span內(nèi)部永遠(yuǎn)無(wú)法插入光標(biāo)隐锭。這樣便能夠?qū)崿F(xiàn)把標(biāo)簽文本(spanned text)看作一個(gè)整體的思路。下面是代碼實(shí)現(xiàn):

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

現(xiàn)在计贰,我們只需要在setText()之前把這個(gè)Span添加到文本上就可以了钦睡。

2. 偵聽(tīng)軟鍵盤(pán)刪除鍵并處理選中狀態(tài)

現(xiàn)在已經(jīng)把Span覆蓋的文本作為一個(gè)整體,且無(wú)法插入光標(biāo)躁倒,但是當(dāng)我們從Span尾部刪除文本荞怒,仍是逐字刪除。我們的要求是刪除Span文本時(shí)秧秉,能夠整體刪除整個(gè)Span褐桌,這就需要監(jiān)聽(tīng)鍵盤(pán)刪除鍵。

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
}

//取數(shù)據(jù)
val strings = editText.text.let {
    it.getSpans(0, it.length, DataBindingSpan::class.java)
}.map { it.bindingData() }

現(xiàn)在就可以實(shí)現(xiàn)微博一樣效果了象迎。一切都那么順利荧嵌。

然而呛踊,當(dāng)你運(yùn)行起來(lái)會(huì)發(fā)現(xiàn),SelectionSpanWatcher完全沒(méi)有效果啦撮。輪子都造好了谭网,你告訴我軸承斷了。

并且赃春,當(dāng)你打印EditText文本上的Span時(shí)愉择,你找不到SelectionSpanWatcher。這說(shuō)明SelectionSpanWatcher在setText()過(guò)程中被清除掉了织中。那我們能不能把它放在setText()之后設(shè)置呢薄辅?如果你這么做,你會(huì)發(fā)現(xiàn)一個(gè)新問(wèn)題抠璃。setText()添加的文本沒(méi)有效果站楚。似乎我們不能通過(guò)setText()添加內(nèi)容,只能使用getText()追加內(nèi)容搏嗡。不僅如此窿春,我們必須完全禁用setText(),因?yàn)槊恳淮握{(diào)用采盒,都會(huì)清除掉SelectionSpanWatcher旧乞。

這種方式看起來(lái)還不錯(cuò),但是換一個(gè)不熟悉這個(gè)特性的人來(lái)使用怎么辦磅氨?告訴他不能用setText()方法尺栖?或者用內(nèi)聯(lián)方法或繼承的方式為EditText新增一個(gè)方法? 這些都可以烦租,唯一的缺點(diǎn)是延赌,它不是我想要的優(yōu)雅。我要讓它就像使用普通EditText一樣正常使用setText()方法叉橱。

需要思考的問(wèn)題是挫以,SelectionSpanWatcher在哪里消失了?我要重新找回這個(gè)軸承窃祝。

3. 讓輪子優(yōu)雅實(shí)現(xiàn)的軸承:Editable.Factory

SelectionSpanWatcher在setText()方法中消失了掐松。我需要去閱讀它的源碼。

EditText重寫(xiě)了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);
}

從源碼上看,重寫(xiě)的唯一目的是將BufferType設(shè)置為BufferType.EDITABLE探膊。

我們都知道TextView有三種文本模式:

  1. BufferType.NORMAL 靜態(tài)文本模式杠愧,這種模式的文本無(wú)法編輯,也沒(méi)有富文本樣式突想。
  2. BufferType.SPANNABLE 帶文本樣式的模式殴蹄,不可編輯究抓。當(dāng)TextView.isTextSelectable()返回true時(shí),TextView的文本模式袭灯。
  3. BufferType.EDITABLE EditText的文本模式刺下,可編輯,帶文本樣式稽荧。

這里不具體講這三種模式相關(guān)的內(nèi)容橘茉。只需要知道EditText的模式是BufferType.EDITABLE。

那么姨丈,BufferType.EDITABLE與“軸承”又有什么關(guān)系呢畅卓? 確實(shí)有關(guān)系。

閱讀上面的源碼片段時(shí)蟋恬,不知道有沒(méi)有人注意到setText(CharSequence)傳入一個(gè)CharSequence對(duì)象翁潘,TextView#getText()返回的是CharSequence對(duì)象, EditText#getText()卻返回一個(gè)Editable對(duì)象歼争。它是在什么時(shí)候拜马,如何完成的轉(zhuǎn)換呢?它會(huì)不會(huì)是一個(gè)突破口沐绒?

從Editable getText()源碼看俩莽,它是在super.setText(text, BufferType.EDITABLE)中完成轉(zhuǎn)換的。

在TextView源碼中乔遮,setText(CharSequence text, BufferType type, boolean notifyBefore, int oldlen)有這樣一個(gè)流程分支:

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

由此可見(jiàn)扮超,我們賦值給EditText的CharSequence對(duì)象先經(jīng)過(guò)mEditableFactory轉(zhuǎn)換為Editable對(duì)象,最終被真正賦值給EditText蹋肮,mEditableFactory的類(lèi)型正是Editable.Factory出刷,這是一個(gè)靜態(tài)內(nèi)部類(lèi)。我們看看Editable.Factory的具體實(shí)現(xiàn)是什么括尸。

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

很簡(jiǎn)單的轉(zhuǎn)換巷蚪,它將CharSequence對(duì)象轉(zhuǎn)換為Editable的子類(lèi)SpannableStringBuilder的對(duì)象。我們看一看這個(gè)構(gòu)造器濒翻。

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是一個(gè)標(biāo)記接口有送。它的作用就是標(biāo)記一個(gè)Span無(wú)法被拷貝。SpannableStringBuilder在構(gòu)造的時(shí)候僧家,會(huì)忽略掉所有NoCopySpan及其子類(lèi)雀摘。因此,SelectionSpanWatcher沒(méi)有被賦值給EditText的文本八拱。

既然NoCopySpan不被復(fù)制阵赠,那我們等SpannableStringBuilder構(gòu)造好后重新設(shè)置便好了涯塔。Editable.Factory的注釋讓我看到了希望。他可以被重寫(xiě)清蚀,并被重新注入EditText匕荸。

android.widget.TextView#setEditableFactory(Factory)

下面是重寫(xiě)的Editable.Factory,作用是重新把NoCopySpan設(shè)置到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)
            }
        }
    }
}

沒(méi)錯(cuò)榛搔,算空行一共17行代碼。它就是這個(gè)輪子的新軸承《В現(xiàn)在我們重新使用它践惑。通過(guò)editText.setEditableFactory()換上新的軸承,讓輪子跑起來(lái)嘶卧。

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
}

一個(gè)“優(yōu)雅的”實(shí)現(xiàn)誕生了尔觉,你可以像微博一樣在評(píng)論中使用@人了。

微博效果.gif

微信的做法

微信的處理方式要簡(jiǎn)單一些芥吟,他們不禁止在Span覆蓋的文本中插入光標(biāo)侦铜,而是當(dāng)Span覆蓋的文本改變后清除Span以及數(shù)據(jù)。他們同樣要監(jiān)聽(tīng)刪除鍵實(shí)現(xiàn)Span整體刪除运沦,只是表現(xiàn)上與微博稍有區(qū)別泵额。

微信的三部曲。

首先携添,定義一個(gè)接口用來(lái)判斷Span是否失效嫁盲。

package com.iyao

import android.text.Spannable

interface RemoveOnDirtySpan {
    fun isDirty(text: Spannable): Boolean
}

其次,讓SpannableData實(shí)現(xiàn)此接口烈掠。當(dāng)然羞秤,你也可以讓RemoveOnDirtySpan繼承DataBindingSpan,盡管我覺(jué)得這樣不符合“六大”左敌。

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

最后瘾蛋,重新寫(xiě)一個(gè)DirtySpanWatcher用來(lái)刪除失效的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) {
    }


}

現(xiàn)在,我們讓微信也跑起來(lái)矫限。

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
}

需要注意哺哼,微信和微博有一點(diǎn)小區(qū)別,微博有二次確認(rèn)刪除選中叼风,微信沒(méi)有取董。代碼上的差別僅僅是微信少了一個(gè)return@setOnKeyListener

微信效果.gif

QQ的做法

QQ的做法太簡(jiǎn)單,我不太想講它无宿。這里寫(xiě)一個(gè)簡(jiǎn)單的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
    }
}

現(xiàn)在只需要實(shí)現(xiàn)一個(gè)繪制文字的Drawable栏豺,這里我取名叫LabelDrawable,也許并不準(zhǔn)確豆胸。

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

如果想要做的更好一點(diǎn),你需要處理多行文本measure配乱、layout溉卓、draw等問(wèn)題。給個(gè)小提示搬泥,TextView截屏也是一個(gè)Drawable桑寨。如果有一個(gè)View,即使它并未attach到Window上忿檩,我們也可以手動(dòng)調(diào)用measure()尉尾、layout()、draw()方法獲取一個(gè)View的截圖Drawable用來(lái)添加到ImageSpan中使用燥透,不過(guò)這樣無(wú)法響應(yīng)觸摸事件沙咏。

三、獲取文本中綁定的數(shù)據(jù)

val strings = editText.text.let {
    it.getSpans(0, it.length, DataBindingSpan::class.java)
}.map { it.bindingData() }

github

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末班套,一起剝皮案震驚了整個(gè)濱河市肢藐,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌吱韭,老刑警劉巖吆豹,帶你破解...
    沈念sama閱讀 211,123評(píng)論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異理盆,居然都是意外死亡痘煤,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,031評(píng)論 2 384
  • 文/潘曉璐 我一進(jìn)店門(mén)猿规,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)衷快,“玉大人,你說(shuō)我怎么就攤上這事姨俩≌喊危” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 156,723評(píng)論 0 345
  • 文/不壞的土叔 我叫張陵环葵,是天一觀的道長(zhǎng)都伪。 經(jīng)常有香客問(wèn)我,道長(zhǎng)积担,這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 56,357評(píng)論 1 283
  • 正文 為了忘掉前任猬仁,我火速辦了婚禮帝璧,結(jié)果婚禮上先誉,老公的妹妹穿的比我還像新娘。我一直安慰自己的烁,他們只是感情好褐耳,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,412評(píng)論 5 384
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著渴庆,像睡著了一般铃芦。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上襟雷,一...
    開(kāi)封第一講書(shū)人閱讀 49,760評(píng)論 1 289
  • 那天刃滓,我揣著相機(jī)與錄音,去河邊找鬼耸弄。 笑死咧虎,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的计呈。 我是一名探鬼主播砰诵,決...
    沈念sama閱讀 38,904評(píng)論 3 405
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼捌显!你這毒婦竟也來(lái)了茁彭?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書(shū)人閱讀 37,672評(píng)論 0 266
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤扶歪,失蹤者是張志新(化名)和其女友劉穎理肺,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體击罪,經(jīng)...
    沈念sama閱讀 44,118評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡哲嘲,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,456評(píng)論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了媳禁。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片眠副。...
    茶點(diǎn)故事閱讀 38,599評(píng)論 1 340
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖竣稽,靈堂內(nèi)的尸體忽然破棺而出囱怕,到底是詐尸還是另有隱情,我是刑警寧澤毫别,帶...
    沈念sama閱讀 34,264評(píng)論 4 328
  • 正文 年R本政府宣布娃弓,位于F島的核電站宣蔚,受9級(jí)特大地震影響崎逃,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜戴而,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,857評(píng)論 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望挽霉。 院中可真熱鬧防嗡,春花似錦、人聲如沸侠坎。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 30,731評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)实胸。三九已至他嫡,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間庐完,已是汗流浹背钢属。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 31,956評(píng)論 1 264
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留假褪,地道東北人署咽。 一個(gè)月前我還...
    沈念sama閱讀 46,286評(píng)論 2 360
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像生音,于是被迫代替她去往敵國(guó)和親宁否。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,465評(píng)論 2 348

推薦閱讀更多精彩內(nèi)容