布局类组件都会包含一个或多个子组件,布局类组件都是直接或间接继承SingleChildRenderObjectWidget
和MultiChildRenderObjectWidget
的Widget,它们一般都会有一个child
或children
属性用于接收子 Widget。
不同的布局类组件对子组件排列(layout)方式不同,如下表所示:
Widget | 说明 | 用途 |
---|---|---|
LeafRenderObjectWidget | 非容器类组件基类 | Widget树的叶子节点,用于没有子节点的widget,通常基础组件都属于这一类,如Image。 |
SingleChildRenderObjectWidget | 单子组件基类 | 包含一个子Widget,如:ConstrainedBox、DecoratedBox等 |
MultiChildRenderObjectWidget | 多子组件基类 | 包含多个子Widget,一般都有一个children参数,接受一个Widget数组。如Row、Column、Stack等 |
我们看一下继承关系 Widget > RenderObjectWidget > (Leaf/SingleChild/MultiChild) RenderObjectWidget
。
RenderObjectWidget
类中定义了创建、更新RenderObject
的方法,子类必须实现他们,关于RenderObject
我们现在只需要知道它是最终布局、渲染UI界面的对象即可,也就是说,对于布局类组件来说,其布局算法都是通过对应的RenderObject
对象来实现的,所以如果对某个布局类组件的原理感兴趣,可以查看其对应的RenderObject
的实现,比如Stack
(层叠布局)对应的RenderObject
对象就是RenderStack
,而层叠布局的实现就在RenderStack
中。
布局原理与约束
尺寸限制类容器用于限制容器大小,Flutter中提供了多种这样的容器,如ConstrainedBox
、SizedBox
、UnconstrainedBox
、AspectRatio
等。
Flutter布局模型
Flutter 中有两种布局模型:
- 基于
RenderBox
的盒模型布局。 - 基于
Sliver
(RenderSliver
) 按需加载列表布局。
两种布局方式在细节上略有差异,但大体流程相同,布局流程如下:
- 上层组件向下层组件传递约束(constraints)条件。
- 下层组件确定自己的大小,然后告诉上层组件。注意下层组件的大小必须符合父组件的约束。
- 上层组件确定下层组件相对于自身的偏移和确定自身的大小(大多数情况下会根据子组件的大小来确定自身的大小)。
比如,父组件传递给子组件的约束是“最大宽高不能超过100,最小宽高为0”,如果我们给子组件设置宽高都为200,则子组件最终的大小是100*100,因为任何时候子组件都必须先遵守父组件的约束,在此基础上再应用子组件约束(相当于父组件的约束和自身的大小求一个交集)。
盒模型布局组件有两个特点:
- 组件对应的渲染对象都继承自
RenderBox
类。 - 在布局过程中父级传递给子级的约束信息由
BoxConstraints
描述。
BoxConstraints
BoxConstraints
是盒模型布局过程中父级组件传递给子组件的约束信息,用来描述子组件可用的空间范围,它包含最小和最大宽高信息,子组件大小需要在约束的范围内,BoxConstraints
默认的构造函数如下:
const BoxConstraints({
this.minWidth = 0.0, //最小宽度
this.maxWidth = double.infinity, //最大宽度
this.minHeight = 0.0, //最小高度
this.maxHeight = double.infinity //最大高度
})
它包含 4 个属性,BoxConstraints
还定义了一些便捷的构造函数,用于快速生成特定限制规则的BoxConstraints
,如BoxConstraints.tight(Size size)
,它可以生成固定宽高的限制;BoxConstraints.expand()
可以生成一个尽可能大的用以填充另一个容器的BoxConstraints
。
约定:为了描述方便,如果我们说一个组件不约束其子组件或者取消对子组件约束时是指对子组件约束的最大宽高为无限大,而最小宽高为0,相当于子组件完全可以自己根据需要的空间来确定自己的大小。
ConstrainedBox
ConstrainedBox
用于对子组件添加额外的约束。例如,如果你想让子组件的最小高度是80像素,你可以使用const BoxConstraints(minHeight: 80.0)
作为子组件的约束。
示例
我们先定义一个redBox
,它是一个背景颜色为红色的盒子,不指定它的宽度和高度:
Widget redBox = DecoratedBox(
decoration: BoxDecoration(color: Colors.red),
);
我们实现一个最小高度为50,宽度尽可能大的红色容器。
ConstrainedBox(
constraints: BoxConstraints(
minWidth: double.infinity, //宽度尽可能大
minHeight: 50.0 //最小高度为50像素
),
child: Container(
height: 5.0,
child: redBox ,
),
)
效果:
可以看到,我们虽然将Container
的高度设置为5
像素,但是最终却是50
像素,这正是ConstrainedBox
的最小高度限制生效了。如果将Container
的高度设置为80
像素,那么最终红色区域的高度也会是80
像素,因为在此示例中,ConstrainedBox
只限制了最小高度,并未限制最大高度。
SizedBox
SizedBox
用于给子元素指定固定的宽高,如:
SizedBox(
width: 80.0,
height: 80.0,
child: redBox
)
效果:
实际上SizedBox
只是ConstrainedBox
的一个定制,上面代码等价于:
ConstrainedBox(
constraints: BoxConstraints.tightFor(width: 80.0,height: 80.0),
child: redBox,
)
而BoxConstraints.tightFor(width: 80.0,height: 80.0)
等价于:
BoxConstraints(minHeight: 80.0,maxHeight: 80.0,minWidth: 80.0,maxWidth: 80.0)
而实际上ConstrainedBox
和SizedBox
都是通过RenderConstrainedBox
来渲染的,我们可以看到ConstrainedBox
和SizedBox
的createRenderObject()
方法都返回的是一个RenderConstrainedBox
对象:
createRenderObject(BuildContext context) {
return RenderConstrainedBox(
additionalConstraints: ...,
);
}
RenderConstrainedBox
多重限制
如果某一个组件有多个父级ConstrainedBox
限制,那么最终会是哪个生效?我们看一个例子:
ConstrainedBox(
constraints: BoxConstraints(minWidth: 60.0, minHeight: 60.0), //父
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: 90.0, minHeight: 20.0),//子
child: redBox,
),
)
上面我们有父子两个ConstrainedBox
,他们的约束条件不同,运行效果:
最终显示效果是宽90,高60,也就是说是子ConstrainedBox
的minWidth
生效,而minHeight
是父ConstrainedBox
生效。单凭这个例子,我们还总结不出什么规律,我们将上例中父子约束条件换一下:
ConstrainedBox(
constraints: BoxConstraints(minWidth: 90.0, minHeight: 20.0),
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: 60.0, minHeight: 60.0),
child: redBox,
)
)
运行效果:
最终的显示效果仍然是90,高60,效果相同,但意义不同,因为此时minWidth
生效的是父ConstrainedBox
,而minHeight
是子ConstrainedBox
生效。
通过上面示例,我们发现有多重限制时,对于minWidth
和minHeight
来说,是取父子中相应数值较大的。实际上,只有这样才能保证父限制与子限制不冲突。
UnconstrainedBox
虽然任何时候子组件都必须遵守其父组件的约束,但前提条件是它们必须是父子关系,假如有一个组件 A
,它的子组件是B
,B
的子组件是 C
,则 C
必须遵守 B
的约束,同时 B
必须遵守 A
的约束,但是 A
的约束不会直接约束到 C
,除非B
将A
对它自己的约束透传给了C
。 利用这个原理,就可以实现一个这样的 B
组件:
B
组件中在布局C
时不约束C
(可以为无限大)。C
根据自身真实的空间占用来确定自身的大小。B
在遵守A
的约束前提下结合子组件的大小确定自身大小。
而这个 B
组件就是 UnconstrainedBox
组件,也就是说UnconstrainedBox
的子组件将不再受到约束,大小完全取决于自己。一般情况下,我们会很少直接使用此组件,但在 "去除"多重限制 的时候也许会有帮助,我们看下下面的代码:
ConstrainedBox(
constraints: BoxConstraints(minWidth: 60.0, minHeight: 100.0), //父
child: UnconstrainedBox( //“去除”父级限制
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: 90.0, minHeight: 20.0),//子
child: redBox,
),
)
)
上面代码中,如果没有中间的UnconstrainedBox
,那么根据上面所述的多重限制规则,那么最终将显示一个90×100
的红色框。但是由于UnconstrainedBox
“去除”了父ConstrainedBox
的限制,则最终会按照子ConstrainedBox
的限制来绘制redBox
,即90×20
,如图所示:
但是,请注意,UnconstrainedBox
对父组件限制的“去除”并非是真正的去除:上面例子中虽然红色区域大小是90×20
,但上方仍然有80
的空白空间。也就是说父限制的minHeight(100.0)
仍然是生效的,只不过它不影响最终子元素redBox
的大小,但仍然还是占有相应的空间,可以认为此时的父ConstrainedBox
是作用于子UnconstrainedBox
上,而redBox
只受子ConstrainedBox
限制,这一点请务必注意。
那么有什么方法可以彻底去除父ConstrainedBox
的限制吗?答案是否定的!请牢记,任何时候子组件都必须遵守其父组件的约束,所以在定义一个通用的组件时,如果要对子组件指定约束,那么一定要注意,因为一旦指定约束条件,子组件自身就不能违反约束。
在实际开发中,当我们发现已经使用 SizedBox
或 ConstrainedBox
给子元素指定了固定宽高,但是仍然没有效果时,几乎可以断定:已经有父组件指定了约束!
举个例子,如 Material 组件库中的AppBar
(导航栏)的右侧菜单中,我们使用SizedBox
指定了 loading 按钮的大小,代码如下:
AppBar(
title: Text(title),
actions: <Widget>[
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation(Colors.white70),
),
)
],
)
运行效果:
我们会发现右侧loading按钮大小并没有发生变化!这正是因为AppBar
中已经指定了actions
按钮的约束条件,所以我们要自定义loading按钮大小,就必须通过UnconstrainedBox
来 “去除” 父元素的限制,代码如下:
AppBar(
title: Text(title),
actions: <Widget>[
UnconstrainedBox(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 3,
valueColor: AlwaysStoppedAnimation(Colors.white70),
),
),
)
],
)
运行效果:
可以看到生效了!实际上将 UnconstrainedBox
换成 Center
或者 Align
也是可以的。
另外,需要注意,UnconstrainedBox
虽然在其子组件布局时可以取消约束(子组件可以为无限大),但是 UnconstrainedBox
自身是受其父组件约束的,所以当 UnconstrainedBox
随着其子组件变大后,如果UnconstrainedBox
的大小超过它父组件约束时,也会导致溢出报错,比如:
Column(
children: <Widget>[
UnconstrainedBox(
alignment: Alignment.topLeft,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [Text('xx' * 30)]),
),
),
]
运行效果:
文本已经超过屏幕宽度,溢出了。
AspectRatio
AspectRatio
的作用是可以调整子元素child的宽高比。
AspectRatio
首先会在布局限制条件允许的范围内尽可能的扩展,widget的高度是由宽度和比率决定的,类似于BoxFit
中的contain
,按照固定比率去尽量占满区域。
如果在满足所有限制条件过后无法找到一个可行的尺寸,AspectRatio
最终将会去优先适应布局限制条件,而忽略所设置的比率。
属性 | 说明 |
---|---|
aspectRatio | 宽高比,最终可能不会根据这个值去布局,具体则要看综合因素,外层是否允许按照这种比率进行布局,这只是一个参考值 |
child | 子组件 |
示例:
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
// This widget is the root of your application.
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: Scaffold(
appBar: AppBar(title: const Text("Flutter App")),
body: const LayoutDemo(),
),
);
}
}
//需求:页面上显示一个容器,宽度是屏幕的宽度,高度是容器宽度的一半
class LayoutDemo extends StatelessWidget {
const LayoutDemo({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return AspectRatio(
aspectRatio: 2/1,
child: Container(
color: Colors.red,
),
);
}
}
LimitedBox
LimitedBox
用于指定child
的最大宽高,它可以将child
限制在其设定的最大宽高中的,但是这个限定是有条件的。当LimitedBox
最大宽度不受限制时,child
的宽度就会受到这个最大宽度的限制,同理高度。
示例:
LimitedBox(
maxWidth: 100,
maxHeight: 100,
child: Container(
color: Colors.blue,
width: 200.0,
),
),
FractionallySizedBox
FractionallySizedBox
的作用是使子组件宽高可以根据父容器宽高的百分比来设置。
示例:
class FractionallySizedBoxWidget extends StatelessWidget {
const FractionallySizedBoxWidget({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("FractionallySizedBox"),),
body: Center(
//child宽高分别是父组件宽高的80%
child: FractionallySizedBox(
widthFactor: 0.8,
heightFactor: 0.8,
child: Container(
color: Colors.red,
),
),
),
);
}
}
线性布局(Row和Column)
所谓线性布局,即指沿水平或垂直方向排列子组件。Flutter 中通过Row
和Column
来实现线性布局,类似于Android 中的LinearLayout
控件。Row
和Column
都继承自Flex
。
主轴和纵轴
对于线性布局,有主轴和纵轴之分,如果布局是沿水平方向,那么主轴就是指水平方向,而纵轴即垂直方向;如果布局沿垂直方向,那么主轴就是指垂直方向,而纵轴就是水平方向。其实纵轴就是相对于主轴方向的交叉轴。
在线性布局中,有两个定义对齐方式的枚举类MainAxisAlignment
和CrossAxisAlignment
,分别代表主轴对齐和交叉轴对齐。
Row
Row可以沿水平方向排列其子widget。常用属性:
属性 | 说明 |
---|---|
textDirection | 表示水平方向子组件的布局顺序(是从左往右还是从右往左),默认为系统当前Locale环境的文本方向(如中文、英语都是从左往右,而阿拉伯语是从右往左) |
mainAxisSize | 表示在主轴(水平)方向占用的空间,默认是MainAxisSize.max ,表示尽可能多的占用水平方向的空间,此时无论子 widgets 实际占用多少水平空间,Row 的宽度始终等于水平方向的最大宽度;而MainAxisSize.min 表示尽可能少的占用水平空间,当子组件没有占满水平剩余空间,则Row 的实际宽度等于所有子组件占用的的水平空间 |
mainAxisAlignment | 表示子组件在Row的水平方向的对齐方式,如果mainAxisSize 值为MainAxisSize.min ,则此属性无意义,因为子组件的宽度等于Row 的宽度。只有当mainAxisSize 的值为MainAxisSize.max 时,此属性才有意义 |
verticalDirection | 表示Row交叉轴(垂直)的对齐方向,默认是VerticalDirection.down ,表示从上到下 |
crossAxisAlignment | 表示子组件在交叉轴方向的对齐方式,Row的高度等于子组件中最高的子元素高度,它的取值和MainAxisAlignment 一样(包含start、end、 center 三个值),不同的是crossAxisAlignment 的参考系是verticalDirection ,即verticalDirection 值为VerticalDirection.down 时crossAxisAlignment.start 指顶部对齐,verticalDirection 值为VerticalDirection.up 时,crossAxisAlignment.start 指底部对齐; |
children | 子组件数组 |
其中 mainAxisAlignment
的取值如下:
MainAxisAlignment | 说明 |
---|---|
MainAxisAlignment.start | 表示沿textDirection 的初始方向对齐,如textDirection 取值为TextDirection.ltr 时,则MainAxisAlignment.start 表示左对齐,textDirection 取值为TextDirection.rt l时表示从右对齐 |
MainAxisAlignment.end | 和MainAxisAlignment.start 正好相反 |
MainAxisAlignment.center | 表示居中对齐 |
MainAxisAlignment.spaceBetween | 将空闲空间均匀地分布在子组件之间 |
MainAxisAlignment.spaceAround | 将空闲空间均匀地分布在子组件之间,并且在第一个child之前和最后一个child之后显示均分空间的一半 |
MainAxisAlignment.spaceEvenly | 将空闲空间均匀地分布在子组件之间,并且在第一个child之前和最后一个child之后也显示均分的空间 |
可以这么理解:textDirection
是mainAxisAlignment
的参考系。
示例:
Column(
//测试Row对齐方式,排除Column默认居中对齐的干扰
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(" hello world "),
Text(" I am Jack "),
],
),
Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(" hello world "),
Text(" I am Jack "),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.end,
textDirection: TextDirection.rtl,
children: <Widget>[
Text(" hello world "),
Text(" I am Jack "),
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
verticalDirection: VerticalDirection.up,
children: <Widget>[
Text(" hello world ", style: TextStyle(fontSize: 30.0),),
Text(" I am Jack "),
],
),
],
);
效果:
解释:
- 第一个
Row
很简单,默认为居中对齐; - 第二个
Row
,由于mainAxisSize
值为MainAxisSize.min
,Row
的宽度等于两个Text
的宽度和,所以对齐是无意义的,所以会从左往右显示; - 第三个
Row
设置textDirection
值为TextDirection.rtl
,所以子组件会从右向左的顺序排列,而此时MainAxisAlignment.end
表示左对齐,所以最终显示结果就是图中第三行的样子; - 第四个
Row
测试的是纵轴的对齐方式,由于两个子Text
字体不一样,所以其高度也不同,我们指定了verticalDirection
值为VerticalDirection.up
,即从低向顶排列,而此时crossAxisAlignment
值为CrossAxisAlignment.start
表示底对齐。
Column
Column
可以在垂直方向排列其子组件。参数和Row
一样,不同的是布局方向为垂直。
示例:
import 'package:flutter/material.dart';
class CenterColumnRoute extends StatelessWidget {
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text("hi"),
Text("world"),
],
);
}
}
效果:
解释:
- 由于我们没有指定
Column
的mainAxisSize
,所以使用默认值MainAxisSize.max
,则Column
会在垂直方向占用尽可能多的空间,此例中会占满整个屏幕高度。 - 由于我们指定了
crossAxisAlignment
属性为CrossAxisAlignment.center
,那么子项在Column
交叉轴方向(水平方向)会居中对齐。注意,在水平方向对齐是有边界的,总宽度为Column
占用空间的实际宽度,而实际的宽度取决于子项中宽度最大的Widget
。在本例中,Column
有两个子Widget
,而显示“world”的Text
宽度最大,所以Column
的实际宽度则为Text("world")
的宽度,所以居中对齐后Text("hi")
会显示在Text("world")
的中间部分。
实际上,Row
和Column
都只会在主轴方向占用尽可能大的空间,而在交叉轴的长度则取决于他们最大子元素的长度。
如果我们想让本例中的两个文本控件在整个手机屏幕中间对齐,我们有两种方法:
-
将
Column
的宽度指定为屏幕宽度;这很简单,我们可以通过ConstrainedBox
或SizedBox
来强制更改宽度限制。例如:将
ConstrainedBox
的minWidth
设为double.infinity
,可以使宽度占用尽可能多的空间。
ConstrainedBox(
constraints: BoxConstraints(minWidth: double.infinity),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text("hi"),
Text("world"),
],
),
);
- 可以使用
Center
组件
嵌套情况
如果Row
里面嵌套Row
,或者Column
里面再嵌套Column
,那么只有最外面的Row
或Column
会占用尽可能大的空间,里面Row
或Column
所占用的空间为实际大小,下面以Column
为例说明:
Container(
color: Colors.green,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.max, //有效,外层Colum高度为整个屏幕
children: <Widget>[
Container(
color: Colors.red,
child: Column(
mainAxisSize: MainAxisSize.max,//无效,内层Colum高度为实际高度
children: <Widget>[
Text("hello world "),
Text("I am Jack "),
],
),
)
],
),
),
);
运行效果:
如果要让里面的Column
占满外部Column
,可以使用Expanded
组件:
Expanded(
child: Container(
color: Colors.red,
child: Column(
mainAxisAlignment: MainAxisAlignment.center, //垂直方向居中对齐
children: <Widget>[
Text("hello world "),
Text("I am Jack "),
],
),
),
)
运行效果:
弹性布局(Flex、Expanded)
弹性布局允许子组件按照一定比例来分配父容器空间。弹性布局的概念在其他UI系统中也都存在,如 H5 中的弹性盒子布局,Android 中的FlexboxLayout
等。Flutter 中的弹性布局主要通过Flex
和Expanded
来配合实现。
Flex
Flex
组件可以沿着水平或垂直方向排列子组件,如果你知道主轴方向,可以直接使用Row
或Column
会方便一些,因为它们都继承自Flex
,参数基本相同,所以能使用Flex
的地方基本上都可以使用Row
或Column
。
Flex
本身功能是很强大的,它也可以和Expanded
组件配合实现弹性布局。接下来我们只讨论Flex
和弹性布局相关的属性(其他属性已经在介绍Row
和Column
时介绍过了)。
Flex({
...
required this.direction, //弹性布局的方向, Row默认为水平方向,Column默认为垂直方向
List<Widget> children = const <Widget>[],
})
Flex
继承自MultiChildRenderObjectWidget
,对应的RenderObject
为RenderFlex
,RenderFlex
中实现了其布局算法。
Expanded
Expanded
只能作为 Flex
的孩子(否则会报错),它可以按比例“扩伸”Flex
子组件所占用的空间。因为 Row
和Column
都继承自 Flex
,所以 Expanded
也可以作为它们的孩子。
const Expanded({
int flex = 1,
required Widget child,
})
flex
:弹性系数,如果为0
或null
,则child
是没有弹性的,即不会被扩伸占用的空间。如果大于0
,所有的Expanded
按照其flex
的比例来分割主轴的全部空闲空间。
下面我们看一个例子:
class FlexLayoutTestRoute extends StatelessWidget {
Widget build(BuildContext context) {
return Column(
children: <Widget>[
//Flex的两个子widget按1:2来占据水平空间
Flex(
direction: Axis.horizontal,
children: <Widget>[
Expanded(
flex: 1,
child: Container(
height: 30.0,
color: Colors.red,
),
),
Expanded(
flex: 2,
child: Container(
height: 30.0,
color: Colors.green,
),
),
],
),
Padding(
padding: const EdgeInsets.only(top: 20.0),
child: SizedBox(
height: 100.0,
//Flex的三个子widget,在垂直方向按2:1:1来占用100像素的空间
child: Flex(
direction: Axis.vertical,
children: <Widget>[
Expanded(
flex: 2,
child: Container(
height: 30.0,
color: Colors.red,
),
),
Spacer(
flex: 1,
),
Expanded(
flex: 1,
child: Container(
height: 30.0,
color: Colors.green,
),
),
],
),
),
),
],
);
}
}
运行效果:
示例中的Spacer
的功能是占用指定比例的空间,实际上它只是Expanded
的一个包装类,Spacer
的源码如下:
class Spacer extends StatelessWidget {
const Spacer({Key? key, this.flex = 1})
: assert(flex != null),
assert(flex > 0),
super(key: key);
final int flex;
Widget build(BuildContext context) {
return Expanded(
flex: flex,
child: const SizedBox.shrink(),
);
}
}
流式布局(Wrap、Flow)
在介绍 Row
和 Colum
时,如果子 widget
超出屏幕范围,则会报溢出错误,如:
Row(
children: <Widget>[
Text("xxx"*100)
],
);
可以看到,右边溢出部分报错。这是因为Row
默认只有一行,超出屏幕后不会折行。我们把超出屏幕显示范围会自动折行的布局称为流式布局。Flutter中通过Wrap
和Flow
来支持流式布局,将上例中的 Row
换成Wrap
后溢出部分则会自动折行,下面我们分别介绍Wrap
和Flow
。
Wrap
Wrap
和 Row\Column
的大部分属性类似,Row
与Column
都是单行单列的,Wrap
则突破了这个限制,当mainAxis
上空间不足时,则向crossAxis
上去扩展显示。
下面是Wrap
常用的几个属性:
属性 | 说明 |
---|---|
direction | 主轴的方向,默认水平Axis.horizontal |
alignment | 主轴的对其方式,默认WrapAlignment.start |
textDirection | 文本方向 |
verticalDirection | 定义了children摆放顺序,默认是VerticalDirection.down ,同Flex 相关属性 |
spacing | 主轴方向子widget的间距 |
runSpacing | 交叉轴方向的间距 |
runAlignment | 交叉轴方向的对齐方式 |
示例:
Wrap(
spacing: 8.0, // 主轴(水平)方向间距
runSpacing: 4.0, // 交叉轴(垂直)方向间距
alignment: WrapAlignment.center, //沿主轴方向居中
children: <Widget>[
Chip(
avatar: CircleAvatar(backgroundColor: Colors.blue, child: Text('A')),
label: Text('Hamilton'),
),
Chip(
avatar: CircleAvatar(backgroundColor: Colors.blue, child: Text('M')),
label: Text('Lafayette'),
),
Chip(
avatar: CircleAvatar(backgroundColor: Colors.blue, child: Text('H')),
label: Text('Mulligan'),
),
Chip(
avatar: CircleAvatar(backgroundColor: Colors.blue, child: Text('J')),
label: Text('Laurens'),
),
],
)
效果:
Flow
我们一般很少会使用Flow
,因为其过于复杂,需要自己实现子 widget
的位置转换,在很多场景下首先要考虑的是Wrap
是否满足需求。Flow
主要用于一些需要自定义布局策略或性能要求较高(如动画中)的场景。Flow
有如下优点:
- 性能好;
Flow
是一个对子组件尺寸以及位置调整非常高效的控件,Flow
用转换矩阵在对子组件进行位置调整的时候进行了优化:在Flow
定位过后,如果子组件的尺寸或者位置发生了变化,在FlowDelegate
中的paintChildren()
方法中调用context.paintChild
进行重绘,而context.paintChild
在重绘时使用了转换矩阵,并没有实际调整组件位置。 - 灵活;由于我们需要自己实现
FlowDelegate
的paintChildren()
方法,所以我们需要自己计算每一个组件的位置,因此,可以自定义布局策略。
缺点:
- 使用复杂。
Flow
不能自适应子组件大小,必须通过指定父容器大小或实现TestFlowDelegate
的getSize
返回固定大小。
示例:我们对六个色块进行自定义流式布局:
Flow(
delegate: TestFlowDelegate(margin: EdgeInsets.all(10.0)),
children: <Widget>[
Container(width: 80.0, height:80.0, color: Colors.red,),
Container(width: 80.0, height:80.0, color: Colors.green,),
Container(width: 80.0, height:80.0, color: Colors.blue,),
Container(width: 80.0, height:80.0, color: Colors.yellow,),
Container(width: 80.0, height:80.0, color: Colors.brown,),
Container(width: 80.0, height:80.0, color: Colors.purple,),
],
)
实现TestFlowDelegate
:
class TestFlowDelegate extends FlowDelegate {
EdgeInsets margin;
TestFlowDelegate({this.margin = EdgeInsets.zero});
double width = 0;
double height = 0;
void paintChildren(FlowPaintingContext context) {
var x = margin.left;
var y = margin.top;
//计算每一个子widget的位置
for (int i = 0; i < context.childCount; i++) {
var w = context.getChildSize(i)!.width + x + margin.right;
if (w < context.size.width) {
context.paintChild(i, transform: Matrix4.translationValues(x, y, 0.0));
x = w + margin.left;
} else {
x = margin.left;
y += context.getChildSize(i)!.height + margin.top + margin.bottom;
//绘制子widget(有优化)
context.paintChild(i, transform: Matrix4.translationValues(x, y, 0.0));
x += context.getChildSize(i)!.width + margin.left + margin.right;
}
}
}
Size getSize(BoxConstraints constraints) {
// 指定Flow的大小,简单起见我们让宽度竟可能大,但高度指定为200,
// 实际开发中我们需要根据子元素所占用的具体宽高来设置Flow大小
return Size(double.infinity, 200.0);
}
bool shouldRepaint(FlowDelegate oldDelegate) {
return oldDelegate != this;
}
}
运行效果:
可以看到我们主要的任务就是实现paintChildren
,它的主要任务是确定每个子widget
位置。由于Flow
不能自适应子widget
的大小,我们通过在getSize
返回一个固定大小来指定Flow
的大小。
注意,如果我们需要自定义布局策略,一般首选的方式是通过直接继承RenderObject
,然后通过重写 performLayout
的方式实现。
层叠布局(Stack、Positioned)
层叠布局和 Web 中的绝对定位、Android 中的 FrameLayout
相似,子组件可以根据距父容器四个角的位置来确定自身的位置。子组件是按照代码中声明的顺序堆叠起来。Flutter中使用Stack结合Positioned、Align这两个组件来配合实现定位。Stack
允许子组件堆叠,而Positioned
用于根据Stack
的四个角来确定子组件的位置。
Stack
常用属性:
属性 | 说明 |
---|---|
alignment | 此参数决定如何去对齐没有定位(没有使用Positioned )或部分定位的子组件。所谓部分定位,在这里特指没有在某一个轴上定位: left 、right 为横轴,top 、bottom 为纵轴,只要包含某个轴上的一个定位属性就算在该轴上有定位。默认值是AlignmentDirectional.topStart |
textDirection | 和Row 、Wrap 的textDirection 功能一样,都用于确定alignment 对齐的参考系,即: textDirection 的值为TextDirection.ltr ,则alignment 的start 代表左,end 代表右,即从左往右的顺序;textDirection 的值为TextDirection.rtl ,则alignment 的start 代表右,end 代表左,即从右往左的顺序 |
fit | 此参数用于确定没有定位的子组件如何去适应Stack 的大小。StackFit.loose 表示使用子组件的大小,StackFit.expand 表示扩伸到Stack 的大小,默认是StackFit.loose |
clipBehavior | 此属性决定对超出Stack 显示空间的部分如何剪裁,Clip 枚举类中定义了剪裁的方式,默认是Clip.hardEdge 表示直接剪裁,不应用抗锯齿 |
Positioned
常用属性:
属性 | 说明 |
---|---|
left | 子元素距离左侧距离 |
top | 子元素距离顶部的距离 |
right | 子元素距离右侧距离 |
bottom | 子元素距离底部的距离 |
child | 子组件 |
width | **子组件的高度 ** |
height | 子组件的高度 |
注意,Positioned
的width
、height
和其他地方的意义稍微有点区别,此处用于配合left、top 、right、 bottom
来定位组件,举个例子,在水平方向时,你只能指定left、right、width
三个属性中的两个,如指定left
和width
后,right
会自动算出(left+width
),如果同时指定三个属性则会报错,垂直方向同理。另外,宽度和高度必须是固定值,没法使用double.infinity
。
示例:下面代码通过对几个Text
组件的定位来演示Stack
和Positioned
的特性
//通过ConstrainedBox来确保Stack占满屏幕
ConstrainedBox(
constraints: BoxConstraints.expand(),
child: Stack(
alignment:Alignment.center , //指定未定位或部分定位widget的对齐方式
//注意:相对于外部容器进行定位,如果没有外部容器就相对于整个屏幕进行定位
children: <Widget>[
Container(
child: Text("Hello world",style: TextStyle(color: Colors.white)),
color: Colors.red,
),
Positioned(
left: 18.0,
child: Text("I am Jack"),
),
Positioned(
top: 18.0,
child: Text("Your friend"),
)
],
),
);
效果:
- 由于第一个子文本组件
Text("Hello world")
没有指定定位,并且alignment
值为Alignment.center
,所以它会居中显示。 - 第二个子文本组件
Text("I am Jack")
只指定了水平方向的定位(left
),所以属于部分定位,即垂直方向上没有定位,那么它在垂直方向的对齐方式则会按照alignment
指定的对齐方式对齐,即垂直方向居中。 - 对于第三个子文本组件
Text("Your friend")
,和第二个Text
原理一样,只不过是水平方向没有定位,则水平方向居中。
我们给上例中的Stack
指定一个fit
属性,然后将三个子文本组件的顺序调整一下:
Stack(
alignment:Alignment.center ,
fit: StackFit.expand, //未定位widget占满Stack整个空间
children: <Widget>[
Positioned(
left: 18.0,
child: Text("I am Jack"),
),
Container(child: Text("Hello world",style: TextStyle(color: Colors.white)),
color: Colors.red,
),
Positioned(
top: 18.0,
child: Text("Your friend"),
)
],
),
效果:
可以看到,由于第二个子文本组件没有定位,所以fit
属性会对它起作用,就会占满Stack
。由于Stack
子元素是堆叠的,所以第一个子文本组件被第二个遮住了,而第三个在最上层,所以可以正常显示。
Stack
和 Positioned
实现固定导航案例
import 'package:flutter/material.dart';
void main() {
runApp(MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text("你好Flutter")), body: const HomePage()),
));
}
class HomePage extends StatelessWidget {
const HomePage({Key? key}) : super(key: key);
Widget build(BuildContext context) {
//获取设备的宽度和高度
final size = MediaQuery.of(context).size;
return Stack(
children: [
ListView(
padding: const EdgeInsets.only(top: 50),
children: List.generate(
40, (index) => ListTile(title: Text("我是一个列表$index"))),
),
Positioned(
left: 0,
top: 0,
// bottom: 0, // 改为bottom即底部固定
width: size.width, //配置子元素的宽度和高度 没法使用double.infinity
height: 44, //配置子元素的宽度和高度
child: Container(
alignment: Alignment.center,
color: Colors.red,
child: const Text(
"二级导航",
style: TextStyle(color: Colors.white),
),
))
],
);
}
}
效果:
上面代码中MediaQuery
可以到获取屏幕宽度和高度,可以在build
方法中调用:
Widget build(BuildContext context) {
final size =MediaQuery.of(context).size;
final width =size.width;
final height =size.height;
...
}
对齐与相对定位(Align)
通过Stack
和Positioned
,我们可以指定多个子元素相对于父元素各个边的精确偏移,并且可以重叠。但如果我们只想简单的调整一个子元素在父元素中的位置的话,使用 Align
组件会更简单一些。
属性 | 说明 |
---|---|
alignment | 需要一个AlignmentGeometry 类型的值,表示子组件在父组件中的起始位置。AlignmentGeometry 是一个抽象类,它有两个常用的子类:Alignment 和 FractionalOffset |
widthFactor 和heightFactor | 用于确定 Align 组件本身宽高的属性;它们是两个缩放因子,会分别乘以子元素的宽、高,最终的结果就是 Align 组件的宽高。如果值为null ,则组件的宽高将会占用尽可能多的空间。 |
简单示例:
Container(
height: 120.0,
width: 120.0,
color: Colors.blue.shade50,
child: Align(
alignment: Alignment.topRight,
child: FlutterLogo(size: 60),
),
)
效果:
FlutterLogo
是 Flutter SDK 提供的一个组件,内容就是 Flutter 的 logo 。在上面的例子中,我们显式指定了Container
的宽、高都为 120
。如果我们不显式指定宽高,而通过同时指定widthFactor
和heightFactor
为 2
也是可以达到同样的效果:
Align(
widthFactor: 2,
heightFactor: 2,
alignment: Alignment.topRight,
child: FlutterLogo(size: 60),
),
因为FlutterLogo的宽高为 60
,则Align
的最终宽高都为2*60=120
。
另外,我们还通过Alignment.topRight
将FlutterLogo定位在Container
的右上角。那Alignment.topRight
是什么呢?通过源码我们可以看到其定义如下:
static const Alignment topRight = Alignment(1.0, -1.0);
可以看到它只是Alignment
的一个实例,下面我们介绍一下Alignment
。
Alignment
Alignment
继承自AlignmentGeometry
,表示矩形内的一个点,他有两个属性x、y
,分别表示在水平和垂直方向的偏移,Alignment
定义如下:
Alignment(this.x, this.y)
Alignment
Widget会以矩形的中心点作为坐标原点,即Alignment(0.0, 0.0)
。x、y
的值从-1
到1
分别代表矩形左边到右边的距离和顶部到底边的距离,因此2
个水平(或垂直)单位则等于矩形的宽(或高),如Alignment(-1.0, -1.0)
代表矩形的左侧顶点,而Alignment(1.0, 1.0)
代表右侧底部终点,而Alignment(1.0, -1.0)
则正是右侧顶点,即Alignment.topRight
。为了使用方便,矩形的原点、四个顶点,以及四条边的终点在Alignment
类中都已经定义为了静态常量。
Alignment
可以通过其坐标转换公式将其坐标转为子元素的具体偏移坐标:
(Alignment.x * childWidth / 2 + childWidth / 2, Alignment.y * childHeight / 2 + childHeight / 2)
其中childWidth
为子元素的宽度,childHeight
为子元素高度。
现在我们再看看上面的示例,我们将Alignment(1.0, -1.0)
带入上面公式,可得FlutterLogo的实际偏移坐标正是(60,0)
。
下面再看一个例子:
Align(
widthFactor: 2,
heightFactor: 2,
alignment: Alignment(2,0.0),
child: FlutterLogo(size: 60),
)
我们可以先想象一下运行效果:将Alignment(2,0.0)
带入上述坐标转换公式,可以得到FlutterLogo的实际偏移坐标为(90,30)
。实际运行如图所示:
FractionalOffset
FractionalOffset
继承自 Alignment
,它和 Alignment
唯一的区别就是坐标原点不同!FractionalOffset
的坐标原点为矩形的左侧顶点,这和布局系统的一致,所以理解起来会比较容易。FractionalOffset
的坐标转换公式为:
实际偏移 = (FractionalOffse.x * childWidth, FractionalOffse.y * childHeight)
简单示例:
Container(
height: 120.0,
width: 120.0,
color: Colors.blue[50],
child: Align(
alignment: FractionalOffset(0.2, 0.6),
child: FlutterLogo(size: 60),
),
)
效果:
我们将FractionalOffset(0.2, 0.6)
带入坐标转换公式得FlutterLogo实际偏移为(12,36)
,和实际运行效果吻合。
建议在需要制定一些精确的偏移时应优先使用FractionalOffset
,因为它的坐标原点和布局系统相同,能更容易算出实际偏移。
Align和Stack对比
可以看到,Align
和Stack/Positioned
都可以用于指定子元素相对于父元素的偏移,但它们还是有两个主要区别:
- 定位参考系统不同:
Stack/Positioned
定位的的参考系可以是父容器矩形的四个顶点;而Align
则需要先通过alignment
参数来确定坐标原点,不同的alignment
会对应不同原点,最终的偏移是需要通过alignment
的转换公式来计算出。 Stack
可以有多个子元素,并且子元素可以堆叠,而Align
只能有一个子元素,不存在堆叠。
Align
结合Stack
使用:
class HomePage extends StatelessWidget {
const HomePage({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Center(
child: Container(
height: 400,
width: 300,
color: Colors.red,
child: const Stack(
// alignment: Alignment.center,
children: <Widget>[
Align(
alignment: Alignment(1,-0.2),
child: Icon(Icons.home,size: 40,color: Colors.white),
),
Align(
alignment: Alignment.center,
child: Icon(Icons.search,size: 30,color: Colors.white),
),
Align(
alignment: Alignment.bottomRight,
child: Icon(Icons.settings_applications,size: 30,color:
Colors.white),
)
],
),
),
);
}
}
Center组件
Center
组件的源码定义如下:
class Center extends Align {
const Center({ Key? key, double widthFactor, double heightFactor, Widget? child })
: super(key: key, widthFactor: widthFactor, heightFactor: heightFactor, child: child);
}
可以看到Center
继承自Align
,它比Align
只少了一个alignment
参数;由于Align
的构造函数中alignment
默认值为Alignment.center
,所以,我们可以认为Center
组件其实是对齐方式确定(Alignment.center
)了的Align
。
上面我们讲过当widthFactor
或heightFactor
为null
时组件的宽高将会占用尽可能多的空间,这一点需要特别注意,我们通过一个示例说明:
...//省略无关代码
DecoratedBox(
decoration: BoxDecoration(color: Colors.red),
child: Center(
child: Text("xxx"),
),
),
DecoratedBox(
decoration: BoxDecoration(color: Colors.red),
child: Center(
widthFactor: 1,
heightFactor: 1,
child: Text("xxx"),
),
)
效果:
熟悉Web开发的同学可能会发现Align
组件的特性和Web开发中相对定位(position: relative
)非常像,是的!在大多数时候,我们可以直接使用Align
组件来实现Web中相对定位的效果。
Card 组件
Card
是卡片组件块,内容可以由大多数类型的Widget构成,Card
具有圆角和阴影,这让它看起来有立体感。
属性 | 说明 |
---|---|
margin | 外边距 |
child | 子组件 |
elevation | 阴影值的深度 |
color | 背景颜色 |
shadowColor | 阴影颜色 |
margin | 外边距 |
clipBehavior | 内容溢出的剪切方式 Clip.none 不剪切 Clip.hardEdge 裁剪但不应用抗锯齿 Clip.antiAlias 裁剪而且抗锯齿 Clip.antiAliasWithSaveLayer 带有抗锯齿的剪辑,并在剪辑之后立即保存saveLayer |
Shape | Card的阴影效果,默认的阴影效果为圆角的长方形边。 shape: const RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(10)) ), |
用 Card
实现一个通讯录的卡片效果:
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
// This widget is the root of your application.
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: Scaffold(
appBar: AppBar(title: const Text("Flutter App")),
body: const LayoutDemo(),
),
);
}
}
class LayoutDemo extends StatelessWidget {
const LayoutDemo({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return ListView(
children: [
Card(
shape: RoundedRectangleBorder(
//Card的阴影效果
borderRadius: BorderRadius.circular(10)),
elevation: 20, //阴影值的深度
margin: const EdgeInsets.all(10),
child: Column(
children: const [
ListTile(
title: Text("张三", style: TextStyle(fontSize: 28)),
subtitle: Text("高级软件工程师"),
),
Divider(),
ListTile(
title: Text("电话:152222222"),
),
ListTile(
title: Text("地址:北京市海淀区 xxx"),
),
],
),
),
Card(
shape: RoundedRectangleBorder(
//Card的阴影效果
borderRadius: BorderRadius.circular(10)),
elevation: 20,
margin: const EdgeInsets.all(10),
// color:Colors.black12, //背景颜色
child: Column(
children: const [
ListTile(
title: Text("李四", style: TextStyle(fontSize: 28)),
subtitle: Text("Flutter高级软件工程师"),
),
Divider(),
ListTile(
title: Text("电话:152222222"),
),
ListTile(
title: Text("地址:北京市海淀区 xxx"),
),
],
),
),
Card(
shape: RoundedRectangleBorder(
//Card的阴影效果
borderRadius: BorderRadius.circular(10)),
elevation: 20, //阴影值的深度
margin: const EdgeInsets.all(10),
child: Column(
children: const [
ListTile(
title: Text("张三", style: TextStyle(fontSize: 28)),
subtitle: Text("高级软件工程师"),
),
Divider(),
ListTile(
title: Text("电话:152222222"),
),
ListTile(
title: Text("地址:北京市海淀区 xxx"),
),
],
),
),
],
);
}
}
用 Card
实现一个图文列表卡片效果:
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
// This widget is the root of your application.
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: Scaffold(
appBar: AppBar(title: const Text("Flutter App")),
body: const LayoutDemo(),
),
);
}
}
class LayoutDemo extends StatelessWidget {
const LayoutDemo({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return ListView(
children: [
Card(
shape: RoundedRectangleBorder(
borderRadius:BorderRadius.circular(10)
),
elevation: 20,
margin: const EdgeInsets.all(10),
child: Column(
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: Image.network(
"https://www.itying.com/images/flutter/3.png",
fit: BoxFit.cover),
),
ListTile(
leading: ClipOval(
child:Image.network(
"https://www.itying.com/images/flutter/3.png",
fit: BoxFit.cover,
height: 40,
width: 40,
),
),
title: const Text("xxxxxxxxx"),
subtitle: const Text("xxxxxxxxx"),
)
],
),
),
Card(
shape: RoundedRectangleBorder(
borderRadius:BorderRadius.circular(10)
),
elevation: 20,
margin: const EdgeInsets.all(10),
child: Column(
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: Image.network(
"https://www.itying.com/images/flutter/3.png",
fit: BoxFit.cover),
),
const ListTile(
leading: CircleAvatar(
backgroundImage: NetworkImage("https://www.itying.com/images/flutter/4.png"),
),
title: Text("xxxxxxxxx"),
subtitle: Text("xxxxxxxxx"),
)
],
),
)
],
);
}
}
LayoutBuilder
通过 LayoutBuilder
,我们可以在布局过程中拿到父组件传递的约束信息,然后我们可以根据约束信息动态的构建不同的布局。
比如我们实现一个响应式的 Column
组件 ResponsiveColumn
,它的功能是当当前可用的宽度小于 200
时,将子组件显示为一列,否则显示为两列。简单来实现一下:
class ResponsiveColumn extends StatelessWidget {
const ResponsiveColumn({Key? key, required this.children}) : super(key: key);
final List<Widget> children;
Widget build(BuildContext context) {
// 通过 LayoutBuilder 拿到父组件传递的约束,然后判断 maxWidth 是否小于200
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
if (constraints.maxWidth < 200) { // 最大宽度小于200,显示单列
return Column(children: children, mainAxisSize: MainAxisSize.min);
} else { // 大于200,显示双列
var _children = <Widget>[];
for (var i = 0; i < children.length; i += 2) {
if (i + 1 < children.length) {
_children.add(Row(
children: [children[i], children[i + 1]],
mainAxisSize: MainAxisSize.min,
));
} else {
_children.add(children[i]);
}
}
return Column(children: _children, mainAxisSize: MainAxisSize.min);
}
},
);
}
}
class LayoutBuilderRoute extends StatelessWidget {
const LayoutBuilderRoute({Key? key}) : super(key: key);
Widget build(BuildContext context) {
var _children = List.filled(6, Text("A"));
// Column在本示例中在水平方向的最大宽度为屏幕的宽度
return Column(
children: [
// 限制宽度为190,小于 200
SizedBox(width: 190, child: ResponsiveColumn(children: _children)),
ResponsiveColumn(children: _children),
LayoutLogPrint(child:Text("xx")) // 下面介绍
],
);
}
}
可以发现 LayoutBuilder
的使用很简单,但是不要小看它,因为它非常实用且重要,它主要有两个使用场景:
- 可以使用
LayoutBuilder
来根据设备的尺寸来实现响应式布局。 LayoutBuilder
可以帮我们高效排查问题。比如我们在遇到布局问题或者想调试组件树中某一个节点布局的约束时LayoutBuilder
就很有用。
打印布局时的约束信息
为了便于排错,我们封装一个能打印父组件传递给子组件约束的组件:
class LayoutLogPrint<T> extends StatelessWidget {
const LayoutLogPrint({
Key? key,
this.tag,
required this.child,
}) : super(key: key);
final Widget child;
final T? tag; //指定日志tag
Widget build(BuildContext context) {
return LayoutBuilder(builder: (_, constraints) {
// assert在编译release版本时会被去除
assert(() {
print('${tag ?? key ?? child}: $constraints');
return true;
}());
return child;
});
}
}
这样,我们就可以使用 LayoutLogPrint
组件树中任意位置的约束信息,比如:
LayoutLogPrint(child:Text("xx"))
控制台输出:
flutter: Text("xx"): BoxConstraints(0.0<=w<=428.0, 0.0<=h<=823.0)
可以看到 Text("xx")
的显示空间最大宽度为 428,最大高度为 823 。
注意!我们的大前提是盒模型布局,如果是
Sliver
布局,可以使用SliverLayoutBuiler
来打印。
运行效果:
Flutter 的 build 和 layout
通过观察 LayoutBuilder
的示例,我们还可以发现一个关于 Flutter 构建(build)和 布局(layout)的结论:Flutter 的 build 和 layout 是可以交错执行的,并不是严格的按照先 build 再 layout 的顺序。比如在上例中 ,在build过程中遇到了 LayoutBuilder
组件,而 LayoutBuilder
的 builder
是在 layout 阶段执行的(layout阶段才能取到布局过程的约束信息),在 builder
中新新建了一个 widget
后,Flutter 框架随后会调用该 widget
的 build
方法,又进入了 build 阶段。
AfterLayout
1. 获取组件大小和相对于屏幕的坐标
Flutter 是响应式UI框架,而命令式UI框架最大的不同就是:大多数情况下开发者只需要关注数据的变化,数据变化后框架会自动重新构建UI而不需要开发者手动去操作每一个组件,所以我们会发现 Widget 会被定义为不可变的(immutable),并且没有提供任何操作组件的 API,因此如果我们想在 Flutter 中获取某个组件的大小和位置就会很困难,当然大多数情况下不会有这个需求,但总有一些场景会需要,而在命令式UI框架中是不会存在这个问题的。
我们知道,只有当布局完成时,每个组件的大小和位置才能确定,所以获取的时机肯定是布局完成后,那布局完成的时机如何获取呢?至少事件分发肯定是在布局完成之后的,比如:
Builder(
builder: (context) {
return GestureDetector(
child: Text('flutter'),
onTap: () => print(context.size), //打印 text 的大小
);
},
),
context.size
可以获取当前上下文 RenderObject
的大小,对于Builder
、StatelessWidget
以及 StatefulWidget
这样没有对应 RenderObject
的组件(这些组件只是用于组合和代理组件,本身并没有布局和绘制逻辑),获取的是子代中第一个拥有 RenderObject
组件的 RenderObject
对象。
虽然事件点击时可以拿到组件大小,但有两个问题,第一是需要用户手动触发,第二是时机较晚,更多的时候我们更希望在布局一结束就去获取大小和位置信息,为了解决这个问题,我们可以自己封装一个 AfterLayout
组件,它可以在子组件布局完成后执行一个回调,并同时将 RenderObject
对象作为参数传递。
以下是 AfterLayout
实现源码:
typedef AfterLayoutCallback = Function(RenderAfterLayout ral);
/// A widget can retrieve its render object after layout.
///
/// Sometimes we need to do something after the build phase is complete,
/// for example, most of [RenderObject] methods and attributes, such as
/// `renderObject.size`、`renderObject.localToGlobal(...)` only can be used
/// after build.
///
/// Call `setState` in callback is **allowed**, it is safe!
class AfterLayout extends SingleChildRenderObjectWidget {
const AfterLayout({Key? key, required this.callback, Widget? child,}) : super(key: key, child: child);
RenderObject createRenderObject(BuildContext context) {
return RenderAfterLayout(callback);
}
void updateRenderObject(context, RenderAfterLayout renderObject) {
renderObject.callback = callback;
}
/// 组件树布局结束后会被触发,注意,并不是当前组件布局结束后触发
/// [callback] will be triggered after the layout phase ends.
final AfterLayoutCallback callback;
}
class RenderAfterLayout extends RenderProxyBox {
RenderAfterLayout(this.callback);
ValueSetter<RenderAfterLayout> callback;
void performLayout() {
super.performLayout();
// 不能直接回调callback,在 frame 结束的时候再去触发回调。
SchedulerBinding.instance.addPostFrameCallback((timeStamp) => callback(this));
}
/// 组件在在屏幕坐标中的起始偏移坐标
Offset get offset => localToGlobal(Offset.zero);
/// 组件在屏幕上占有的矩形空间区域
Rect get rect => offset & size;
}
AfterLayout
可以在布局结束后拿到子组件的代理渲染对象 (RenderAfterLayout
), RenderAfterLayout
对象会代理子组件渲染对象 ,因此,通过RenderAfterLayout
对象也就可以获取到子组件渲染对象上的属性,比如件大小、位置等。
上面代码有三点需要注意:
-
callback
调用时机不是在子组件完成布局后就立即调用,原因是子组件布局完成后可能还有其他组件未完成布局,如果此时调用callback
,一旦callback
中存在触发更新的代码(比如调用了setState
)则会报错。因此我们在frame
结束的时候再去触发回调。 -
RenderAfterLayout
的performLayout
方法中直接调用了父类RenderProxyBox
的performLayout
方法:
void performLayout() {
if (child != null) {
child!.layout(constraints, parentUsesSize: true);
size = child!.size;
} else {
size = computeSizeForNoChild(constraints);
}
}
可以看到是直接将父组件传给自身的约束传递给子组件,并将子组件的大小设置为自身大小。也就是说 RenderAfterLayout
的大小和其子组件大小是相同的
- 我们定义了
offset
和rect
两个属性,它们是组件相对于屏幕的的位置偏移和占用的矩形空间范围。
AfterLayout
使用示例:
AfterLayout(
callback: (RenderAfterLayout ral) {
print(ral.size); //子组件的大小
print(ral.offset); // 子组件在屏幕中坐标
},
child: Text('flutter'),
),
运行后控制台输出:
flutter: Size(105.0, 17.0)
flutter: Offset(42.5, 290.0)
可以看到 Text
文本的实际长度是 105,高度是 17,它的起始位置坐标是(42.5, 290.0)。
2. 获取组件相对于某个父组件的坐标
RenderAfterLayout
类继承自 RenderBox
,RenderBox
有一个 localToGlobal
方法,它可以将坐标转化为相对与指定的祖先节点的坐标,比如下面代码可以打印出 Text('A')
在 父 Container
中的坐标:
Builder(builder: (context) {
return Container(
color: Colors.grey.shade200,
alignment: Alignment.center,
width: 100,
height: 100,
child: AfterLayout(
callback: (RenderAfterLayout ral) {
Offset offset = ral.localToGlobal(
Offset.zero,
// 传一个父元素 Container 对应的 RenderObject 对象
ancestor: context.findRenderObject(),
);
print('A 在 Container 中占用的空间范围为:${offset & ral.size}');
},
child: Text('A'),
),
);
}),
下面是一个 AfterLayout
的完整测试示例:
class AfterLayoutRoute extends StatefulWidget {
const AfterLayoutRoute({Key? key}) : super(key: key);
_AfterLayoutRouteState createState() => _AfterLayoutRouteState();
}
class _AfterLayoutRouteState extends State<AfterLayoutRoute> {
String _text = 'flutter 实战 ';
Size _size = Size.zero;
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Builder(
builder: (context) {
return GestureDetector(
child: Text(
'Text1: 点我获取我的大小',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.blue),
),
onTap: () => print('Text1: ${context.size}'),
);
},
),
),
AfterLayout(
callback: (RenderAfterLayout ral) {
print('Text2: ${ral.size}, ${ral.offset}');
},
child: Text('Text2:flutter@wendux'),
),
Builder(builder: (context) {
return Container(
color: Colors.grey.shade200,
alignment: Alignment.center,
width: 100,
height: 100,
child: AfterLayout(
callback: (RenderAfterLayout ral) {
Offset offset = ral.localToGlobal(
Offset.zero,
ancestor: context.findRenderObject(),
);
print('A 在 Container 中占用的空间范围为:${offset & ral.size}');
},
child: Text('A'),
),
);
}),
Divider(),
AfterLayout(
child: Text(_text),
callback: (RenderAfterLayout value) {
setState(() {
//更新尺寸信息
_size = value.size;
});
},
),
//显示上面 Text 的尺寸
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
'Text size: $_size ',
style: TextStyle(color: Colors.blue),
),
),
ElevatedButton(
onPressed: () {
setState(() {
_text += 'flutter 实战 ';
});
},
child: Text('追加字符串'),
),
],
);
}
}
运行效果:
运行后点击 Text1 就可以在日志面板看到它的大小。点击 “追加字符串” 按钮,字符串大小变化后,屏幕上上也会显示变化后的文本区域大小(按钮上方挨着)。
参考:《Flutter实战·第二版》