如何低成本實現Flutter富文字,看這一篇就夠了!
作者:閒魚技術-玄川
背景
閒魚是國內最早使用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 控制元件,必需要指定寬高,另外對於文字選擇、自定義文字背景這些都是無法支援,其次對富文字編輯器的支援,可以使其編輯文字時,讓圖片、貨幣格式化等控制元件輸入等。
原文連結
本文為雲棲社群原創內容,未經