在移動開發中圖文混排是十分常見的業務需求,如下圖效果所示,本篇將介紹在 Flutter 中的圖文混排效果與實現原理。

事實上,針對如上所示的圖文混排需求,Flutter 官方提供了十分便捷的實現方式: WidgetSpan
。
如下程式碼所示,通過 Text.rich
接入 TextSpan
和 WidgetSpan
就可以快速實現圖文混排的需求,並且可以看出 WidgetSpan
不止支援圖片控制元件,它可以接入任何你需要的 Widget
,比如 Card
、InkWell
等等。
Text.rich(TextSpan(
children: <InlineSpan>[
TextSpan(text: 'Flutter is'),
WidgetSpan(
child: SizedBox(
width: 120,
height: 50,
child: Card(
color: Colors.blue,
child: Center(child: Text('Hello World!'))),
)),
WidgetSpan(
child: SizedBox(
width: size > 0 ? size : 0,
height: size > 0 ? size : 0,
child: new Image.asset(
"static/gsy_cat.png",
fit: BoxFit.cover,
),
)),
TextSpan(text: 'the best!'),
],
)
複製程式碼
也就是說 WidgetSpan
支援在文字中插入任意控制元件,這大大提升了 Flutter 中富文字的自定義效果,比如上述演示效果中隨意改變圖片的大小。
那為什麼 WidgetSpan
可以如何方便地實現文字和 Widget 混合效果呢?這就要從 Text
的實現說起。
實現原理
我們常用的 Text
控制元件其實只是 RichText
的封裝,而 RichText
的實現如下圖所示,主要可以分為三部分:MultiChildRenderObjectWidget
、 MultiChildRenderObjectElement
和 RenderParagraph
。

正如我們知道的, Flutter 控制元件一般是由 Widget
、Element
和 RenderObeject
三部分組成,而在 RichText
中也是如此,其中:
RenderParagraph
主要是負責文字繪製、佈局相關;RichText
繼承MultiChildRenderObjectWidget
主要是需要通過MultiChildRenderObjectElement
來處理WidgetSpan
中 children 控制元件的插入和管理。
那 WidgetSpan
究竟是如何混入在文字繪製中呢?
在前面的使用中,我們首先是傳入了一個 TextSpan
給 RichText
,並在 TextSpan
的 children
中拼接我們需要的內容,那就從 RichText
開始挖掘其中的原理。

如上程式碼所示,這裡我們首先看 RichText
的入口,可以看到 RichText
開始是有一個 _extractChildren
方法,這個方法主要是將傳入 TextSpan
的 children
裡,所有的 WidgetSpan
通過 visitChildren
方法給遞迴篩選出來,然後傳入給父類 MultiChildRenderObjectWidget
。
為什麼需要這麼做?在 《十六、詳解自定義佈局實戰》 中介紹過,
MultiChildRenderObjectWidget
的 children 最終會通過MultiChildRenderObjectElement
作為橋樑,然後被插入到需要管理和繪製的 child 連結串列結構中,這樣在RenderObject
中方便管理和訪問。
另外我們知道 RichText
傳入的 text
其實是一個 InlineSpan
,而 TextSpan
就是 InlineSpan
的子類,WidgetSpan
也是 InlineSpan
的子類實現,它們的關係如下圖所示:

對於 InlineSpan
系列我們主要關注兩個方法:visitChildren
和 build
方法,它的子類 TextSpan
和 WidgetSpan
都對這兩個方法有自己對應的實現。
void build(ui.ParagraphBuilder builder, { double textScaleFactor = 1.0, List<PlaceholderDimensions> dimensions });
bool visitChildren(InlineSpanVisitor visitor);
複製程式碼

接著看 RenderParagraph
,如上程式碼所示,RichText
中的 text
(InlineSpan
) 會繼續被傳入到 RenderParagraph
中,RenderParagraph
繼承了 RenderBox
並混入的 ContainerRenderObjectMixin
和 RenderBoxContainerDefaultsMixin
等。
混入的物件這部分在內容在 《十六、詳解自定義佈局實戰》 也介紹過,這裡只需要知道通過混入它們,
RenderParagraph
就可以獲得前面通過WidgetSpan
傳入到MultiChildRenderObjectElement
的 children 連結串列,並且佈局計算大小等。

之後 RenderParagraph
中的 text
之後會被放置到 TextPainter
中使用,並且通過 _extractPlaceholderSpans
方法將所有的 PlaceholderSpans
篩選出來。
TextPainter
主要用於實現文字的繪製,這裡我們暫時不多分析,而 _extractPlaceholderSpans
挑選出來的所有 PlaceholderSpans
,其實就是 WidgetSpan
。
WidgetSpan
是通過繼承PlaceholderSpans
從而實現了InlineSpan
,而目前暫時PlaceholderSpans
實現的類只有WidgetSpan
。

挑選出來的 List<PlaceholderSpan>
們會在 RenderParagraph
計算寬高等方法中被用到,比如 computeMaxIntrinsicWidth
方法等,其中主要有 _canComputeIntrinsics
、 _computeChildrenWidthWithMaxIntrinsics
、_layoutText
三個關鍵方法,這三個方法結合處理了 RenderParagraph
中 Span 的尺寸和佈局等。

_canComputeIntrinsics
:_canComputeIntrinsics
主要判斷了PlaceholderSpan
只支援的baseline
配置。

_computeChildrenWidthWithMaxIntrinsics
:_computeChildrenWidthWithMaxIntrinsics
中會通過PlaceholderSpan
去對應得到PlaceholderDimensions
,得到的PlaceholderDimensions
會用於後續如WidgetSpan
的大小繪製資訊。
這個
PlaceholderDimensions
會通過setPlaceholderDimensions
方法設定到TextPainter
裡面, 這樣TextPainter
在layout
的時候,就會將PlaceholderDimensions
賦予WidgetSpan
大小資訊。

_layoutText
:_layoutText
方法會呼叫_textPainter.layout
, 從而執行_text.build
方法,這個方法就會觸發children
中的WidgetSpan
去執行build
。

所以如下程式碼所示,_textPainter.layout
會執行 Span 的 build
方法,將 PlaceholderDimensions
設定到 WidgetSpan
裡面,然後還有通過 _paragraph.getBoxesForPlaceholders()
方法獲取到控制元件繪製需要的 left
、right
等資訊,這些資訊來源是基於上面 text.build
的執行。

_paragraph.getBoxesForPlaceholders() 獲取到的
TextBox
資訊,是基於後面我們介紹在 Span 裡提交的addPlaceholder
方法獲取。
這些資訊會在 setParentData
方法中被設定到 TextParentData
裡,關於 ParentData
及其子類的作用,在《十六、詳解自定義佈局實戰》 同樣有所介紹,這裡就不贅述了,簡單理解就是 WidgetSpan
繪製的時候所需要的 offset
位置資訊會由它們提供。

之後如下程式碼所示, WidgetSpan
的 build
方法被執行,這裡會有一個 placeholderCount
, placeholderCount
預設是從 0 開始,而在執行 addPlaceholder
方法時會通過 _placeholderCount++
自增,這樣下一個 WidgetSpan
就會拿到下一個 PlaceholderDimensions
用於設定大小。
addPlaceholder
之後會執行到 Flutter Engine 中的流程了。

最終 RenderParagrash
的 paint
方法會執行 _textPainter.paint
並把確定了大小和位置的 child 提交繪製。

是不是有點暈,結合下圖所示,總結起來其實就是:
RichText
中傳入TextSpan
, 在TextSpan
的 children 中使用WidgetSpan
,WidgetSpan
裡的Widget
們會轉成MultiChildRenderObjectElement
的children
, 處理後得到一個 child 連結串列結構;- 之後
TextSpan
進入RenderParagrash
,會抽取出對應PlaceholderSpan
(WidgetSpan
),然後通過轉化為PlaceholderDimensions
儲存大小等資訊; - 之後進去
TextPainter
會觸發InlineSpan
的build
方法,從而將前面得到的PlaceholderDimensions
傳遞到WidgetSpan
中; WidgetSpan
中的控制元件資訊通過addPlaceholder
會被傳遞到Paragraph
;- 之後
TextPainter
中通過addPlaceholder
的資訊獲取,呼叫_paragraph.getBoxesForPlaceholders()
獲取去控制元件繪製需要的offset
; - 有了大小和位置,最終文字中插入的控制元件,會在
RenderParagrash
的paint
方法被繪製。

RichText
中插入控制元件的管理巧妙的依託到 MultiChildRenderObjectWidget
中,從而複用了原本控制元件的管理邏輯,之後依託引擎計算位置從而繪製完成。
至此,簡簡單單的 WidgetSpan
的實現原理解析完成~
資源推薦
- Github :github.com/CarGuo
- 開源 Flutter 完整專案:github.com/CarGuo/GSYG…
- 開源 Flutter 多案例學習型專案: github.com/CarGuo/GSYF…
- 開源 Fluttre 實戰電子書專案:github.com/CarGuo/GSYF…
- 開源 React Native 專案:github.com/CarGuo/GSYG…
