Flutter 自定义控件之RenderObject
博主相关文章列表
Flutter 框架实现原理
Flutter 框架层启动源码剖析
Flutter 页面更新流程剖析
Flutter 事件处理源码剖析
Flutter 路由源码剖析
Flutter 安卓平台源码剖析(一)
Flutter 自定义控件之RenderObject
使用RenderObject 自定义控件
前面课程已经讲了使用Canvas
自绘控件,为什么还需要了解使用RenderObject
自定义控件呢?两种有什么区别?
Canvas
主要是进行底层绘制的,是最基础的一环。有时候一个控件除了绘制,还需要处理布局和事件,我们如果直接使用Canvas
,就需要自己处理这些异常麻烦的事情,而Flutter的控件体系正是实现了这样一套机制,我们使用RenderObject
去自定义控件就能复用这套体系。另外,通过学习使用RenderObject
,也能加深我们对Flutter的控件、元素、渲染对象三者之间关系的理解。
布局原理
在Flutter中,布局阶段由两个线性传递构成:约束沿树向下传递,以及布局细节沿树向上传递。
过程如下:
- 父级给每个子级传递某些“约束”。这些约束是子级布置自己时必须遵守的一组规则。约束的一个简单示例是最大宽度约束。父级可以将允许渲染的最大宽度传递给其子级。当子级收到这些约束时,它知道不能超过父级约束的最大宽度。
- 接着,子级生成新的约束,并将其向下传递给自己的子级,这种情况一直持续到没有子级的叶子节点为止。
- 然后,此叶子节点控件根据传递给它的约束条件确定其“布局细节”。例如,如果其父级传递给它的最大宽度限制为500像素。它可以选择全部用光或只使用100像素。之后,叶子节点控件将确定的“布局细节”返回父级。
- 父级反过来也是这样做的。它利用子级返回的细节来确定自己的细节是什么,然后把它们传到渲染树上,一直沿着树往上传,要么传到根,要么达到某些限制为止。
至于 "约束 "和 "布局细节 "是什么,要看使用的布局协议。在Flutter中,主要有两种布局协议:box协议,和sliver协议。box协议用于在简单的二维笛卡尔坐标系中显示对象,而sliver协议用于显示对滚动有反应的对象。
在box协议中,父代传递给子代的约束称为BoxConstraints
。这些约束决定了每个子代允许的最大和最小宽度和高度。例如,父代可能会将以下BoxConstraints
(MinWidth
=150,MaxWidth
=300,MinHeight
=100)传给它的子级。
这表示子级可以取得图中绿色范围内的值。 即介于150到300之间的任何宽度,大于100的任何高度(此处maxHeight
为无穷大)。 由此,子级决定在这些限制条件下要拥有多大的尺寸,并将其决定通知父级。所以,“布局细节”是指子级选择的大小。
在Sliver
协议中,情况会更复杂。 父级向下传递SliverConstraints
到其子级,其中包含滚动信息和约束,例如滚动偏移量,重叠部分等。子级又将SliverGeometry
返回其父级。 Sliver
协议非常复杂,本篇不涉及。
一旦父级知道其子级的所有布局细节,它就可以继续绘制自己和子级。Flutter会传递给它一个PaintingContext
,其中包含一个Canvas
,它可以在上面绘制。
关于布局约束的深入理解,请阅读官方文档的解释 《深入理解布局约束》
自定义示例
渲染对象RenderObject
是一个抽象类。我们需要继承它来完成自定义控件的渲染。它有两个重要的子类RenderBox
和RenderSliver
。这两个类分别实现box协议和sliver协议,这两个类还被其他几十个类继承,这些子类分别处理特定的场景,并实现渲染过程的细节。
如果我们直接从RenderObject
继承,就无法复用已有的布局协议,通常来说,应该从它的子类RenderBox
类去派生自定义类。但是直接继承RenderBox
仍然会有些细节处理,较为繁琐,通常我们可以去继承RenderBox
的两个子类RenderShiftedBox
或RenderProxyBox
。
自定义RenderObject
这里继承自RenderShiftedBox
/// 自定义用于对齐布局的渲染对象
class MyAlignRenderBox extends RenderShiftedBox {
AlignmentGeometry alignment;
MyAlignRenderBox({
this.alignment = Alignment.center,
RenderBox child,
}) : super(child);
@override
void performLayout() {
/// 测量
/// 父级向子级传递约束,子级必须服从给定的约束。
/// parentUsesSize为true,表示父级依赖于子级的布局,子级布局改变,父级也要重新布局
/// 反之,子级发生改变,不会通知父级。即父级不依赖子级
child.layout(BoxConstraints(
minHeight: 0.0,
maxHeight: constraints.maxHeight,
minWidth: 0.0,
maxWidth: constraints.maxWidth
), parentUsesSize: true);
/// 对子级进行布局
/// 经过测量后,可通过 child.size 拿到 child 测量后的大小
/// 这里parentData即负责存储父节点所需要的子节点的布局信息
final BoxParentData childParentData = child.parentData;
if(alignment == Alignment.center){
// offset属性即用来设置子节点相对于父节点的位置
childParentData.offset = Offset((this.constraints.maxWidth - child.size.width)/2,
(this.constraints.maxHeight - child.size.height)/2);
}else{
childParentData.offset = Offset(0,0);
}
/// 确定自己的“布局细节”
size = Size(this.constraints.maxWidth, constraints.maxHeight);
}
}
上面关于方位的计算,可以直接利用Alignment
已经封装的功能,无需使用if
判断
childParentData.offset = (alignment as Alignment).alongOffset(Size(constraints.maxWidth, constraints.maxHeight)-child.size);
自定义Widget
有了渲染对象,还需要一个与之对应的Widget,用于插入控件树中。自定义的Widget中需要实现两个方法,用于创建与之相关的Element
和RenderObject
。为了简单,这里继承自SingleChildRenderObjectWidget
,因为它内部会帮我们创建上下文Element
,这样我们只需把精力放在RenderObject
上
/// 自定义对齐布局Widget
class MyAlignWidget extends SingleChildRenderObjectWidget{
MyAlignWidget({this.alignment=Alignment.center,Widget child}):super(child:child);
final AlignmentGeometry alignment;
@override
SingleChildRenderObjectElement createElement() {
return super.createElement();
}
@override
RenderObject createRenderObject(BuildContext context) {
// 创建我们自定义的渲染对象
return MyAlignRenderBox(alignment: alignment);
}
}
使用自定义布局
Widget build(BuildContext context) {
return Scaffold(
body: MyAlignWidget(
alignment: Alignment.center,
child: Container(
width: 100,
height: 100,
color: Colors.blue,
),
),
);
}
其他示例
当我们的控件不想进行布局,而是交给它的子级去处理,而我们只是想改变某些行为时,可以继承一个RenderObject
的代理类RenderProxyBox
。
以下是一个仅处理触摸事件的自定义控件示例,而RenderConstrainedBox
正是一个RenderProxyBox
的子类
class TouchHighlightRender extends RenderConstrainedBox {
TouchHighlightRender() : super(additionalConstraints: const BoxConstraints.expand());
// 自身是否可进行命中检测
@override
bool hitTestSelf(Offset position) => true;
final Map<int, Offset> _dots = <int, Offset>{};
// 实现该方法用于处理事件
@override
void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
if (event is PointerDownEvent || event is PointerMoveEvent) {
_dots[event.pointer] = event.position;
markNeedsPaint();
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
_dots.remove(event.pointer);
markNeedsPaint();
}
}
@override
void paint(PaintingContext context, Offset offset) {
final Canvas canvas = context.canvas;
canvas.drawRect(offset & size, Paint()..color = const Color(0xFFE6E6FA));
final Paint paint = Paint()..color = const Color(0xFFFFFF00);
for (Offset point in _dots.values)
canvas.drawCircle(point, 50.0, paint);
super.paint(context, offset);
}
}
///
/// 触摸高亮控件
///
class TouchHighlight extends SingleChildRenderObjectWidget {
const TouchHighlight({ Key key, Widget child }) : super(key: key, child: child);
@override
TouchHighlightRender createRenderObject(BuildContext context) => TouchHighlightRender();
}
使用控件。当我们触摸屏幕时,触摸点会形成一个圆圈高亮效果
Widget build(BuildContext context) {
return Scaffold(
body: TouchHighlight(
child: Center(
child: Text("Hello"),
),
),
);
}
总结
当我们使用这种方式自定义控件时,至少需要自定义一个Widget
和一个RenderObject
。通常,我们的Widget
可以继承自以下三种类
-
SingleChildRenderObjectWidget
:RenderObject
只有一个child
-
MultiChildRenderObjectWidget
:可以有多个child
-
LeafRenderObjectWidget
:RenderObject
是一个叶子节点,没有child
而我们的自定义的RenderObject
通常可以从RenderShiftedBox
或RenderProxyBox
及其子类派生。
当然,并不推荐实际开发中直接使用这种方式去自定义布局,总体来说仍然显得繁琐。Flutter已为开发者提供了两个控件用于自定义布局
-
CustomSingleChildLayout
处理包含单个child
的布局 -
CustomMultiChildLayout
处理包含多个child
的布局
视频课程
本篇博客视频内容可访问B站链接 使用RenderObject 自定义控件 您觉得有帮助,别忘了点赞哦
如需要获取完整的Flutter全栈式开发课程,请访问以下地址
Flutter全栈式开发之Dart 编程指南
上一篇: java中字节输入输出流
下一篇: Mybatis第一天 入门
推荐阅读