Android Span架构介绍 & 如何基于TextView 实现富文本编辑器

一、背景

最近正在做富文本编辑器,整个编辑器包含加粗、斜体、下划线、删除线、字体背景、字体颜色、链接、等宽、引用、有序列表、无序列表、代码块等功能。

整体下来以上功能全部基于TextView(EditText由于继承自TextView,以下统一称呼为TextView) Span接口来做。在此之前对于Span的认知属于一知半解,没有一个非常全面的知识图谱。所以借着本次富文本编辑器的开发,梳理Span的整体架构。同时本篇文档也会对富文本编辑的实现做一个简单介绍。

二、TextView 的Span架构

了解Android 开发的同学都知道Android TextView代码非常复杂,仅TextView一个类,就有13000+行代码。文本编辑器EditText继承自TextView代码两百余行,所以可以说绝大部分逻辑都集中在TextView中。如果要基于TextView实现富文本样式,有两种策略:

  • 基于Span接口
java 复制代码
public void setSpan(Object what, int start, int end, int flags)
  • 基于Html 使用
java 复制代码
tv.setText(Html.fromHtml(HTML_TEXT));

由于使用HTML要依赖HTML标签,TextView本身对于标签的支持性不够好,所以目前实现富文本样式计划基于Span接口。

2.1、Span 架构

Span主要的顶层类或者接口:

  • CharacterStyle 影响字符级别文本格式
  • UpdateAppearance 影响字符级别文本外观
  • UpdateLayout 影响字符级别文本测量
  • ParagraphStyle 影响段落级别的文本格式

CharacterStyle

java 复制代码
public abstract class CharacterStyle { 
    public abstract void updateDrawState(TextPaint tp); 
}

UpdateAppearance

java 复制代码
public interface UpdateAppearance { }

UpdateLayout

java 复制代码
public interface UpdateLayout extends UpdateAppearance { }

ParagraphStyle

java 复制代码
public interface ParagraphStyle{ }

2.2、CharacterStyle 顶层抽象类

影响字符级别文本格式,它只有一个抽象方法 updateDrawState(TextPaint tp),该方法用于将特定的样式更新到TextPaint中。当文本绘制时,这个方法被TextView调用,以便给文本应用特定的样式。

我们比较熟悉的一些Span都是直接继承自 CharacterStyle:

  • BackgroundColorSpan 改变文本的背景色
  • ClickableSpan 实现Span区域可点击
  • ForegroundColorSpan 改变文本的前景色
  • UnderlineSpan 向文本添加下划线
  • StrikethroughSpan 向文本添加删除线
  • MetricAffectingSpan 抽象类

同时以上所有的类同时都实现了 UpdateAppearance 接口。因为实现 UpdateAppearance 接口代表这个Span将会影响字符级别文本外观。

2.3、UpdateAppearance

java 复制代码
public interface UpdateAppearance { }

UpdateAppearance 接口本身不定义任何方法,是一个标记性接口,它用于指示某种类型的Span对象可以改变文本的外观,但不会影响文本的布局(如宽度和高度)。

Span对象可以影响文本的多种属性,包括颜色、字体、链接、样式等,而实现了 UpdateAppearance 接口的Span类特别表示它们仅影响文本的外观。这意味着文本视觉样式的改变不会引起重新布局,所以它们可以在文本不重新测量和布局的情况下被添加、更新或移除。

2.2 中所有的类都实现了 UpdateAppearance 接口。CharacterStyle 类规范行为,UpdateAppearance 规范定义。

2.4、UpdateLayout

UpdateLayout 继承自 UpdateAppearance

java 复制代码
public interface UpdateLayout extends UpdateAppearance { }

与 UpdateAppearance 接口不同,UpdateAppearance 标记的 Span 只改变文本的外观(如颜色、字体样式等),而不会影响布局,UpdateLayout 接口的 Span 会影响文本的布局,这意味着当它们被应用到文本上时,文本可能需要重新测量和布局。UpdateLayout 也是标记性接口,无任何方法。

MetricAffectingSpan 实现了 UpdateLayout 接口。

2.5、MetricAffectingSpan

这里介绍一下 MetricAffectingSpan,MetricAffectingSpan 继承自 CharacterStyle同时实现了UpdateLayout接口。其允许开发者改变文本样式,因为其实现了 UpdateLayout 接口,这些改变不仅影响文本的外观,也影响文本布局的测量。

java 复制代码
public abstract class MetricAffectingSpan extends CharacterStyle implements UpdateLayout {

    /**
     * Classes that extend MetricAffectingSpan implement this method to update the text formatting
     * in a way that can change the width or height of characters.
     *
     * @param textPaint the paint used for drawing the text
     */
    public abstract void updateMeasureState(@NonNull TextPaint textPaint);
    /**
     * Returns "this" for most MetricAffectingSpans, but for
     * MetricAffectingSpans that were generated by {@link #wrap},
     * returns the underlying MetricAffectingSpan.
     */
    @Override

    public MetricAffectingSpan getUnderlying() {
        return this;
    }
}

MetricAffectingSpan重要的两个方法

  • updateDrawState(TextPaint tp)
    • 修改用于绘制文本的 TextPaint 对象的方法,这会影响文本的外观,如颜色、字体大小等,本方法来自接口 CharacterStyle 。
  • updateMeasureState(TextPaint p)
    • 修改用于测量文本布局的 TextPaint 对象的方法,这会影响文本的度量,如宽度和高度。

MetricAffectingSpan 有哪些子类,都是用来做什么的

  • TextAppearanceSpan
    • 这个类能够将一个 TextAppearance 样式资源(通常在 styles.xml 文件定义)应用到文本上。它可以用来改变文本的字体、大小、颜色等各种属性。
  • AbsoluteSizeSpan
    • 这个类改变文本的字体大小,使用绝对值。
  • RelativeSizeSpan
    • 与 AbsoluteSizeSpan 类似,这个类可以根据当前文本大小的相对值来改变字体大小(例如,将文本大小设置为当前大小的1.5倍)。
  • ScaleXSpan
    • 这个类可以横向缩放文本,可以用作特殊的文字效果。
  • StyleSpan
    • 这个类可以改变文本的样式,例如使其变为粗体、斜体或者粗斜体。
  • TypefaceSpan
    • 这个类允许你指定一个字体家族来改变文本的字体类型。

MetricAffectingSpan 还有一个子类,我们业务中也经常使用:ReplacementSpan。例如我们的业务中显示图片、表情、文档的Span均继承自ReplacementSpan。

2.6 、ReplacementSpan

java 复制代码
public abstract class ReplacementSpan extends MetricAffectingSpan {

    /**
     * Returns the width of the span. Extending classes can set the height of the span by updating
     * attributes of {@link android.graphics.Paint.FontMetricsInt}. If the span covers the whole
     * text, and the height is not set,
     * {@link #draw(Canvas, CharSequence, int, int, float, int, int, int, Paint)} will not be
     * called for the span.
     *
     * @param paint Paint instance.
     * @param text Current text.
     * @param start Start character index for span.
     * @param end End character index for span.
     * @param fm Font metrics, can be null.
     * @return Width of the span.
     */
    public abstract int getSize(@NonNull Paint paint, CharSequence text,
                        @IntRange(from = 0) int start, @IntRange(from = 0) int end,
                        @Nullable Paint.FontMetricsInt fm);

    /**
     * Draws the span into the canvas.
     *
     * @param canvas Canvas into which the span should be rendered.
     * @param text Current text.
     * @param start Start character index for span.
     * @param end End character index for span.
     * @param x Edge of the replacement closest to the leading margin.
     * @param top Top of the line.
     * @param y Baseline.
     * @param bottom Bottom of the line.
     * @param paint Paint instance.
     */
    public abstract void draw(@NonNull Canvas canvas, CharSequence text,
                              @IntRange(from = 0) int start, @IntRange(from = 0) int end, float x,
                              int top, int y, int bottom, @NonNull Paint paint);

    /**
     * This method does nothing, since ReplacementSpans are measured
     * explicitly instead of affecting Paint properties.
     */
    public void updateMeasureState(TextPaint p) { }

    /**
     * This method does nothing, since ReplacementSpans are drawn
     * explicitly instead of affecting Paint properties.
     */
    public void updateDrawState(TextPaint ds) { }
}

当我们使用 ReplacementSpan 时,必须要重写 getSize 与 draw 方法。

  • getSize(Paint paint, CharSequence text, int start, int end, Paint.FontMetricsInt fm):
    • 这个方法被用来确定替换部分文本的宽度。如果你需要修改行的高度,也可以在这个方法中通过参数 fm 修改字体度量。
  • draw(Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, Paint paint)
    • 在这个方法中,开发者可以自定义如何绘制替换的文本。canvas 是绘制的画布,text 是要被替换的原始文本,start 和 end 表示替换范围在原始文本中的开始和结束位置,x、top、y、bottom 分别标明了要替换文本的位置和边界,paint 为绘制文本使用的画笔。

2.7、ParagraphStyle

java 复制代码
public interface ParagraphStyle{ }

ParagraphStyle 是 Android 文本渲染系统中的一个接口,用于定义可以应用于段落级别的样式属性。一个段落通常被定义为由换行符或文档起始/结束位置界定的文本块。实现 ParagraphStyle 接口的 Span 对象可以影响整个段落的显示方式,而不仅仅是单个字符或文字。

ParagraphStyle的实现类:

  • AlignmentSpan.Standard
    • 用于改变段落文本的对齐方式(如左对齐、居中、右对齐)。
  • LeadingMarginSpan.Standard
    • 用于为文本添加前导边距,它可以用来实现缩进效果或模拟列表的布局。
  • LeadingMarginSpan.LeadingMarginSpan2
    • LeadingMarginSpan 的子接口,它允许对段落前几行使用一个边距,而对后面的行使用另一个边距,这在创建多级列表时很有用。
  • QuoteSpan
    • 在段落的左侧添加一条垂直线,用于引用或高亮显示文本。
  • BulletSpan
    • 创建带有圆形子弹点的列表项。可以自定义子弹点的颜色和半径。
  • DrawableMarginSpan
    • 允许在段落的前面插入一个 Drawable 对象,这可以用来创建带有图标的列表项。
  • LineHeightSpan
    • 用于为段落设置自定义的行高。可以为段落中的每一行文本指定固定的行高。
  • LineBackgroundSpan
    • 允许为包含该 Span 的文本行设置背景颜色,这可以用于突出显示某些文本行。
  • TabStopSpan.Standard
    • 设置水平制表符的停靠位置。文本中的 \t 字符会对齐到最近的制表符位置。
  • IconMarginSpan
    • 类似于 DrawableMarginSpan,但专门用于在文本前插入一个 Bitmap 图标。它允许为段落的第一行添加一个图标。

这些类可以被用来创建复杂的文本布局效果,例如创建有着不同行高、对齐方式、列表样式、引用样式和缩进的文本。当使用这些 Span 时,通常需要指定它们在 Spannable 字符串中的起始和结束位置,以及它们应该应用的标记类型(如 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)。

使用 ParagraphStyle 实现类时,需要注意 Span 的应用范围,以确保样式正确应用于预期的段落中。例如,如果要应用 BulletSpan 以创建列表项,应确保 Span 起始和结束位置恰好围绕着一个完整的段落(通常以换行符结束)。

2.8、LeadingMarginSpan

java 复制代码
public interface LeadingMarginSpan
extends ParagraphStyle
{
    /**
     * Returns the amount by which to adjust the leading margin. Positive values
     * move away from the leading edge of the paragraph, negative values move
     * towards it.
     *
     * @param first true if the request is for the first line of a paragraph,
     * false for subsequent lines
     * @return the offset for the margin.
     */
    public int getLeadingMargin(boolean first);

    /**
     * Renders the leading margin.  This is called before the margin has been
     * adjusted by the value returned by {@link #getLeadingMargin(boolean)}.
     *
     * @param c the canvas
     * @param p the paint. The this should be left unchanged on exit.
     * @param x the current position of the margin
     * @param dir the base direction of the paragraph; if negative, the margin
     * is to the right of the text, otherwise it is to the left.
     * @param top the top of the line
     * @param baseline the baseline of the line
     * @param bottom the bottom of the line
     * @param text the text
     * @param start the start of the line
     * @param end the end of the line
     * @param first true if this is the first line of its paragraph
     * @param layout the layout containing this line
     */
    public void drawLeadingMargin(Canvas c, Paint p,
                                  int x, int dir,
                                  int top, int baseline, int bottom,
                                  CharSequence text, int start, int end,
                                  boolean first, Layout layout);

    
}

LeadingMarginSpan 是 Android 中用于处理段落前导边距(即段落的左边距)的接口,在文本布局中,它允许开发者为文本段落设置首行缩进或整体缩进。LeadingMarginSpan 的实现类可以控制段落首行或所有行的左边距,从而创建列表、引用块或其他需要特殊缩进的文本格式。

LeadingMarginSpan 接口有两个关键方法:

  • getLeadingMargin(boolean first)
    • 返回首行(first 为 true)或后续行(first 为 false)的前导边距的像素值。这个值用来确定文本在水平方向的偏移量。
  • drawLeadingMargin(Canvas c, Paint p, int x, int dir, int top, int baseline, int bottom, CharSequence text, int start, int end, boolean first, Layout layout)
    • 用于在段落前绘制任意的装饰(如符号、图标等)。这个方法提供了一个画布对象和其他绘图参数,允许自定义绘制逻辑。

2.9、与Span有关的接口

Spanned 接口

java 复制代码
public interface Spanned
extends CharSequence
{

    /**
     * Return an array of the markup objects attached to the specified
     * slice of this CharSequence and whose type is the specified type
     * or a subclass of it.  Specify Object.class for the type if you
     * want all the objects regardless of type.
     */
    public <T> T[] getSpans(int start, int end, Class<T> type);

    /**
     * Return the beginning of the range of text to which the specified
     * markup object is attached, or -1 if the object is not attached.
     */
    public int getSpanStart(Object tag);

    /**
     * Return the end of the range of text to which the specified
     * markup object is attached, or -1 if the object is not attached.
     */
    public int getSpanEnd(Object tag);

    /**
     * Return the flags that were specified when {@link Spannable#setSpan} was
     * used to attach the specified markup object, or 0 if the specified
     * object has not been attached.
     */
    public int getSpanFlags(Object tag);

    /**
     * Return the first offset greater than <code>start</code> where a markup
     * object of class <code>type</code> begins or ends, or <code>limit</code>
     * if there are no starts or ends greater than <code>start</code> but less
     * than <code>limit</code>. Specify <code>null</code> or Object.class for
     * the type if you want every transition regardless of type.
     */
    public int nextSpanTransition(int start, int limit, Class type);
}

Spannable

java 复制代码
public interface Spannable
extends Spanned
{
    /**
     * Attach the specified markup object to the range <code>start...end</code>
     * of the text, or move the object to that range if it was already
     * attached elsewhere.  See {@link Spanned} for an explanation of
     * what the flags mean.  The object can be one that has meaning only
     * within your application, or it can be one that the text system will
     * use to affect text display or behavior.  Some noteworthy ones are
     * the subclasses of {@link android.text.style.CharacterStyle} and
     * {@link android.text.style.ParagraphStyle}, and
     * {@link android.text.TextWatcher} and
     * {@link android.text.SpanWatcher}.
     */
    public void setSpan(Object what, int start, int end, int flags);

    /**
     * Remove the specified object from the range of text to which it
     * was attached, if any.  It is OK to remove an object that was never
     * attached in the first place.
     */
    public void removeSpan(Object what);

    /**
     * Factory used by TextView to create new {@link Spannable Spannables}. You can subclass
     * it to provide something other than {@link SpannableString}.
     *
     * @see android.widget.TextView#setSpannableFactory(Factory)
     */
    public static class Factory {
        private static Spannable.Factory sInstance = new Spannable.Factory();

        /**
         * Returns the standard Spannable Factory.
         */
        public static Spannable.Factory getInstance() {
            return sInstance;
        }

        /**
         * Returns a new SpannableString from the specified CharSequence.
         * You can override this to provide a different kind of Spannable.
         */
        public Spannable newSpannable(CharSequence source) {
            return new SpannableString(source);
        }
    }
}

SpannedString、SpannableString 、SpannableStringBuilder 之间的区别:

2.9.1、 SpannedString

SpannedString 继承自Spanned,没有设置Span接口,所以是一个不可变的文本序列。意味着一旦创建,它的内容和附加的Span都不能被更改。由于它是不可变的,SpannedString在传递和使用时更加安全。当你想要保持原始文本不变时,可以选择使用它。

2.9.2、 SpannableString

SpannableString 继承自 Spannable。SpannableString是一个可变的文本序列,允许修改文本中的字符,但不支持修改文本长度(例如不能添加或删除字符)。它允许添加、移除或更改附加到文本上的Span对象,这意味着你可以改变文本的样式,如颜色、大小、点击事件等。SpannableString适用于文本大小固定但样式需要变动的情况。

2.9.3、 SpannableStringBuilder

SpannableStringBuilder是一个动态的、可变的文本序列,允许添加、替换或删除文本中的字符,也就是说,它支持对文本内容的编辑。它也允许添加、移除或更改附加到文本上的Span对象。SpannableStringBuilder提供了丰富的API来操作文本和样式,是最灵活的类。如果你需要执行多次文本和样式的编辑操作,这是最佳的选择。

在实际开发中,选择使用哪个类通常取决于你的需求。如果你只需要展示一段带有样式的文本,并且不需要对其进行修改,那么SpannedString或SpannableString可能就足够了。如果你需要在用户与文本交互的过程中动态地改变文本内容和样式,那么SpannableStringBuilder则是更合适的选择。

三、富文本能力实现

接下来介绍一下,基于以上Span,如何实现富文本能力。我们依据Span作用范围,将富文本能力拆分成三类,分别为字符类、段落类以及代码块。

3.1、字符类

字符类的功能分别有:加粗、斜体、下划线、删除线、字体背景、字体颜色、链接、等宽。这些效果实现起来都比较简单。基本在上面我们介绍的Span中都能找到对应实现类,如果没有也可以集成 MetricAffectingSpan 自行实现。

3.2、段落类

段落类的功能主要是:引用、有序列表、无序列表, 在我们的项目中我们是继承自LeadingMarginSpan ,自定义对应的实现。如果需要类似TODO的功能也可以基于 LeadingMarginSpan 进行自定义。

3.3、代码块

代码块比较特殊,这里单独说一下。由于在我们的需求中,代码块需要支持设置代码语言,支持复制,同时整个代码块还需要存在一个携带边框的背景。

这里携带边框的背景,我们初期是选择使用段落类的Span进行绘制,但是代码块中可能存在多个段落,使用LeadingMarginSpan 整体的逻辑并不对。后面我们组内经过讨论,同学提出为何不在onDraw()方法中自行绘制呢,自行绘制就不存在段落类Span的限制了。

所以这里采用整个代码块被一个空实现的字符类Span包裹,这个Span没有任何实现,仅用来做逻辑实现。在TextView的onDraw()方法中,根据这个Span的位置,在onDraw方法中绘制边框以及背景。

java 复制代码
    val baseTextStyle = othersTextStyleMap[TextStyle.CODE]
    if (baseTextStyle != null && layout != null) {
        val codeSpan = text!!.getGivenSpan(baseTextStyle = baseTextStyle, 0, text!!.length)
        codeSpan.forEach {

            val spanStart = text!!.getSpanStart(it)
            val spanEnd = text!!.getSpanEnd(it)

            val startLine = layout.getLineForOffset(spanStart)
            val endLine = layout.getLineForOffset(spanEnd)

            val left = compoundPaddingLeft.toFloat()
            val right = (width - compoundPaddingRight).toFloat()
            val top = (layout.getLineTop(startLine)).toFloat() + compoundPaddingTop
            val bottom = (layout.getLineBottom(endLine)).toFloat() + + compoundPaddingTop

            codeBgRect.set(left, top, right, bottom)
            codeBorderRect.set(left, top, right, bottom)
            // 绘制背景的圆角矩形
            canvas?.drawRoundRect(codeBgRect, cornerRadius, cornerRadius, codeBgPaint)
            // 绘制边框的圆角矩形
            canvas?.drawRoundRect(codeBorderRect, cornerRadius, cornerRadius, codeBorderPaint)
        }
    }

代码实现如上。获取到Span之后,我们通过Span的start、End位置,获取到对应的行数,然后根据行数确定矩形边框的左、上、右、下。

3.4、富文本通用逻辑

在本小结中介绍一下,实现富文本的通用逻辑。因为仅清楚使用Span设置对应的效果是不够,我们还需要有很多对应的交互逻辑。

在实现富文本的能力中,需要关注以下几方面能力:

3.4.1、添加TextWatcher,监听用户新输入的内容

当用户点击粗体按钮,希望接下来输出的内容都是粗体,那么我们的实现方案就是为TextView设置TextWatcher,监听用户输入。

在 onTextChanged() 方法回调后,为用户新输入的内容设置对应的Span。

3.4.2、用户调整光标

假设当前用户斜体输入,然后用户将光标调整到粗体的字符中间,此时用户再次进行输入,输入到TextView中的文本就需要为粗体样式了。

为了实现这个效果,我们需要重写 onSelectionChanged() 方法,监听光标的变化:

java 复制代码
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
    super.onSelectionChanged(selStart, selEnd) 
}

当光标发生变化时,获取光标位置存在的Span。如果获取到粗体Span,那么意味着再次输入的内容也需要粗体。

其次,用户调整光标也可能是要部分选中,即 selStart != selEnd ,我们也需要针对这种情况做处理。如果部分选中区域包含富文本效果,用户再次点击需要将其取消。用户取消之后,再次点击需要设置对应的富文本效果。

3.4.3、常规流程图

以上为常规的流程图,当用户点击某个富文本按钮时,需要判断当前是为部分选中状态,如果为部分选中状态,只需要设置/取消对应的富文本样式(使用removeSpan接口)。如果不是部分选中状态,那么需要设置对应的TextWathcer监控用户输入,设置对应的样式Span。

需要说明的是,设置字符类型的Span,对应的流程比较简单。段落类的Span就会比以上流程复杂很多,原因是段落类的Span需要额外插入占位符。因为段落类的Span,通常每个段落前面都需要对应的样式,如无序列表,每个段落前面需要有一个圆点。这个样式我们需要有对应的占位符用于显示它,即用户不输入文本也可以展示这个圆点。在我们需求中,我们使用 ZeroWidthSpace 字符( "\u200b" ), 即能占位,也不会显示出来。

3.5、遇到的坑

下面介绍一下,在实现过程中遇到过的坑。

3.5.1、体验逻辑较预期复杂

一些隐藏的逻辑比较多,就如上面介绍的,当用户调整光标(包含部分选中)时,我们都需要对应的逻辑跟进处理。

3.5.2、耗时

最初实现时,如果用户输入较多文字,然后全选设置粗体相关的效果,我们会为每一个字符设置一个Span样式,这样做的目的就是逻辑比较清晰,如果选中的文字中间包含不能被设置为粗体的字符,我们仅需过滤掉该字符就行了。不过在实测过程中,发现这样会比较耗时。所以最后还是改为整体设置一个Span,遇到无法设置粗体Span的字符,就将整个范围拆成两个部分,设置成两个Span。

3.5.3、段落类Span

段落类Span由于本次是第一次使用,在使用中使用不当,一直无法实现理想的效果。最后通过不断翻阅文档,发现一个段落仅能设置一个类型的段落Span。调整实现之后,发现可以效果达到了理想状态。

3.6、最后实现样式截图

四、参考链接

developer.android.com/guide/topic...

相关推荐
CYRUS STUDIO3 分钟前
ARM64汇编寻址、汇编指令、指令编码方式
android·汇编·arm开发·arm·arm64
weixin_449310841 小时前
高效集成:聚水潭采购数据同步到MySQL
android·数据库·mysql
Zender Han1 小时前
Flutter自定义矩形进度条实现详解
android·flutter·ios
白乐天_n3 小时前
adb:Android调试桥
android·adb
姑苏风7 小时前
《Kotlin实战》-附录
android·开发语言·kotlin
数据猎手小k10 小时前
AndroidLab:一个系统化的Android代理框架,包含操作环境和可复现的基准测试,支持大型语言模型和多模态模型。
android·人工智能·机器学习·语言模型
你的小1011 小时前
JavaWeb项目-----博客系统
android
风和先行11 小时前
adb 命令查看设备存储占用情况
android·adb
AaVictory.12 小时前
Android 开发 Java中 list实现 按照时间格式 yyyy-MM-dd HH:mm 顺序
android·java·list
似霰13 小时前
安卓智能指针sp、wp、RefBase浅析
android·c++·binder