说明
本文源码基于flutter 1.7.8
相对于旧版本而言,text进行了一定的改动,已经支持了图文混编。
之前看一些大佬魔改源码的时候,内部实现是添加了一个ImageSpan,在ImageSpan内部绘制图片。而新版本改动有些大,新增了一个WidgetSpan和TextSpan共同继承自InlineSpan。
对比而言,官方这个策略更优一些,毕竟ImageSpan只能针对图片文件,而WidgetSpan针对的是一个Widget控件,内部的实现也全权交给这个widget自己。
使用
简单列举一下怎么使用的
class TextWithWidgetDemo extends StatelessWidget {
@override
Widget build(BuildContext context) {
return RichText(
text: TextSpan(
text: "你好",
children: [
WidgetSpan(child: Icon(Icons.ac_unit)),
WidgetSpan(child: FlatButton(onPressed: (){}, child: Text("点我"))),
TextSpan(text: "这是什么啦啦啦"),
TextSpan(
text: "层级复杂的",
children: [
TextSpan(
text: "点我",
style: TextStyle(color: Colors.red, fontSize: 16),
recognizer: TapGestureRecognizer()..onTap = (){
print("点击事件");
}
),
],
),
],
style: Theme.of(context).textTheme.display1,
),
);
}
}
分析问题
为了防止一深入源码里就迷失了,我们带着下面2个问题去阅读源码
- text是如何实现图文混编的
- text是如何处理超出数据长度
开始分析
因为text内部使用的是RichText富文本控件,所以我们直接从RichText开始分析:
//RichText继承MultiChildRenderObjectWidget,内部可以有多个子控件
class RichText extends MultiChildRenderObjectWidget {
RichText({
Key key,
@required this.text,
this.textAlign = TextAlign.start,
this.textDirection,
this.softWrap = true,
this.overflow = TextOverflow.clip,
this.textScaleFactor = 1.0,
this.maxLines,
this.locale,
this.strutStyle,
this.textWidthBasis = TextWidthBasis.parent,
}) : ...
//这一步记住了,已经将属于WidgetSpan的widget列表传递给了父类
super(key: key, children: _extractChildren(text));
//主要目的是遍历Span,然后取出WidgetSpan内子控件列表
static List _extractChildren(InlineSpan span) {
final List result = [];
//调用InlineSpan的visitChildren方法,InlineSpan是一个抽象类,其中的这个方法是抽象方法
span.visitChildren((InlineSpan span) {
//判断Span是否为WidgetSpan,添加到列表中
if (span is WidgetSpan) {
result.add(span.child);
}
return true;
});
return result;
}
}
新版本将RichText的父类改为MultiChildRenderObjectWidget,老版本是一个LeafRenderObjectWidget(想看旧版本的RichText,可以看下我之前的文章:https://www.jianshu.com/p/1012d79551bc)
也就是说RichText将是一个布局控件,内部可以摆放多个子控件
InlineSpan是一个抽象类,将会有WidgetSpan和TextSpan来实现visitChildren方法
typedef InlineSpanVisitor = bool Function(InlineSpan span);
//--- TextSpan ---
@override
bool visitChildren(InlineSpanVisitor visitor) {
//text就是自己传递的String字符串
if (text != null) {
//调用方法,如果返回false将不调用下面的部分
if (!visitor(this))
return false;
}
//上面的方法,visitor默认返回true,所以一定会执行到这
if (children != null) {
for (InlineSpan child in children) {
//遍历下去
if (!child.visitChildren(visitor))
return false;
}
}
return true;
}
//--- WidgetSpan ---
@override
bool visitChildren(InlineSpanVisitor visitor) {
//直接返回visitor,并将自己传递出去
return visitor(this);
}
到这里为止,RichText已经收集了所有的WidgetSpan中的子控件,用我们开始的例子来说的话,就是已经有了Icon和FlatButton
继续往下阅读
//RichText类
@override
RenderParagraph createRenderObject(BuildContext context) {
assert(textDirection != null || debugCheckHasDirectionality(context));
return RenderParagraph(text,
textAlign: textAlign,
textDirection: textDirection ?? Directionality.of(context),
softWrap: softWrap,
overflow: overflow,
textScaleFactor: textScaleFactor,
maxLines: maxLines,
strutStyle: strutStyle,
textWidthBasis: textWidthBasis,
locale: locale ?? Localizations.localeOf(context, nullOk: true),
);
}
这里创建了一个RenderParagraph,这个是绘制的核心,这里将会进行layout、paint和事件分发操作
1. layout 过程
我们逐一分析,先看layout过程
class RenderParagraph extends RenderBox
with ContainerRenderObjectMixin,
RenderBoxContainerDefaultsMixin {
@override
void performLayout() {
//摆放子控件的位置
_layoutChildren(constraints);
//摆放文本的位置
_layoutTextWithConstraints(constraints);
_setParentData();
//后面那部分主要是一些异常情况和越界裁剪处理
...
}
}
1.1 摆放子控件的流程
void _layoutChildren(BoxConstraints constraints) {
if (childCount == 0) {
return;
}
RenderBox child = firstChild;
final List placeholderDimensions = List(childCount);
int childIndex = 0;
while (child != null) {
//让child进行摆放一下,主要是为了获取到child的size
child.layout(
BoxConstraints(
maxWidth: constraints.maxWidth,
),
parentUsesSize: true
);
double baselineOffset;
switch (_placeholderSpans[childIndex].alignment) {
case ui.PlaceholderAlignment.baseline: {
baselineOffset = child.getDistanceToBaseline(_placeholderSpans[childIndex].baseline);
break;
}
default: {
baselineOffset = null;
break;
}
}
//封装一下,_placeholderSpans是在构造函数中,和之前获取WidgetSpan一样,获取的是一个PlaceholderSpan列表,这里包含了对齐方式
placeholderDimensions[childIndex] = PlaceholderDimensions(
size: child.size,
alignment: _placeholderSpans[childIndex].alignment,
baseline: _placeholderSpans[childIndex].baseline,
baselineOffset: baselineOffset,
);
child = childAfter(child);
childIndex += 1;
}
//设置值,将_textPainter的 _placeholderDimensions 赋值(后面会用到这个)
_textPainter.setPlaceholderDimensions(placeholderDimensions);
}
关于firstChild是怎么获取到的,这个过程发送在build的过程,简单说明一下:
布局控件的mount方法 --(遍历子控件) -> 子孩子的mount方法 -> RenderObjectElement的attachRenderObject - > insertChildRenderObject -> ContainerRenderObjectMixin的insert -> _insertIntoChildList -> _firstChild赋值
void _insertIntoChildList(ChildType child, { ChildType after }) {
final ParentDataType childParentData = child.parentData;
_childCount += 1;
if (after == null) {
// 第1个子孩子
childParentData.nextSibling = _firstChild;
if (_firstChild != null) {
final ParentDataType _firstChildParentData = _firstChild.parentData;
_firstChildParentData.previousSibling = child;
}
//赋值
_firstChild = child;
_lastChild ??= child;
} else {
final ParentDataType afterParentData = after.parentData;
if (afterParentData.nextSibling == null) {
// 第2个子孩子
childParentData.previousSibling = after;
afterParentData.nextSibling = child;
_lastChild = child;
} else {
//子孩子大于或等于3个则构建链表
childParentData.nextSibling = afterParentData.nextSibling;
childParentData.previousSibling = after;
final ParentDataType childPreviousSiblingParentData = childParentData.previousSibling.parentData;
final ParentDataType childNextSiblingParentData = childParentData.nextSibling.parentData;
childPreviousSiblingParentData.nextSibling = child;
childNextSiblingParentData.previousSibling = child;
assert(afterParentData.nextSibling == child);
}
}
}
1.2 摆放文本的位置流程
void _layoutText({ double minWidth = 0.0, double maxWidth = double.infinity }) {
final bool widthMatters = softWrap || overflow == TextOverflow.ellipsis;
_textPainter.layout(minWidth: minWidth, maxWidth: widthMatters ? maxWidth : double.infinity);
}
void _layoutTextWithConstraints(BoxConstraints constraints) {
_layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
}
即调用_textPainter.layout
//--- TextPainter类 ---
void layout({ double minWidth = 0.0, double maxWidth = double.infinity }) {
if (!_needsLayout && minWidth == _lastMinWidth && maxWidth == _lastMaxWidth)
return;
_needsLayout = false;
if (_paragraph == null) {
final ui.ParagraphBuilder builder = ui.ParagraphBuilder(_createParagraphStyle());
//这个是关键点,核心部分,用了我们上一步传递的_placeholderDimensions
_text.build(builder, textScaleFactor: textScaleFactor, dimensions: _placeholderDimensions);
_inlinePlaceholderScales = builder.placeholderScales;
_paragraph = builder.build();
}
_lastMinWidth = minWidth;
_lastMaxWidth = maxWidth;
//进行一次摆放
_paragraph.layout(ui.ParagraphConstraints(width: maxWidth));
if (minWidth != maxWidth) {
final double newWidth = maxIntrinsicWidth.clamp(minWidth, maxWidth);
if (newWidth != width) {
_paragraph.layout(ui.ParagraphConstraints(width: newWidth));
}
}
//获取占位符的位置坐标盒子列表
_inlinePlaceholderBoxes = _paragraph.getBoxesForPlaceholders();
}
我之前的文章介绍过使用Paragraph可以绘制文本,这里TextPainter其实就是对Paragraph的一层封装
_text是什么呢?这个就是RenderParagraph构造函数中传递的InlineSpan text,也就是我们最原始使用的那个InlineSpan,也就是RichText的text值
//--- TextSpan ---
@override
void build(ui.ParagraphBuilder builder, { double textScaleFactor = 1.0, List dimensions }) {
final bool hasStyle = style != null;
if (hasStyle)
builder.pushStyle(style.getTextStyle(textScaleFactor: textScaleFactor));
if (text != null)
//添加文本String数据,加了之后就能绘制文本
builder.addText(text);
if (children != null) {
for (InlineSpan child in children) {
//遍历children下去,重复同样的操作
child.build(builder, textScaleFactor: textScaleFactor, dimensions: dimensions);
}
}
if (hasStyle)
builder.pop();
}
//--- WidgetSpan ---
@override
void build(ui.ParagraphBuilder builder, { double textScaleFactor = 1.0, @required List dimensions }) {
final bool hasStyle = style != null;
if (hasStyle) {
builder.pushStyle(style.getTextStyle(textScaleFactor: textScaleFactor));
}
//使用了我们自己摆放的控件信息,包含了此控件的大小、对齐方式和基线
final PlaceholderDimensions currentDimensions = dimensions[builder.placeholderCount];
//建立一个控件大小的占位区,占着位置
builder.addPlaceholder(
currentDimensions.size.width,
currentDimensions.size.height,
alignment,
scale: textScaleFactor,
baseline: currentDimensions.baseline,
baselineOffset: currentDimensions.baselineOffset,
);
if (hasStyle) {
builder.pop();
}
}
到了这里,控件使用同等大小的占位符给代替了,文本还是照常摆放
2. paint过程
@override
void paint(PaintingContext context, Offset offset) {
_layoutTextWithConstraints(constraints);
if (_needsClipping) {
final Rect bounds = offset & size;
if (_overflowShader != null) {
context.canvas.saveLayer(bounds, Paint());
} else {
context.canvas.save();
}
context.canvas.clipRect(bounds);
}
//绘制文本
_textPainter.paint(context.canvas, offset);
//绘制控件
RenderBox child = firstChild;
int childIndex = 0;
while (child != null) {
assert(childIndex < _textPainter.inlinePlaceholderBoxes.length);
final TextParentData textParentData = child.parentData;
final double scale = textParentData.scale;
//让控件位移之前layout计算的距离,也就是移动到占位符的区域
context.pushTransform(
needsCompositing,
offset + textParentData.offset,
Matrix4.diagonal3Values(scale, scale, scale),
(PaintingContext context, Offset offset) {
context.paintChild(
child,
offset,
);
},
);
child = childAfter(child);
childIndex += 1;
}
if (_needsClipping) {
if (_overflowShader != null) {
context.canvas.translate(offset.dx, offset.dy);
final Paint paint = Paint()
..blendMode = BlendMode.modulate
..shader = _overflowShader;
context.canvas.drawRect(Offset.zero & size, paint);
}
context.canvas.restore();
}
}
至此,控件是怎么绘制上去的:其实就是先占位,然后真实的控件再平移一段距离在绘制到这个点(至于是画布平移还是矩阵变换,感兴趣的可以自己去看看源码,这里就不深入了)
3. 事件分发
添加了子控件,子控件的事件需要自己去处理,文本的事件则自己处理(还是原来的处理方式)
@override
bool hitTestChildren(BoxHitTestResult result, { Offset position }) {
RenderBox child = firstChild;
while (child != null) {
final TextParentData textParentData = child.parentData;
//配套对应,将子控件的触摸反馈点也进行平移
final Matrix4 transform = Matrix4.translationValues(textParentData.offset.dx, textParentData.offset.dy, 0.0)
..scale(textParentData.scale, textParentData.scale, textParentData.scale);
final bool isHit = result.addWithPaintTransform(
transform: transform,
position: position,
hitTest: (BoxHitTestResult result, Offset transformed) {
return child.hitTest(result, position: transformed);
},
);
//如果点击事件发生在平移的widget控件上,则其自己处理事件,不调用自己的handleEvent
if (isHit) {
return true;
}
child = childAfter(child);
}
//默认情况下自己处理事件,调用自己的handleEvent
return false;
}
//当hitTestChildren返回false时就会调用该方法
@override
bool hitTestSelf(Offset position) => true;
看看hitTestChildren和hitTestSelf何时被调用
//--- proxy_box ---
@override
bool hitTest(BoxHitTestResult result, { Offset position }) {
bool hitTarget = false;
if (size.contains(position)) {
//因为hitTestSelf默认为true,也就是说无论如何都会去调用handleEvent
hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
if (hitTarget || behavior == HitTestBehavior.translucent)
//将自己加入列表中,主要起作用的是handleEvent
result.add(BoxHitTestEntry(this, position));
}
return hitTarget;
}
然后对文本事件进行事件分配
@override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (event is! PointerDownEvent)
return;
_layoutTextWithConstraints(constraints);
//获取点击点的本地坐标(不是绝对坐标,这个坐标是相对父控件而言的)
final Offset offset = entry.localPosition;
//获得_textPainter上点击位置对于的索引坐标,占位符占一个单位
final TextPosition position = _textPainter.getPositionForOffset(offset);
//获取到坐标上对应的一个TextSpan
final TextSpan span = _textPainter.text.getSpanForPosition(position);
//如果存在recognizer,则使其加入到事件分发中
span?.recognizer?.addPointer(event);
}
对事件分发还不太了解的可以去看看我之前写的事件分发(https://www.jianshu.com/p/1012d79551bc)和GestureDetector分析(https://www.jianshu.com/p/dc4853c33562)
接下来第二个问题,我文本超出长度了,为什么可以以...结束
enum TextOverflow {
/// 裁剪超出的部分
clip,
///超出的部分设置为透明
fade,
/// 使用...结束
ellipsis,
/// 超出的部分仍让它显示
visible,
}
在构造函数中,可以看到赋值
_textPainter = TextPainter(
text: text,
textAlign: textAlign,
textDirection: textDirection,
textScaleFactor: textScaleFactor,
maxLines: maxLines,
//是否绘制...
ellipsis: overflow == TextOverflow.ellipsis ? _kEllipsis : null,
locale: locale,
strutStyle: strutStyle,
textWidthBasis: textWidthBasis,
)
这个值传递到TextPainter中被使用
ui.ParagraphStyle _createParagraphStyle([ TextDirection defaultTextDirection ]) {
return _text.style?.getParagraphStyle(
textAlign: textAlign,
textDirection: textDirection ?? defaultTextDirection,
textScaleFactor: textScaleFactor,
maxLines: _maxLines,
// 使用
ellipsis: _ellipsis,
locale: _locale,
strutStyle: _strutStyle,
) ?? ui.ParagraphStyle(
textAlign: textAlign,
textDirection: textDirection ?? defaultTextDirection,
//设置最大行数
maxLines: maxLines,
ellipsis: ellipsis,
locale: locale,
);
}
从这里也可以得出,是否越界其实是由Paragraph说了算的
//这个决定一行可以画多宽
_paragraph.layout(ui.ParagraphConstraints(width: newWidth));
到这里也算简单的分析完了,可能会有疑惑为什么不再分析Paragraph,后面都是native代码了,也就是调用c中的skia库来绘制
别着急,接下来我们用个简单的例子来演示这个流程,加深点印象
模仿demo
//图文混编排版测试
class LayoutDemo extends MultiChildRenderObjectWidget{
LayoutDemo({
Key key,
List children,
}): super(key: key , children: children);
@override
RenderLayout createRenderObject(BuildContext context) {
return RenderLayout();
}
}
class PageParentData extends ContainerBoxParentData {}
class RenderLayout extends RenderBox with ContainerRenderObjectMixin,RenderBoxContainerDefaultsMixin{
double textWidth = 100;
double textFontSize = 12.0;
ui.Paragraph paragraph;
List _inlinePlaceholderBoxes;
RenderLayout({List children}){
ui.ParagraphBuilder paragraphBuilder = ui.ParagraphBuilder(
ui.ParagraphStyle(
textAlign: TextAlign.start,
fontSize: textFontSize,
textDirection: TextDirection.ltr,
maxLines: 1,
ellipsis: "...", //超出用...代替
),
)
..pushStyle(
ui.TextStyle(
color: Colors.black87, textBaseline: ui.TextBaseline.alphabetic),
)
..addText("前面部分")
..addPlaceholder(50, 50, ui.PlaceholderAlignment.middle)
..addText("中间部分")
..addPlaceholder(50, 20, ui.PlaceholderAlignment.bottom)
..addText("尾巴");
paragraph = paragraphBuilder.build()
..layout(ui.ParagraphConstraints(width: 300));
//获得空白占位的盒子位置列表,即addPlaceholder部分(源码中使用的是textPainter.layout ,内部会调用这个)
_inlinePlaceholderBoxes = paragraph.getBoxesForPlaceholders();
}
@override
void setupParentData(RenderBox child) {
if (child.parentData is! PageParentData)
child.parentData = PageParentData();
}
@override
void performLayout() {
size = Size(300,300);
double widthOffset = 0.0;
double heightOffset = 0.0;
RenderBox child = firstChild;
int index = 0;
while(child != null){
print("index = $index ${_inlinePlaceholderBoxes[index].left}");
widthOffset = _inlinePlaceholderBoxes[index].left;
heightOffset = _inlinePlaceholderBoxes[index].top;
final PageParentData childParentData = child.parentData;
child.layout(constraints.heightConstraints(), parentUsesSize: true);
childParentData.offset = Offset(widthOffset, heightOffset);
index++;
child = childParentData.nextSibling;
}
}
//当hitTestChildren返回false时就会调用该方法
//hitTestSelf返回true代表自己能处理本事件,调用自己的handleEvent方法
@override
bool hitTestSelf(Offset position) => true;
@override
bool hitTestChildren(BoxHitTestResult result, { Offset position }) {
RenderBox child = firstChild;
while (child != null) {
final PageParentData textParentData = child.parentData;
//配套对应,将触摸反馈点也进行平移
final Matrix4 transform = Matrix4.translationValues(textParentData.offset.dx, textParentData.offset.dy, 0.0)
..scale(1.0, 1.0, 1.0);
final bool isHit = result.addWithPaintTransform(
transform: transform,
position: position,
hitTest: (BoxHitTestResult result, Offset transformed) {
return child.hitTest(result, position: transformed);
},
);
//如果点击事件发生在平移的widget控件上,则其自己处理事件,不调用自己的handleEvent
if (isHit) {
return true;
}
child = childAfter(child);
}
//默认情况下自己处理事件,调用自己的handleEvent
return false;
}
@override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (event is! PointerDownEvent)
return;
//默认的文本处理方式,当textSpan中设置了recognizer,即捕获事件
//找到对应位置的span,然后让其捕获事件,span?.recognizer?.addPointer(event);
}
@override
void paint(PaintingContext context, Offset offset) {
var canvas = context.canvas;
double scale = 1.0;
//绘制文字
canvas.drawParagraph(paragraph, offset);
RenderBox child = firstChild;
while(child != null){
final PageParentData childParentData = child.parentData;
//位移控件,即将控件平移offset + childParentData.offset距离
context.pushTransform(
true,
offset + childParentData.offset,
Matrix4.diagonal3Values(scale, scale, scale),
(PaintingContext context, Offset offset) {
context.paintChild(
child,
offset,
);
},
);
child = childParentData.nextSibling;
}
}
}
使用:
LayoutDemo(
children: [
Icon(Icons.ac_unit),
Icon(Icons.link),
],
)
效果图:
结尾
可能有时候需要折叠文本,那么怎么知道文本到了可以折叠的地方呢(这里也可以用于对小说的章节页数划分)
//计算是否需要折叠
TextPainter textPainter = TextPainter(textDirection: TextDirection.ltr);
textPainter.text =
TextSpan(text: widget.str, style: TextStyle(fontSize: 14,color: Colors.grey),);
textPainter.layout(maxWidth: width);
//获取占满这个区域的String的最后一个字符的index(第几个就返回几)
int end = textPainter.getPositionForOffset(Offset(width, 50.0)).offset;
//得到这个end也可以对字符串就行拆分
再说一个源码的错误:
在上面分析handleEvent的时候有这一段代码
final TextSpan span = _textPainter.text.getSpanForPosition(position);
在有用WidgetSpan的时候返回的 TextSpan是错误的
InlineSpan getSpanForPosition(TextPosition position) {
//单独封装了一个value,便于传值
//只是简单的使用int i = 0的话,然后将i传递给函数,函数内修改i的值,后面的span使用的i值是不会变的仍是0,封装成对象则不一样,值是会变的
final Accumulator offset = Accumulator();
InlineSpan result;
visitChildren((InlineSpan span) {
//遍历所有子控件,找到对应索引点位置的span(这里主要找的是textspan)
result = span.getSpanForPositionVisitor(position, offset);
return result == null;
});
return result;
}
getSpanForPositionVisitor到底做了什么?
举个例子:
比如这个格式的: "hi"+占位符+"你好"+"我在这"
当我们点击到占位符上的时候,返回的索引就是3(hi占2个,占位符占1个)
第一次循环是"hi"
offset的值为0,position的值为3,并不满足下面那个条件判断,offset的值变为2
第二次循环是占位符,直接返回null,offset的值还是2
第三次循环是"你好",正好满足if判断的第二个条件,所以返回的是"你好"的span
span不为null,循环终止,最终值为"你好"
但实际上我们我们点击的是占位符上的控件
// --- TextSpan ---
@override
InlineSpan getSpanForPositionVisitor(TextPosition position, Accumulator offset) {
if (text == null) {
return null;
}
final TextAffinity affinity = position.affinity;
final int targetOffset = position.offset;
final int endOffset = offset.value + text.length;
if (offset.value == targetOffset && affinity == TextAffinity.downstream ||
offset.value < targetOffset && targetOffset < endOffset ||
endOffset == targetOffset && affinity == TextAffinity.upstream) {
return this;
}
//即相当于index会加一个当前text的长度,并且这是个对象,后面使用的offset值就是当前的
offset.increment(text.length);
return null;
}
// --- WidgetSpan ---
@override
InlineSpan getSpanForPositionVisitor(TextPosition position, Accumulator offset) {
return null;
}
修改起来也简单
// --- WidgetSpan ---
@override
InlineSpan getSpanForPositionVisitor(TextPosition position, Accumulator offset) {
//add
offset.increment(1);
return null;
}
尾巴
文中demo的github地址:https://github.com/leaf-fade/flutter_source/blob/master/lib/text/text.dart