最近有个需求:评论@人。网上已经有一些文章分享了相似功能实现逻辑,可是几乎都是扩展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 來源:简书 简书著做权归做者全部,任何形式的转载都请联系做者得到受权并注明出处。