背景
闲鱼是国内最早使用 Flutter 的团队,作为一个电商 App 商品详情页是非常重要场景,其中最主要的技术能力是文字混排。
我们面对文本类的需求是复杂而且多变,然而 Flutter 历史的几个版本,Text 只能显示简单样式文本,它只有包含一些控制文本样式显示的属性,而通过 TextSpan 连接实现的 RichText 也只能显示多种文本样式(例如:一个基础文本片段和一个链接片段),这些远远达不到设计需要的能力。被产品和设计怂为啥别人别的平台能做,Flutter 为何做不了,不管,必须支持。
因此,需要开发一个能力更强的文字混排组件就变得迫在眉睫。
富文本的原理
再讲文字混批组件设计实现前,先来讲讲系统 RichText 的富文本的原理。
- 创建过程
创建 RichText 节点的时候其实会创建以下几个对象:
-
先创建 LeafRenderObjectElement 实例。
- ComponentElement 方法当中会调用 RichText 实例的 CreateRenderObject 方法,生成 RenderParagraph 实例。
- RenderParagraph 会创建 TextPainter 负责其就计算宽高和绘制文本到 Canvas 的代理类,同时 TextPainter 持有 TextSpan 文本结构。
RenderParagraph 实例最后会将自身登记到渲染模块的 Dirty Nodes 当中去,渲染模块会遍历 Dirty Nodes 将进入 RenderParagraph 渲染环节。
- 渲染过程
RenderParagraph 方法当中封装的是将文本绘制到 canvas 上面的逻辑,主要是用了一个叫做 TextPainter 的模块, 其调用过程遵循 RenderObject 调用。
- PerfromLayout 过程通过调用 TextPaint 的 Layout,在期过程中通过 TextSpan 结构树,依次通过 AddText 添加各个阶段的文本,最后通过 Paragraph 的 Layout 计算文本高度。
- Paint 过程,先绘制 clipRect,接着通过 TextPaint 的 Paint 函数调用,Paragraph 的 Paint 绘制文本,最后绘制 drawRect。
设计思路
通过 RichText 的文本绘制原理, 我们不难发现 TextSpan 记录了各段文本信息,TextPaint 通过记录的信息调用 Native 接口计算宽高,以及将文本绘制到 canvas 上面。传统的方案实现复杂的混排,会通过 HTML 去做一个 WebView 的富文本,使用 WebView 在性能上自然不及原生实现,出于性能的考虑,我们设想通过通过原生的方式去实现图文混排。一开始的方案是设计几种特殊的 Span(例如:ImageSpan,EmojiSpan 等),通过 Span 记录的信息,在 TextPaint 的 Layout 重新根据各种类型重新计算布局,在 Paint 过程再分别绘制特殊的 Widget,然而这种方案对上面几个涉及的类封装破坏的特别大,需要将 RichText、RenderParagraph 源码 Copy 出来重新修改。最后设想是后可以通过特殊的文字先占位置,(例如:空字符串),然后在这个文字的位置上面把特殊的 Span 分别独立移动到上面。
然而上面这种方案会带来两个难点:
- 难点一:如何在文本中先占位,并且能制定任意想要的宽高。
通过 Google 发现 u200B 字符代表 ZERO WIDTH SPACE(宽带为 0 的空白),结合对 TextPainter 测试,我们发现 layout 出来的 Width 总是 0,fontSize 只决定了高度,结合 TextStyle 里面的 letterSpacing
/// The amount of space (in logical pixels) to add between each letter
/// A negative value can be used to bring the letters closer.
final double letterSpacing;
这样我们就能任意的控制这个特殊文字的宽高度。
- 难点二:如何将特殊的 Span 移动到位置上面。
通过上面的测试不难发现,特殊的 Span 其实还是独立 Widget 和 RichText 并不融合。所以我们需要知道当前 widget 相对 RichText 空间的相对位置,并且结合 Stack 将其融合。结合 TextPaint 里面的 getOffsetForCaret 方法
/// Returns the offset at which to paint the caret.
///
/// Valid only after [layout] has been called.
Offset getOffsetForCaret(TextPosition position, Rect caretPrototype)
可以天然的获取到当前占位符相对位置。
实现方案
关键部分代码实现如下:
-
统一的占位 SpaceSpan
SpaceSpan({ this.contentWidth, this.contentHeight, this.widgetChild, GestureRecognizer recognizer, }) : super( style: TextStyle( color: Colors.transparent, letterSpacing: contentWidth, height: 1.0, fontSize: contentHeight), text: '\u200B', recognizer: recognizer);
-
SpaceSpan 相对位置获取
for (TextSpan textSpan in widget.text.children) {if (textSpan is SpaceSpan) { final SpaceSpan targetSpan = textSpan; Offset offsetForCaret = painter.getOffsetForCaret(TextPosition(offset: textIndex), Rect.fromLTRB(0.0, targetSpan.contentHeight, targetSpan.contentWidth, 0.0), ); ........ } textIndex += textSpan.toPlainText().length;}
-
RichtText 和 SpaceSpan 融合
Stack( children: <Widget>[RichText(), Positioned(left: position.dx, top: position.dy, child: child), ], ); }
效果
先上图看看效果
这种方案的优点是任意 Widget 可通过 SpaceSpan 和 RichText 进行组合,无论是图片、自定义标签、甚至是按钮都可以融合进来,同时对 RichText 本身封装性破坏较小。
未来
上面只是富文本显示的部分,依然存在着很多局限,还有较多需要优化的点,目前通过 SpaceSpan 控件,必需要指定宽高,另外对于文本选择、自定义文字背景这些都是无法支持,其次对富文本编辑器的支持,可以使其编辑文字时,让图片、货币格式化等控件输入等。
本文作者:闲鱼技术 - 玄川
阅读原文
本文为云栖社区原创内容,未经允许不得转载。