真·富文本编辑器的演进之路-Span的整体性控制

1,268 阅读6分钟

时隔多日,终于又更新了。

在了解了Span的基本知识后,我们先来处理下「Span的整体性控制」,怎么理解呢?我们在编辑富文本的时候,经常会遇到一些整体内容的输入,例如「@用户」、输入话题「#什么话题#」、跳转链接「URL」,这些Span区别于普通文字,输入时是整体输入,删除时,也是整体删除,而知中间也不能插入文字或者修改,这就是「Span的整体性控制」。

所以,我们需要对Span做下面的限制:

  • 中间不允许光标插入
  • 增加时整体新增
  • 删除时整体删除

对应这样的需求,我们有两种方式来处理,第一种是使用原本就是整体的Span,例如ImageSpan,这是最简单的方法,而且代码也非常简单,另一种是通过代码处理,让普通文本来实现整体性的功能。

通过ImageSpan保证完整性

将Span内容生成ImageSpan,从而实现整体性控制。这种方案简单易行,我们以新增「@用户」为例。

  1. 首先,创建一个ATSpan,继承自ImageSpan,附带@的数据信息
  2. 解析要添加的富文本数据,将要展示的内容,例如「@xuyisheng」,作为文本,创建一个TextView来承载
  3. 将生成的TextView转化为Drawable,设置给ATSpan,并传入@的相关数据信息
  4. 将ImageSpan插入Edittext,实现整体性Span的富文本插入

可以发现,这种方案的实现步骤是比较简单的,但是它的确定也很明显:

首先,由于是ImageSpan,所以在与普通文本的对齐方式上,始终会存在一些误差,这些误差有来自TextView-Drawable的转换过程,也有ImageSpan的对齐过程,所以,在样式上,对齐会有一些问题,同时,由于TextView-Drawable的整体性,一旦TextView有多行或者当前行剩余位置不够,那么第二行的剩余区域都将被View的矩形区域填满,从而导致这些区域无法再输入文本,如下所示。

image-20210819162910988

这是由于View的图形限制导致的问题,使用ImageSpan的话,是无法解决的问题,由此可见,ImageSpan虽然天生具有整体性,但是却只是一个妥协的方案,不能算是最好的实现方式。

通过SpanWatcher控制

第二种方案,我们使用普通文本,但是对普通文本增加Span标记,并对这个Span做整体性控制,这种方案复杂一点,要处理的地方也比较多,但是由于它使用的是普通文本,所以在样式上可以和其它普通文本完全保持一致,视觉样式应该是最好的。

着色

首先,我们来实现普通文本的变色功能,做一个蓝色的字色,这个比较简单,可以使用ClickableSpan或者其它Span来着色,为了方便我们富文本的输入和展示,这里直接选择ClickableSpan来实现。

控制选中

在讲解如何在普通文本中对Span做整体性控制前,我们先来考虑下选择的问题——如何让「整体性Span」的内部无法被选中。

首先,我们要知道,Edittext的光标也是一种Span。也就是说,我们可以通过监听光标的移动事件,通过Selection实现当光标移动到Span内部时,让它重新移动到Span最近的边缘位置,从而让Span内部永远无法插入光标,这就是我们的主要思路。

那么问题来了,我要怎么监听Edittext的光标呢?

其实,Android的Span不仅功能非常强大,而且也提供了非常完善的管理API,在TextView和Edittext中,我们要监听Text的变化过程,可以使用TextWatcher,它可以在文本发生改变时进行回调,类似的,在SpannableStringBuidler中,也有类似的管理类——SpanWatcher,它同样可以用于在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.

在TextVIew的内部,它通过DynamicLayout来渲染Spannable数据,在其内部会设置SpanWatcher来监听Span的新增、修改和删除,当监听到变化后,会调用其内部的方法进行刷新。

image-20210819165313706

SpanWatcher和TextWatcher一样,都是继承自NoCopySpan,它们一个监听文本变化,一个监听Span变化。

看完了SpanWatcher,再来看下Selection,Selection是为TextView和Edittext设计的一套管理选中态的工具类,借助Selection,可以在不依赖具体View的情况下,对Span做选中态的修改。

Selection有两个状态,Start和End,而选择光标,就是Selection的两个状态,当两个状态重合时,就是光标的输入状态。

现在我们的思路就很明显了,在SpanWatcher的onSpanChanged中监听Selection的Start和End状态即可,一旦Selection的Start和End在我们的「整体性Span」中,就将Selection光标移动到最近的Span标记处。

image-20210819173317458

那么SpanWatcher怎么使用呢?

Edittext提供了Editable.Factory来自定义添加SpanWatcher,我们只需要在初始化的时候传入即可,代码如下所示。

class ExEditableFactory(private val spans: List<NoCopySpan>) : Factory() {
    override fun newEditable(source: CharSequence): Editable {
        val spannableStringBuilder = RepairSpannableStringBuilder(source)
        for (span in spans) {
            spannableStringBuilder.setSpan(span, 0, source.length, Spanned.SPAN_INCLUSIVE_INCLUSIVE or Spanned.SPAN_PRIORITY)
        }
        return spannableStringBuilder
    }
}

val watchers = ArrayList<NoCopySpan>()
watchers.add(SelectionSpanWatcher(IntegratedSpan::class))
setEditableFactory(ExEditableFactory(watchers))

这样我们就完成了选中的整体性功能,当我们的Selection在「整体性Span」(通过IntegratedSpan来标记)中时,就自动修改Selection的位置,从而实现「整体性Span」中间无法插入光标。

控制删除

那么除了选中之外,剩下的一个问题就是删除的整体性控制。

相比于选中来说,删除就比较简单了,我们可以通过setOnKeyListener来监听KeyEvent.KEYCODE_DEL和KeyEvent.ACTION_DOWN事件。

当我们检测到这两个事件后,根据当前Selection的位置,拿到当前是否存在「整体性Span」,如果是「整体性Span」,那么在删除时则整体移除即可。

这里有个很重要的地方,getSpan函数传入的Start和End,是闭区间,也就是说,即使光标现在在「整体性Span」的末尾,getSpan函数也是能拿到这个Span的。

有了思路之后,我们的代码就很容易了,关键代码如下所示。

image-20210820145414181

其实这里除了对「整体性Span」进行整体性删除以为,你甚至可以使用removeSpan来移除「整体性Span」,从而将其恢复成普通文本,当然,这都是看你自己的需求了。

好了,到此为止,我们又实现了富文本编辑器中的一个非常重要的功能——Span的整体性控制。

向大家推荐下我的网站 xuyisheng.top/ 专注 Android-Kotlin-Flutter 欢迎大家访问