一、背景
最近正在做富文本编辑器,整个编辑器包含加粗、斜体、下划线、删除线、字体背景、字体颜色、链接、等宽、引用、有序列表、无序列表、代码块等功能。
整体下来以上功能全部基于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
- Spannable
- SpannedString
- SpannableString
- SpannableStringBuilder
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。调整实现之后,发现可以效果达到了理想状态。