1. 程式人生 > >如何低成本實現Flutter富文字,看這一篇就夠了!

如何低成本實現Flutter富文字,看這一篇就夠了!

作者:閒魚技術-玄川

背景

閒魚是國內最早使用Flutter 的團隊,作為一個電商App商品詳情頁是非常重要場景,其中最主要的技術能力是文字混排。

我們面對文字類的需求是複雜而且多變,然而Flutter歷史的幾個版本,Text只能顯示簡單樣式文字,它只有包含一些控制文字樣式顯示的屬性,而通過TextSpan連線實現的RichText也只能顯示多種文字樣式(例如:一個基礎文字片段和一個連結片段),這些遠遠達不到設計需要的能力。被產品和設計慫為啥別人別的平臺能做,Flutter為何做不了,不管,必須支援。

因此,需要開發一個能力更強的文字混排元件就變得迫在眉睫。

富文字的原理

再講文字混批元件設計實現前,先來講講系統RichText的富文字的原理。

  • 建立過程

    建立RichText節點的時候其實會建立以下幾個物件:

    1. 先建立LeafRenderObjectElement例項。
    2. ComponentElement方法當中會呼叫RichText例項的CreateRenderObject方法,生成RenderParagraph 例項。
    3. RenderParagraph 會建立TextPainter 負責其就計算寬高和繪製文字到Canvas 的代理類,同時TextPainter 持有TextSpan 文字結構。

    RenderParagraph例項最後會將自身登記到渲染模組的Dirty Nodes當中去,渲染模組會遍歷Dirty Nodes 將進入RenderParagraph 渲染環節。

  • 渲染過程

    RenderParagraph 方法當中封裝的是將文字繪製到 canvas 上面的邏輯,主要是用了一個叫做 TextPainter 的模組,其呼叫過程遵循RenderObject 呼叫。

    1. PerfromLayout 過程通過呼叫TextPaint的Layout,在期過程中通過TextSpan 結構樹,依次通過AddText 新增各個階段的文字,最後通過Paragraph的Layout 計算文字高度。
    2. 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 控制元件,必需要指定寬高,另外對於文字選擇、自定義文字背景這些都是無法支援,其次對富文字編輯器的支援,可以使其編輯文字時,讓圖片、貨幣格式化等控制元件輸入等。

原文連結

本文為雲棲社群原創內容,未經