原文在這裡。能看原文的推薦看原文。
這不是一次愉悅的旅行,但是我會帶你領略Flutter文字繪製裡從未有過的精彩。第一眼看起來非常的簡單。只不過是幾個字元,對不?但是越往深挖越有難度。
在本文的最後你會學到:
- widget、elements和繪製物件之間的關係
- 在
Text
和RichText
下的深度內容 - 定製自己的文字widget
注意:這是一篇有深度的教程,我假設讀者已經對Flutter的基礎瞭如指掌。當然,如果你非常好奇,一定要看。那麼繼續吧。
複製程式碼
開始
下載初始程式碼。
概覽Flutter Framework
作為一個Flutter開發者,你應該已經對Flutter的stateless和statefule widget頗為熟悉了,但是Flutter裡不只這些。今天我們就來學習一點第三種型別RenderObjectWidget
,以及其他相關的底層類。
下面這幅圖把喊了widget
的全部子類,藍色的將是本文主要關注的。
RenderObjectWidget
是一幅藍圖。它保留了RenderObject
的配置資訊,這個類會檢測碰撞和繪製UI。
這下面的圖是RenderObject
的子類。最常用的是RenderBox
,它定義了螢幕上的用於繪製的長方形區域。RenderParagraph
就是Flutter用來繪製文字的。
很快你就要定製自己的文字繪製widget了!
如你所知,flutter通過把widget組織成樹形來實現佈局。在記憶體中對應的會存在一個繪製樹(render object tree)。但是widget和render object是互相不知道對方的。widget不會生成對應的render object,render object也不知道widget樹什麼時候發生了更改。
這就需要element出場了。對應於widget樹,會生成一個element樹。element會保留widget和render object的引用。element就是widget和render object的中間人一樣。他知道什麼時候生成一個render object,如何把他們放在一個樹形裡,什麼時候更新render objects,什麼時候為子widget建立新的element。
下面的一幅圖說明了Element
子類,每個element都有一個對應的element。
一個有趣的現象,你一直都在直接操作element,但是你並沒有注意到這一點。你知道BuildContext
?這只是Element
的一個暱稱而已。更正式一點的說法是,BuildContext
是Element
的抽象類。
理論準備部分到此結束,現在該動手操作了
深入Text Widget
現在我們要深入程式碼來看看到底widget,element和render object是如何運作的。我們就從Text
widget開始來看看它是如何建立它的render object:RenderParagraph
的。
開啟你的起始專案,執行flutter pub get
來獲取依賴包。執行起來之後你會看到這樣的介面:
在lib/main.dart,滾動到最下面找到TODO:Start your project journey here這一行:
child: Align(
alignment: Alignment.bottomCenter,
child: Text( // TODO: Start your journey here
Strings.travelMongolia,
複製程式碼
widget樹裡包含了一個Align
widget和一個子widget Text
。當你瀏覽完程式碼你形成一個如下圖的認識:
進行如下的步驟:
- Command+click(或者是PC的話Control+click)Text跳轉到這個widget的原始碼裡。主要
Text
是一個無狀態widget。 - 向下滾動到
build
方法。這個方法返回什麼?是一個RichText
widget。Text
只是RichText
的一個偽裝而已。 - Command+click RichText來到它的原始碼部分。主要
RichText
是一個MultiChildRenderObjectWidget
。為什麼是多個child?在Flutter 1.7之前的版本里,它其實叫做LeafRenderObjectWidget
,沒有子節點,但是現在RichText
支援widget spans了。 - 滾動到
creteRenderObject
方法,這裡就是建立RenderParagraph
的地方。 - 在return RenderParagraph那一行打一個斷點。
- 在除錯模式下再次執行程式碼
在Android Studio的除錯裡你會看到如下的內容
你應該也會看到如下的stack呼叫。我在括號裡新增了widget或者element的型別。最後邊的數字是後面說明的編號
我們來一步一步看看RenderParagraph
是如何建立的。
- 點選SingleChildRenderObjectElement.mount。你就在
Align
widget對應的element裡了。在你的layout裡,Text
是Align
的子widget。所以,傳到了updateChild
方法裡的widget.child
是Text
widget。 - 點選Element.updateChild,在一個長長的方法之後,你的
Text
widget,被稱為newWidget
,傳入了inflateWidet
方法。 - 點選Element.inflateWidget。inflate一個widget指的是從這個widget建立一個element。就如你所見Element newChild = newWidget.createElement()。這個時候你還在
Align
element裡,但是你就要單步除錯到你剛剛建立的Text
element的mount
方法裡了。 - 點選ComponentElement.mount。你現在就在
Text
elemnt裡了。Component element(比如StatelessElement
)不會直接建立render object,但是他們會建立其他的element,讓這些elemnt去建立render object。 - 下面就是幾個呼叫棧的方法了。點選ComponentElement.performRebuild。找到**built = build()**那一行。這裡,同學們,就是
Text
widget的build
方法被呼叫的地方。StatelessElement
使用了一個setter給自己新增了一個BuildContext
引數的引用。那個built
變數就是RichText
。 - 點選Element.inflateWidget。這時
newWidget
是一個RichText
,並且它用來建立了MultiChildRenderObjectElement
。你還在Text
element,不過你就要進入RichText
element的mount
方法了。 - 點選RenderObjectElement.mount。你會驚喜的發現widget.createRenderObject(this)。終於,這就是建立
RenderParagraph
的地方。引數this
就是MultiChildRenderObjectElement
。 - 點選RichText.createRenderObject。注意
MultiChildRenderObjectElement
就是BuildContext
。
累了麼?這還只是開始,既然你在一個斷點上了,那就去喝點水休息片刻吧。後面還有很多精彩內容。
Text Render Object
Flutter架構圖,想必你已經看過:
我們之前看到的內容都在Widget層,接下來我們就要進入Rendering,Painting和Foundation層了。即使我們要進入這些底層的內容,其實他們還是很簡單的。因為目前還不需要處理多個樹的情況。
你還在那個斷點上嗎?Command+click RenderParagraph,到他的原始碼看看。
RenderParagraph
是繼承自RenderBox
的。也就是說這個render object是一個方形,並且已經具有了繪製內容的固有的高度和寬度。就render paragraph來說,內容就是文字。- 它還會處理碰撞檢測。
performLayout
和paint
方法也很有趣。
你有沒有注意到RenderParagraph
並沒有處理文字繪製的工作,而是交給了TextPainter
?在類的上方找到**_textPainter**。Command+click TextPainter,我們離開Rendering層,到Painting層來看看。
你會發現什麼呢
- 有一個很重要的
ui.Paragraph
型別的類成員:_paragraph
。ui
是dart:ui
庫裡面的類的通用字首。 layout
方法。你是無法直接初始化Paragraph
類的。你必須要使用一個ParagraphBuilder
的類來初始化它。這需要一個預設的對全部文字有效的樣式。這個樣式可以根據TextSpan
樹裡的樣式來修改。呼叫TextSpan.build()
會給ParagraphBuilder
物件新增樣式。- 你會發現
paint
方法其實非常簡單。TextPainter
把文字都交給了canvas.drawParagraph()。如果進入這個方法的定義,你會發現它其實呼叫了paragraph._paint。
這時候,你已經來到了Flutter的Foundation層。在TextPainter
類裡,Comand+click下面的類:
- ParagraphBuilder: 它新增文字和樣式,但是具體的工作都交給了native層。
- Paragraph:並沒有什麼值得看的。所有的都交給native層處理了。
現在可以停止app的執行了。剛剛看到的都可以總結到一幅圖了裡面:
繼續深入Flutter的文字引擎
這裡,你就要離開Dart
的底盤進入native文字繪製引擎了。你不能在command+click了,但是程式碼都在githubg的Flutter程式碼庫裡。文字引擎叫做LibTxt。
我們不會在這部分耗費太多時間,不夠如果你喜歡。可以去src目錄看。現在我們來看看叫做Paragraph.dart
的native類,它把繪製工作都交給了txt/paragraph_text.cc, 點選連結。
當你有空的時候你可以看看Layout
和Paint
方法,但是現在我們來看看這些引入的內容:
#include "flutter/fml/logging.h"
#include "font_collection.h"
#include "font_skia.h"
#include "minikin/FontLanguageListCache.h"
#include "minikin/GraphemeBreak.h"
#include "minikin/HbFontCache.h"
#include "minikin/LayoutUtils.h"
#include "minikin/LineBreaker.h"
#include "minikin/MinikinFont.h"
#include "third_party/skia/include/core/SkCanvas.h"
#include "third_party/skia/include/core/SkFont.h"
#include "third_party/skia/include/core/SkFontMetrics.h"
#include "third_party/skia/include/core/SkMaskFilter.h"
#include "third_party/skia/include/core/SkPaint.h"
#include "third_party/skia/include/core/SkTextBlob.h"
#include "third_party/skia/include/core/SkTypeface.h"
#include "third_party/skia/include/effects/SkDashPathEffect.h"
#include "third_party/skia/include/effects/SkDiscretePathEffect.h"
#include "unicode/ubidi.h"
#include "unicode/utf16.h"
複製程式碼
從這裡你會看到LibTxt是如何處理文字的。它是基於很多的其他的庫的,這裡有一些有趣的:
你看的越多就越發現正確渲染文字需要多少的東西。我都還沒有介紹到行距,字形集和雙向文字的問題。
我們已經學習的足夠深入了,現在我們要把這些內容用起來了。
建立一個自定義文字widget
我們要做一些也許你之前從來沒有做過的事情。你要自定義一個文字widget了。不是像往常一樣的組合起來一些widget,而是建立render object,由它來使用Flutter底層api來繪製文字。
Flutter本來是不允許開發人員來自定義文字佈局的,但是Flutter很負責任的做出了修改。
現在我們的app看起來還不錯。但是,如果能支援蒙語就更好了。傳統的蒙語非常的不同。它是從上到下的書寫的。Flutter的標準文字widget僅支援水平的書寫方式,所以我們要定製一個可以從上到下書寫,從左到右排列的widget。
自定義Render Object
為了幫助各位同學理解底層的文字佈局,我把widge、render object和幫助類偶放進了初始專案中。
為了方便你以後定製自己的render object,我來解釋一下我都做了什麼。
- vertical_text.dart:這是
VerticalText
widget。我從RichText
的程式碼開始寫的。我刪掉了基本上所有的程式碼,把它改成了LeafRenderObjectWidget
,它沒子節點。它會建立RenderVerticalText
物件。 - render_vertical_text.dart: 寫這個的時候,把
RenderParagraph
刪掉一部分,之後加入了寬度和高度的測量。它使用了VerticalTextPainter
而不是TextPainter
。 - vertical_text_painter.dart:我是從
TextPainter
開始的,然後把不需要的內容全部刪除了。我也交換了寬度和高度的計算,刪掉了TextSpan
支援的複雜的文字樣式部分。 - vertical_paragraph_constraint.dart:我使用了
height
來做為約束,代替了之前的width
。 - vertical_paragraph_builder.dart: 這個部分是從
ParagraphBuilder
開始。刪除了一切不別要的程式碼。新增了預設的樣式並在build
方法裡返回VerticalParagraph
,而不是之前的Paragraph
。 - line_breaker.dart:這個是用來代替Minikin的
LineBreaker
類的。這個類沒有在dart裡面暴露出來。
計算和測量文字
文字都需要自動換行。要做到這一點你需要找字串裡的一個合適的地方來分割成行。就如前文所述,在寫作本文的時候Flutter並沒有暴露出Minikin/ICU的LineBreake
類,但是按照一個空格或者一個詞來風格也是一個可接受的方案。
比如這個app歡迎語句:
ᠤᠷᠭᠡᠨ ᠠᠭᠤᠳᠠᠮ ᠲᠠᠯᠠ ᠨᠤᠲᠤᠭ ᠲᠤ ᠮᠢᠨᠢ ᠬᠦᠷᠦᠯᠴᠡᠨ ᠢᠷᠡᠭᠡᠷᠡᠢ
複製程式碼
可行的分割點:
我把可分割的每個子串叫做一個run。後面會用TextRun
來表示每個run。
在lib/model目錄,建立一個檔案text_run.dart,把下面的檔案貼上進去:
import 'dart:ui' as ui show Paragraph;
class TextRun {
TextRun(this.start, this.end, this.paragraph);
// 1
int start;
int end;
// 2
ui.Paragraph paragraph;
}
複製程式碼
解釋一下上面的程式碼:
- 這些是每個子串run的索引。
start
索引是包含關係,end
是不包含的。如:[start, end)。 - 你會為每個子串run建立一個“paragraph”,這樣你就可以獲得測量到的size。
在dartui/vertical_paragraph.dart裡把下面的程式碼新增到VerticalParagraph
,記住import TextRun
。
// 1
List<TextRun> _runs = [];
void _addRun(int start, int end) {
// 2
final builder = ui.ParagraphBuilder(_paragraphStyle)
..pushStyle(_textStyle)
..addText(_text.substring(start, end));
final paragraph = builder.build();
// 3
paragraph.layout(ui.ParagraphConstraints(width: double.infinity));
final run = TextRun(start, end, paragraph);
_runs.add(run);
}
複製程式碼
一下內容需要注意:
- 你會分別儲存字串裡的每個單詞
- 在建立paragraph之前新增文字和樣式
- 你必須在獲得測量資料之前呼叫
layout
方法。我把width
賦值給infinity
來確保這子串run只有一行。
在**_calculageRuns**方法裡新增如下的程式碼:
// 1
if (_runs.isNotEmpty) {
return;
}
// 2
final breaker = LineBreaker();
breaker.text = _text;
final int breakCount = breaker.computeBreaks();
final breaks = breaker.breaks;
// 3
int start = 0;
int end;
for (int i = 0; i < breakCount; i++) {
end = breaks[i];
_addRun(start, end);
start = end;
}
// 4
end = _text.length;
if (start < end) {
_addRun(start, end);
}
複製程式碼
解釋如下:
- 不需要對子串run多次計算
- 這是我在
util
目錄新增的換行類。這些breaks
變數是一列換行的索引的位置 - 從文字里面的每個幻皇建立子串的run
- 處理字串裡的最後一個詞
現在的程式碼還不足以在螢幕上顯示出什麼東西。但是在**_layout**方法後面新增一個print語句:
print("There are ${_runs.length} runs.");
複製程式碼
執行這個app。你應該在console裡面看到列印出來的資訊:
There are 8 runs.
複製程式碼
這就很接近了
把子串run放在不同行
現在要看看每行可以放幾個子串run。假設最長的行可以達到下圖綠色的部分:
如上圖,前三個子串run可以放進去,但是第四個就要放在一個新行裡了。
要程式設計的方式達到這個效果你需要知道每個子串run有多長。辛虧這些都存在TextRun
的paragraph
屬性裡了。
這時需要一個類來存放每行的資料。在lib/model目錄下建立一個檔案line_info.dart。把下面的程式碼貼上進去:
import 'dart:ui';
class LineInfo {
LineInfo(this.textRunStart, this.textRunEnd, this.bounds);
// 1
int textRunStart;
int textRunEnd;
// 2
Rect bounds;
}
複製程式碼
在dartui/vertical_paragraph.dart,VerticalParagraph類,新增下面的程式碼。記住import LineInfo
:
// 1
List<LineInfo> _lines = [];
// 2
void _addLine(int start, int end, double width, double height) {
final bounds = Rect.fromLTRB(0, 0, width, height);
final LineInfo lineInfo = LineInfo(start, end, bounds);
_lines.add(lineInfo);
}
複製程式碼
解釋:
- 這個列表的長度就是行數
- 在這個時候你並沒有旋轉任何字串,所以
width
和height
還都是指水平方向的
之後,在**_calculateLineBreaks**裡新增如下程式碼:
// 1
if (_runs.isEmpty) {
return;
}
// 2
if (_lines.isNotEmpty) {
_lines.clear();
}
// 3
int start = 0;
int end;
double lineWidth = 0;
double lineHeight = 0;
for (int i = 0; i < _runs.length; i++) {
end = i;
final run = _runs[i];
// 4
final runWidth = run.paragraph.maxIntrinsicWidth;
final runHeight = run.paragraph.height;
// 5
if (lineWidth + runWidth > maxLineLength) {
_addLine(start, end, lineWidth, lineHeight);
start = end;
lineWidth = runWidth;
lineHeight = runHeight;
} else {
lineWidth += runWidth;
// 6
lineHeight = math.max(lineHeight, run.paragraph.height);
}
}
// 7
end = _runs.length;
if (start < end) {
_addLine(start, end, lineWidth, lineHeight);
}
複製程式碼
解釋如下:
- 這個方法必須在子串run計算之後執行
- 在不同的約束下重新佈局這些行是OK的
- 迴圈每個子串run,檢查測量資料
Paragraph
也有width
引數,但是這是約束的寬度,不是測量寬度。因為你把double.infinity
作為約束,寬度就是無限的。使用maxIntrinsicWidth
或者longestLine
會獲得子串run的寬度。更多看這裡。- 找到寬度的和。如果超出了最大值,那麼開始一個新行
- 當前高度總是一樣的,但是在之後你給每個子串run用了不同的樣式,取最大值可以適用於所有子串run。
- 把最後一個子串run作為最後一行
在**_layout**方法的最後加一個print語句看看到此為止的程式碼是否可以正確執行:
print("There are ${_lines.length} lines.");
複製程式碼
來一個hot restart(或者直接重新執行)。你會看到:
There are 3 lines.
複製程式碼
這就是你期望的。因為在main.dart裡,VerticalText
widget有一個300邏輯畫素的約束,差不多也就是下圖裡綠色線的長度:
設定size
系統想要知道widget的size,但是之前你沒有足夠的資料。現在已經測量了這些行,你可以計算size了。
在VerticalParagraph類的**——calclateWidth**方法裡新增如下程式碼:
double sum = 0;
for (LineInfo line in _lines) {
sum += line.bounds.height;
}
_width = sum;
複製程式碼
為什麼我說新增高度來獲取寬度。因為,width
是你給外界的一個值。外界使用者看到的是豎排的行。height
值是你用在內部的。
這個高度是在有足夠高度的時候實際可以獲得高度值。在**_calculateInstrinsicHeight**方法裡新增如下程式碼:
double sum = 0;
double maxRunWidth = 0;
for (TextRun run in _runs) {
final width = run.paragraph.maxIntrinsicWidth;
maxRunWidth = math.max(width, maxRunWidth);
sum += width;
}
// 1
_minIntrinsicHeight = maxRunWidth;
// 2
_maxIntrinsicHeight = sum;
複製程式碼
解釋如下:
- 之前,寬度和高度值因為旋轉的關係混在一起了。你不希望任何的一個單詞被剪掉,所以widget的最小高度也要保證最長的行可以完全顯示出來。
- 如果這個widget把所有的內容都顯示在一個最長的豎行裡,程式碼看起來是這樣的:
print("width=$width height=$height");
print("min=$minIntrinsicHeight max=$maxIntrinsicHeight");
複製程式碼
再次執行程式碼你會看到如下的輸出
width=123.0 height=300.0
min=126.1953125 max=722.234375
複製程式碼
豎排的時候的最小和最大值基本上是這樣的:
在畫布上繪製文字
就快完事兒了。剩下的就是把子串run都繪製出來了。拷貝如下程式碼並放進draw方法裡:
canvas.save();
// 1
canvas.translate(offset.dx, offset.dy);
// 2
canvas.rotate(math.pi / 2);
for (LineInfo line in _lines) {
// 3
canvas.translate(0, -line.bounds.height);
// 4
double dx = 0;
for (int i = line.textRunStart; i < line.textRunEnd; i++) {
// 5
canvas.drawParagraph(_runs[i].paragraph, Offset(dx, 0));
dx += _runs[i].paragraph.longestLine;
}
}
canvas.restore();
複製程式碼
解釋如下:
- 移動到開始的位置
- 把畫布旋轉90度。以前的top現在是right。
- 移動到行開始的地方。
y
值都是負的,這樣就會把每行都往上移動,也就是在旋轉之後的畫布上往右移動了 - 每次話一個單詞(子串run)
- offset就是每個單詞(子串run)的開始位置
下圖顯示了旋轉前後的對比:
這次執行app。
驚豔的效果躍然螢幕上。
擴充套件
如果你不願就此聽不的話。
可以修改的部分
- 處理新行的字元
- 讓子串支援
TextSpan
樹,來實現子串的樣式。也就是開發一個VerticalRichText
。 - 新增碰撞檢測semantics
- 支援Emoji和cjk 字元。讓他們也可以在豎排的時候正確的顯示
- 如何實現一個豎排的
TextField
,支援文字的選擇和閃爍的游標
我準備在後面支援這些特性。你可以在這裡來檢視進度或者參與開發。
如下是一些我找到的特別好的文章: