原文在這裏。能看原文的推薦看原文。html
這不是一次愉悅的旅行,可是我會帶你領略Flutter文本繪製裏從未有過的精彩。第一眼看起來很是的簡單。只不過是幾個字符,對不?可是越往深挖越有難度。android
在本文的最後你會學到:git
Text
和RichText
下的深度內容注意:這是一篇有深度的教程,我假設讀者已經對Flutter的基礎瞭如指掌。固然,若是你很是好奇,必定要看。那麼繼續吧。
下載初始代碼。github
做爲一個Flutter開發者,你應該已經對Flutter的stateless和statefule widget頗爲熟悉了,可是Flutter裏不僅這些。今天咱們就來學習一點第三種類型RenderObjectWidget
,以及其餘相關的底層類。編程
下面這幅圖把喊了widget
的所有子類,藍色的將是本文主要關注的。canvas
RenderObjectWidget
是一幅藍圖。它保留了RenderObject
的配置信息,這個類會檢測碰撞和繪製UI。api
這下面的圖是RenderObject
的子類。最經常使用的是RenderBox
,它定義了屏幕上的用於繪製的長方形區域。RenderParagraph
就是Flutter用來繪製文本的。架構
很快你就要定製本身的文本繪製widget了!app
如你所知,flutter經過把widget組織成樹形來實現佈局。在內存中對應的會存在一個繪製樹(render object tree)。可是widget和render object是互相不知道對方的。widget不會生成對應的render object,render object也不知道widget樹何時發生了更改。less
這就須要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
的抽象類。
理論準備部分到此結束,如今該動手操做了
如今咱們要深刻代碼來看看到底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
。當你瀏覽完代碼你造成一個以下圖的認識:
進行以下的步驟:
Text
是一個無狀態widget。build
方法。這個方法返回什麼?是一個RichText
widget。Text
只是RichText
的一個假裝而已。RichText
是一個MultiChildRenderObjectWidget
。爲何是多個child?在Flutter 1.7以前的版本里,它其實叫作LeafRenderObjectWidget
,沒有子節點,可是如今RichText
支持widget spans了。creteRenderObject
方法,這裏就是建立RenderParagraph
的地方。在Android Studio的調試裏你會看到以下的內容
你應該也會看到以下的stack調用。我在括號裏添加了widget或者element的類型。最後邊的數字是後面說明的編號
咱們來一步一步看看RenderParagraph
是如何建立的。
Align
widget對應的element裏了。在你的layout裏,Text
是Align
的子widget。因此,傳到了updateChild
方法裏的widget.child
是Text
widget。Text
widget,被稱爲newWidget
,傳入了inflateWidet
方法。Align
element裏,可是你就要單步調試到你剛剛建立的Text
element的mount
方法裏了。Text
elemnt裏了。Component element(好比StatelessElement
)不會直接建立render object,可是他們會建立其餘的element,讓這些elemnt去建立render object。Text
widget的build
方法被調用的地方。StatelessElement
使用了一個setter給本身添加了一個BuildContext
參數的引用。那個built
變量就是RichText
。newWidget
是一個RichText
,而且它用來建立了MultiChildRenderObjectElement
。你還在Text
element,不過你就要進入RichText
element的mount
方法了。RenderParagraph
的地方。參數this
就是MultiChildRenderObjectElement
。MultiChildRenderObjectElement
就是BuildContext
。累了麼?這還只是開始,既然你在一個斷點上了,那就去喝點水休息片刻吧。後面還有不少精彩內容。
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下面的類:
如今能夠中止app的運行了。剛剛看到的均可以總結到一幅圖了裏面:
這裏,你就要離開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,而是建立render object,由它來使用Flutter底層api來繪製文本。
Flutter原本是不容許開發人員來自定義文本佈局的,可是Flutter很負責任的作出了修改。
如今咱們的app看起來還不錯。可是,若是能支持蒙語就更好了。傳統的蒙語很是的不一樣。它是從上到下的書寫的。Flutter的標準文本widget僅支持水平的書寫方式,因此咱們要定製一個能夠從上到下書寫,從左到右排列的widget。
爲了幫助各位同窗理解底層的文本佈局,我把widge、render object和幫助類偶放進了初始項目中。
爲了方便你之後定製本身的render object,我來解釋一下我都作了什麼。
VerticalText
widget。我從RichText
的代碼開始寫的。我刪掉了基本上全部的代碼,把它改爲了LeafRenderObjectWidget
,它沒子節點。它會建立RenderVerticalText
對象。RenderParagraph
刪掉一部分,以後加入了寬度和高度的測量。它使用了VerticalTextPainter
而不是TextPainter
。TextPainter
開始的,而後把不須要的內容所有刪除了。我也交換了寬度和高度的計算,刪掉了TextSpan
支持的複雜的文本樣式部分。height
來作爲約束,代替了以前的width
。ParagraphBuilder
開始。刪除了一切不別要的代碼。添加了默認的樣式並在build
方法裏返回VerticalParagraph
,而不是以前的Paragraph
。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; }
解釋一下上面的代碼:
start
索引是包含關係,end
是不包含的。如:[start, end)。在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); }
一下內容須要注意:
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); }
解釋以下:
util
目錄添加的換行類。這些breaks
變量是一列換行的索引的位置如今的代碼還不足以在屏幕上顯示出什麼東西。可是在_layout方法後面添加一個print語句:
print("There are ${_runs.length} runs.");
運行這個app。你應該在console裏面看到打印出來的信息:
There are 8 runs.
這就很接近了
如今要看看每行能夠放幾個子串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); }
解釋以下:
Paragraph
也有width
參數,可是這是約束的寬度,不是測量寬度。由於你把double.infinity
做爲約束,寬度就是無限的。使用maxIntrinsicWidth
或者longestLine
會得到子串run的寬度。更多看這裏。在_layout方法的最後加一個print語句看看到此爲止的代碼是否能夠正確運行:
print("There are ${_lines.length} lines.");
來一個hot restart(或者直接從新運行)。你會看到:
There are 3 lines.
這就是你指望的。由於在main.dart裏,VerticalText
widget有一個300邏輯像素的約束,差很少也就是下圖裏綠色線的長度:
系統想要知道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;
解釋以下:
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();
解釋以下:
y
值都是負的,這樣就會把每行都往上移動,也就是在旋轉以後的畫布上往右移動了下圖顯示了旋轉先後的對比:
此次運行app。
驚豔的效果躍然屏幕上。
若是你不肯就此聽不的話。
TextSpan
樹,來實現子串的樣式。也就是開發一個VerticalRichText
。TextField
,支持文本的選擇和閃爍的光標我準備在後面支持這些特性。你能夠在這裏來查看進度或者參與開發。
以下是一些我找到的特別好的文章: