原文在这里。能看原文的推荐看原文。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
,支持文本的选择和闪烁的光标我准备在后面支持这些特性。你能够在这里来查看进度或者参与开发。
以下是一些我找到的特别好的文章: