Widget 简介(了解)

Widget 概念

在前面的介绍中,我们知道在Flutter中几乎所有的对象都是一个 widget 。与原生开发中“控件”不同的是,Flutter 中的 widget 的概念更广泛,它不仅可以表示UI元素,也可以表示一些功能性的组件如:用于手势检测的 GestureDetector 、用于APP主题数据传递的 Theme 等等,而原生开发中的控件通常只是指UI元素。在后面的内容中,我们在描述UI元素时可能会用到“控件”、“组件”这样的概念,读者心里需要知道他们就是 widget ,只是在不同场景的不同表述而已。由于 Flutter 主要就是用于构建用户界面的,所以,在大多数时候,读者可以认为 widget 就是一个控件,不必纠结于概念。

Flutter 中是通过 Widget 嵌套 Widget 的方式来构建UI和进行实践处理的,所以记住,Flutter 中万物皆为Widget。

Flutter中的树(比较抽象,尽量理解即可)

既然 Widget 只是描述一个UI元素的配置信息,那么真正的布局、绘制是由谁来完成的呢?Flutter 框架的的处理流程是这样的:

  1. 根据 Widget 树生成一个 Element 树,Element 树中的节点都继承自 Element 类。
  2. 根据 Element 树生成 Render 树(渲染树),渲染树中的节点都继承自RenderObject 类。
  3. 根据渲染树生成 Layer 树,然后上屏显示,Layer 树中的节点都继承自 Layer 类。

真正的布局和渲染逻辑在 Render 树中,Element 是 Widget 和 RenderObject 的粘合剂,可以理解为一个中间代理。我们通过一个例子来说明,假设有如下 Widget 树:

1
2
3
4
5
6
7
8
9
Container( // 一个容器 widget
color: Colors.blue, // 设置容器背景色
child: Row( // 可以将子widget沿水平方向排列
children: [
Image.network('https://www.example.com/1.png'), // 显示图片的 widget
const Text('A'),
],
),
);

注意,如果 Container 设置了背景色,Container 内部会创建一个新的 ColoredBox 来填充背景,相关逻辑如下:

1
2
if (color != null)
current = ColoredBox(color: color!, child: current);

而 Image 内部会通过 RawImage 来渲染图片、Text 内部会通过 RichText 来渲染文本,所以最终的 Widget树、Element 树、渲染树结构如下:

这里需要注意:

  1. 三棵树中,Widget 和 Element 是一一对应的,但并不和 RenderObject 一一对应。比如 StatelessWidgetStatefulWidget 都没有对应的 RenderObject。
  2. 渲染树在上屏前会生成一棵 Layer 树。
  3. 以上的内容比较涉及底层原理,这里只是让大家了解一下界面渲染原理,不必深究。

Context

build方法有一个context参数,它是BuildContext类的一个实例,表示当前 widget 在 widget 树中的上下文,每一个 widget 都会对应一个 context 对象(因为每一个 widget 都是 widget 树上的一个节点)。实际上,context是当前 widget 在 widget 树中位置中执行”相关操作“的一个句柄(handle),比如它提供了从当前 widget 开始向上遍历 widget 树以及按照 widget 类型查找父级 widget 的方法。下面是在子树中获取父级 widget 的一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ContextRoute extends StatelessWidget  {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Context测试"),
),
body: Container(
child: Builder(builder: (context) {
// 在 widget 树中向上查找最近的父级`Scaffold` widget
Scaffold scaffold = context.findAncestorWidgetOfExactType<Scaffold>();
// 直接返回 AppBar的title, 此处实际上是Text("Context测试")
return (scaffold.appBar as AppBar).title;
}),
),
);
}
}

运行后效果

通过Context获取State对象

context对象有一个findAncestorStateOfType()方法,该方法可以从当前节点沿着 widget 树向上查找指定类型的 StatefulWidget 对应的 State 对象。通常用于在子 widget 树中获取父级 StatefulWidget 的State 对象。

一般来说,如果 StatefulWidget 的状态是私有的(不应该向外部暴露),那么我们代码中就不应该去直接获取其 State 对象;如果StatefulWidget的状态是希望暴露出的(通常还有一些组件的操作方法),我们则可以去直接获取其State对象。但是通过 context.findAncestorStateOfType 获取 StatefulWidget 的状态的方法是通用的,我们并不能在语法层面指定 StatefulWidget 的状态是否私有,所以在 Flutter 开发中便有了一个默认的约定:**如果 StatefulWidget 的状态是希望暴露出的,应当在 StatefulWidget 中提供一个of 静态方法来获取其 State 对象,开发者便可直接通过该方法来获取;如果 State不希望暴露,则不提供of方法。**这个约定在 Flutter SDK 里随处可见。

但其实也可以通过GlobalKey去获取,但是代价比较大,加上key的概念更加抽象难以理解,也不做解释。

StatefulWidget 生命周期:

跟安卓相比,基本一模一样。

热重载

Flutter的热重载(hot reload)功能可以帮助您在无需重新启动应用的情况下快速、轻松地进行测试、构建用户界面、添加功能以及修复错误。 通过将更新后的源代码文件注入正在运行的Dart虚拟机(VM)中来实现热重载。在虚拟机使用新的的字段和函数更新类后,Flutter框架会自动重新构建widget树,以便您快速查看更改的效果。

要热重载一个Flutter应用程序:

  1. 从受支持的IntelliJ IDE或终端窗口运行应用程序。物理机或虚拟器都可以运行。

  2. 修改项目中的一个Dart文件。大多数类型的代码更改可以重新加载; 有关需要完全重新启动的更改列表,请参阅限制

  3. 单击工具栏上的Hot Reload按钮。

如果您正在使用命令行flutter run运行应用程序,请在终端窗口输入r

成功执行热重载后,您将在控制台中看到类似于以下内容的消息:

1
2
Performing hot reload...
Reloaded 1 of 448 libraries in 2,777ms.

在android studio中的位置:

比较简单,可以自行尝试

路由管理

路由(Route)在移动开发中通常指页面(Page),这跟 Web 开发中单页应用的 Route 概念意义是相同的,Route 在 Android中 通常指一个 Activity,在 iOS 中指一个 ViewController。所谓路由管理,就是管理页面之间如何跳转,通常也可被称为导航管理。Flutter 中的路由管理和原生开发类似,无论是 Android 还是 iOS,导航管理都会维护一个路由栈,路由入栈(push)操作对应打开一个新页面,路由出栈(pop)操作对应页面关闭操作,而路由管理主要是指如何来管理路由栈

同样地,我们在原生中常常遇到一个界面在不可见之后是否要销毁的问题以及清空路由栈等等操作,而这些操作在Flutter中更加便利,我们可以直接调用Navigator进行统一的路由管理,其中的操作其实也不仅限于push与pop。

Navigator是一个路由管理的组件,它提供了打开和退出路由页方法。Navigator通过一个栈来管理活动路由集合,其实就是路由栈。通常当前屏幕显示的页面就是栈顶的路由。Navigator提供了一系列方法来管理路由栈,在此我们只介绍其最常用的两个方法:

Future push(BuildContext context, Route route)

将给定的路由入栈(即打开新的页面),返回值是一个Future对象,用以接收新路由出栈(即关闭)时的返回数据。但是实际上,如果你仅仅是想打开一个新的界面,完全可以这样:

1
2
3
4
5
6
7
IconButton(
onPressed:(){
Navigator.push(context, MaterialPageRoute(builder: (context) {
return NewRoute();
}),);
}
),

意味着将这个界面打开(入栈)。

bool pop(BuildContext context, [ result ])

将栈顶路由出栈,也就是关闭当前界面,result 为页面关闭时返回给上一个页面的数据。

同上,如果没有什么需求,直接这样就行:

1
2
3
4
5
IconButton(
onPressed:(){
Navigator.pop(context);
}
),

PS.

Navigator 还有很多其它方法,如Navigator.replaceNavigator.popUntil等,通过这些方法的使用甚至组合使用,其实可以解决很多逻辑上的难题,详情自行使用搜索引擎进行查阅,在此不再赘述,路由逻辑上的问题,只要能够发现,一般都会有现成的比较成熟的解决方案。下面我们还需要介绍一下路由相关的另一个概念“命名路由”。

实例方法

Navigator类中第一个参数为context的静态方法都对应一个Navigator的实例方法, 比如Navigator.push(BuildContext context, Route route)等价于Navigator.of(context).push(Route route) ,下面命名路由相关的方法也是一样的。这个在上面的context与widget树部分有提到。

路由传值

很多时候,在路由跳转时我们需要带一些参数,比如打开商品详情页时,我们需要带一个商品id,这样商品详情页才知道展示哪个商品信息;又比如我们在填写订单时需要选择收货地址,打开地址选择页并选择地址后,可以将用户选择的地址返回到订单页等等。下面我们通过一个简单的示例来演示新旧路由如何传参。

示例

我们创建一个TipRoute路由,它接受一个提示文本参数,负责将传入它的文本显示在页面上,另外TipRoute中我们添加一个“返回”按钮,点击后在返回上一个路由的同时会带上一个返回参数,下面我们看一下实现代码。

TipRoute实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class TipRoute extends StatelessWidget {
TipRoute({
Key key,
@required this.text, // 接收一个text参数
}) : super(key: key);
final String text;

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("提示"),
),
body: Padding(
padding: EdgeInsets.all(18),
child: Center(
child: Column(
children: <Widget>[
Text(text),
ElevatedButton(
onPressed: () => Navigator.pop(context, "我是返回值"),
child: Text("返回"),
)
],
),
),
),
);
}
}

下面是打开新路由TipRoute的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class RouterTestRoute extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: ElevatedButton(
onPressed: () async {
// 打开`TipRoute`,并等待返回结果
var result = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return TipRoute(
// 路由参数
text: "我是提示xxxx",
);
},
),
);
//输出`TipRoute`路由返回结果
print("路由返回值: $result");
},
child: Text("打开提示页"),
),
);
}
}

运行上面代码,点击RouterTestRoute页的“打开提示页”按钮,会打开TipRoute

需要说明:

  1. 提示文案“我是提示xxxx”是通过TipRoutetext参数传递给新路由页的。我们可以通过等待Navigator.push(…)返回的Future来获取新路由的返回数据。

  2. TipRoute页中有两种方式可以返回到上一页;第一种方式是直接点击导航栏返回箭头,第二种方式是点击页面中的“返回”按钮。这两种返回方式的区别是前者不会返回数据给上一个路由,而后者会。下面是分别点击页面中的返回按钮和导航栏返回箭头后,RouterTestRoute页中print方法在控制台输出的内容:

    1
    2
    I/flutter (27896): 路由返回值: 我是返回值
    I/flutter (27896): 路由返回值: null

上面介绍的是非命名路由的传值方式,命名路由的传值方式会有所不同,我们会在下面介绍命名路由时介绍。

命名路由

所谓“命名路由”(Named Route)即有名字的路由,我们可以先给路由起一个名字,然后就可以通过路由名字直接打开新的路由了,这为路由管理带来了一种直观、简单的方式。其实这种方式只是方便对路由进行统一管理,可以理解为键值对,与直接使用并无太大区别。通过使用命名路由,可以将路由与跳转的界面进行解耦,方便修改目标界面(不过小型项目倒无所谓)。

路由表

要想使用命名路由,我们必须先提供并注册一个路由表(routing table),这样应用程序才知道哪个名字与哪个路由组件相对应。路由表的定义如下:

1
Map<String, WidgetBuilder> routes;

它是一个Map,key为路由的名字,是个字符串;value是个builder回调函数,用于生成相应的路由widget。我们在通过路由名字打开新路由时,应用会根据路由名字在路由表中查找到对应的WidgetBuilder回调函数,然后调用该回调函数生成路由widget并返回。

注册路由表

路由表的注册方式很简单,我们回到之前“计数器”的示例,然后在MyApp类的build方法中找到MaterialApp,添加routes属性,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
//注册路由表
routes:{
"new_page":(context) => NewRoute(),
... // 省略其它路由注册信息
} ,
home: MyHomePage(title: 'Flutter Demo Home Page'),
);

现在我们就完成了路由表的注册。上面的代码中home路由并没有使用命名路由,如果我们也想将home注册为命名路由应该怎么做呢?其实很简单,直接看代码:

1
2
3
4
5
6
7
8
9
10
11
12
MaterialApp(
title: 'Flutter Demo',
initialRoute:"/", //名为"/"的路由作为应用的home(首页)
theme: ThemeData(
primarySwatch: Colors.blue,
),
//注册路由表
routes:{
"new_page":(context) => NewRoute(),
"/":(context) => MyHomePage(title: 'Flutter Demo Home Page'), //注册首页路由
}
);

可以看到,我们只需在路由表中注册一下MyHomePage路由,然后将其名字作为MaterialAppinitialRoute属性值即可,该属性决定应用的初始路由页是哪一个命名路由。

通过路由名打开新路由页

要通过路由名称来打开新路由,可以使用NavigatorpushNamed方法:

1
Future pushNamed(BuildContext context, String routeName,{Object arguments})

Navigator 除了pushNamed方法,还有pushReplacementNamed等其他管理命名路由的方法,可以自行使用搜索引擎查看他们的作用。接下来我们通过路由名来打开新的路由页,修改TextButtononPressed回调代码,改为:

1
2
3
4
5
6
7
onPressed: () {
Navigator.pushNamed(context, "new_page");
//Navigator.push(context,
// MaterialPageRoute(builder: (context) {
// return NewRoute();
//}));
},

热重载应用,再次点击“open new route”按钮,依然可以打开新的路由页。

命名路由参数传递

在Flutter最初的版本中,命名路由是不能传递参数的,后来才支持了参数;下面展示命名路由如何传递并获取路由参数:

我们先注册一个路由:

1
2
3
routes:{
"new_page":(context) => EchoRoute(),
} ,

在路由页通过RouteSetting对象获取路由参数:

1
2
3
4
5
6
7
8
9
class EchoRoute extends StatelessWidget {

@override
Widget build(BuildContext context) {
//获取路由参数
var args=ModalRoute.of(context).settings.arguments;
//...省略无关代码
}
}

在打开路由时传递参数

1
Navigator.of(context).pushNamed("new_page", arguments: "hi");

PS.

关于一些路由的基本逻辑操作,可以参考这篇博客

布局逻辑

在原生开发的时候一直提倡大家用LinearLayout,其中一个原因就是Flutter的默认布局逻辑基本就是LinearLayout,所以在原生使用相对布局的同学可能会比较难受,不过用习惯了就好,毕竟这个布局逻辑是很容易接受的。

如果大家看《Flutter实战》,在布局类组件(第四章)可以先忽略4.1和4.2,这两部分可能对大家来说特别陌生、难以接受,不过还是推荐了解一下SizedBox,这个容器简单好用。

下面主要说一下线性布局与弹性布局。

线性布局

Flutter 中通过RowColumn来实现线性布局,类似于Android 中的LinearLayout控件。RowColumn都继承自Flex,我们将在弹性布局一节中详细介绍Flex

主轴和纵轴

对于线性布局,有主轴纵轴之分,如果布局是沿水平方向,那么主轴就是指水平方向,而纵轴即垂直方向;如果布局沿垂直方向,那么主轴就是指垂直方向,而纵轴就是水平方向。在线性布局中,有两个定义对齐方式的枚举类MainAxisAlignmentCrossAxisAlignment,分别代表主轴对齐和纵轴对齐。

Row

Row可以沿水平方向排列其子widget。定义如下:

1
2
3
4
5
6
7
8
9
Row({
...
TextDirection textDirection,
MainAxisSize mainAxisSize = MainAxisSize.max,
MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
VerticalDirection verticalDirection = VerticalDirection.down,
CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center,
List<Widget> children = const <Widget>[],
})
  • textDirection:表示水平方向子组件的布局顺序(是从左往右还是从右往左),默认为系统当前Locale环境的文本方向(如中文、英语都是从左往右,而阿拉伯语是从右往左)。
  • mainAxisSize表示Row在主轴(水平)方向占用的空间,默认是MainAxisSize.max,表示尽可能多的占用水平方向的空间,此时无论子 widgets 实际占用多少水平空间,Row的宽度始终等于水平方向的最大宽度;而MainAxisSize.min表示尽可能少的占用水平空间,当子组件没有占满水平剩余空间,则Row的实际宽度等于所有子组件占用的的水平空间;
  • mainAxisAlignment表示子组件在Row所占用的水平空间内对齐方式,如果mainAxisSize值为MainAxisSize.min,则此属性无意义,因为子组件的宽度等于Row的宽度。只有当mainAxisSize的值为MainAxisSize.max时,此属性才有意义,MainAxisAlignment.start表示沿textDirection的初始方向对齐,如textDirection取值为TextDirection.ltr时,则MainAxisAlignment.start表示左对齐,textDirection取值为TextDirection.rtl时表示从右对齐。而MainAxisAlignment.endMainAxisAlignment.start正好相反;MainAxisAlignment.center表示居中对齐。读者可以这么理解:textDirectionmainAxisAlignment的参考系。
  • verticalDirection:表示Row纵轴(垂直)的对齐方向,默认是VerticalDirection.down,表示从上到下。
  • crossAxisAlignment:表示子组件在纵轴方向的对齐方式,Row的高度等于子组件中最高的子元素高度,它的取值和MainAxisAlignment一样(包含startendcenter三个值),不同的是crossAxisAlignment的参考系是verticalDirection,即verticalDirection值为VerticalDirection.downcrossAxisAlignment.start指顶部对齐,verticalDirection值为VerticalDirection.up时,crossAxisAlignment.start指底部对齐;而crossAxisAlignment.endcrossAxisAlignment.start正好相反;
  • children :子组件数组。

示例

请阅读下面代码,先想象一下运行的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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.minRow的宽度等于两个Text的宽度和,所以对齐是无意义的,所以会从左往右显示;第三个Row设置textDirection值为TextDirection.rtl,所以子组件会从右向左的顺序排列,而此时MainAxisAlignment.end表示左对齐,所以最终显示结果就是图中第三行的样子;第四个 Row 测试的是纵轴的对齐方式,由于两个子 Text 字体不一样,所以其高度也不同,我们指定了verticalDirection值为VerticalDirection.up,即从低向顶排列,而此时crossAxisAlignment值为CrossAxisAlignment.start表示底对齐。

Column

Column可以在垂直方向排列其子组件。参数和Row一样,不同的是布局方向为垂直,主轴纵轴正好相反,读者可类比Row来理解,下面看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import 'package:flutter/material.dart';

class CenterColumnRoute extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
Text("hi"),
Text("world"),
],
);
}
}

运行效果如图所示:

解释:

  • 由于我们没有指定ColumnmainAxisSize,所以使用默认值MainAxisSize.max,则Column会在垂直方向占用尽可能多的空间,此例中会占满整个屏幕高度。
  • 由于我们指定了 crossAxisAlignment 属性为CrossAxisAlignment.center,那么子项在Column纵轴方向(此时为水平方向)会居中对齐。注意,在水平方向对齐是有边界的,总宽度为Column占用空间的实际宽度,而实际的宽度取决于子项中宽度最大的Widget。在本例中,Column有两个子Widget,而显示“world”的Text宽度最大,所以Column的实际宽度则为Text("world") 的宽度,所以居中对齐后Text("hi")会显示在Text("world")的中间部分。

实际上,RowColumn都只会在主轴方向占用尽可能大的空间,而纵轴的长度则取决于他们最大子元素的长度。如果我们想让本例中的两个文本控件在整个手机屏幕中间对齐,我们有两种方法:

  • Column的宽度指定为屏幕宽度;这很简单,我们可以通过ConstrainedBoxSizedBox(我们将在后面章节中专门介绍这两个Widget)来强制更改宽度限制,例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    ConstrainedBox(
    constraints: BoxConstraints(minWidth: double.infinity),
    child: Column(
    crossAxisAlignment: CrossAxisAlignment.center,
    children: <Widget>[
    Text("hi"),
    Text("world"),
    ],
    ),
    );

    minWidth设为double.infinity,可以使宽度占用尽可能多的空间。

  • 使用Center 组件;将在后面章节中介绍。

特殊情况

如果Row里面嵌套Row,或者Column里面再嵌套Column,那么只有最外面的RowColumn会占用尽可能大的空间,里面RowColumn所占用的空间为实际大小,下面以Column为例说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 组件:

1
2
3
4
5
6
7
8
9
10
11
12
Expanded( 
child: Container(
color: Colors.red,
child: Column(
mainAxisAlignment: MainAxisAlignment.center, //垂直方向居中对齐
children: <Widget>[
Text("hello world "),
Text("I am Jack "),
],
),
),
)

运行效果如图所示:

我们将在介绍弹性布局时详细介绍Expanded。

弹性布局

弹性布局允许子组件按照一定比例来分配父容器空间。弹性布局的概念在其它UI系统中也都存在,如 H5 中的弹性盒子布局,Android中 的FlexboxLayout等。Flutter 中的弹性布局主要通过FlexExpanded来配合实现。

Flex

Flex组件可以沿着水平或垂直方向排列子组件,如果你知道主轴方向,使用RowColumn会方便一些,因为RowColumn都继承自Flex,参数基本相同,所以能使用Flex的地方基本上都可以使用RowColumnFlex本身功能是很强大的,它也可以和Expanded组件配合实现弹性布局。接下来我们只讨论Flex和弹性布局相关的属性(其它属性已经在介绍RowColumn时介绍过了)。

1
2
3
4
5
Flex({
...
required this.direction, //弹性布局的方向, Row默认为水平方向,Column默认为垂直方向
List<Widget> children = const <Widget>[],
})

Flex继承自MultiChildRenderObjectWidget,对应的RenderObjectRenderFlexRenderFlex中实现了其布局算法。

Expanded

Expanded 只能作为 Flex 的孩子(否则会报错),它可以按比例“扩伸”Flex子组件所占用的空间。因为 RowColumn 继都承自Flex,所以 Expanded 也可以作为它们的孩子。

也就是说,Expanded组件必须用在Row、Column、Flex内,并且从Expanded到封装它的Row、Column、Flex的路径必须只包括StatelessWidgets或StatefulWidgets组件(不能是其他类型的组件,像RenderObjectWidget,它是渲染对象,不再改变尺寸了,因此Expanded不能放进RenderObjectWidget)。

注意一点:在Row中使用Expanded的时候,无法指定Expanded中的子组件的宽度width,但可以指定其高度height。同理,在Column中使用Expanded的时候,无法指定Expanded中的子组件的高度height,可以指定宽度width。

1
2
3
4
const Expanded({
int flex = 1,
required Widget child,
})

flex参数为弹性系数,如果为 0 或null,则child是没有弹性的,即不会被扩伸占用的空间。如果大于0,所有的Expanded按照其 flex 的比例来分割主轴的全部空闲空间。下面我们看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
class FlexLayoutTestRoute extends StatelessWidget {
@override
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的源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Spacer extends StatelessWidget {
const Spacer({Key? key, this.flex = 1})
: assert(flex != null),
assert(flex > 0),
super(key: key);

final int flex;

@override
Widget build(BuildContext context) {
return Expanded(
flex: flex,
child: const SizedBox.shrink(),
);
}
}

小结

弹性布局比较简单,唯一需要注意的就是RowColumn以及Flex的关系。

Expanded经常会出现应用不正确的报错,一般是将Expanded用在了一个无界widget中导致渲染器报错,大家多去尝试、踩坑。

可以参考这个博客去了解一些expanded与col和row的组合使用

PS.

占位 Spacer(),以及油管教学

状态管理

这里提一嘴状态管理,但是初期遇到的状态管理问题可能并不多。

响应式的编程框架中都会有一个永恒的主题——“状态(State)管理”,无论是在 React/Vue(两者都是支持响应式编程的 Web 开发框架)还是 Flutter 中,他们讨论的问题和解决的思想都是一致的。所以,StatefulWidget的状态应该被谁管理?Widget本身?父 Widget ?都会?还是另一个对象?答案是取决于实际情况!以下是管理状态的最常见的方法:

  • Widget 管理自己的状态。
  • Widget 管理子 Widget 状态。
  • 混合管理(父 Widget 和子 Widget 都管理状态)。

实际上,还是以计数器为例,一个很经典的问题就是,点击加号之后如何更新界面?如果你使用setState,当然可以,但是会将整个widget树都更新一遍,但是实际上我们很明确只会更新树中的一个节点的属性,这是很浪费性能的。在者,若果在一个界面上改变了某个值,如何在其余界面上将这个改变表现出来?或者说,如何通知其他界面更新这个值?这都是状态管理的问题。或许你可以重新跳转到那个界面,但同样地,如果这个界面本就在路由栈中,重新构建这个界面会浪费性能,而且可能导致路由错乱。

为此,开发者们可谓耗尽心机,为此做了很多库,比如Provider和GetX,都是很好用的状态管理库,如果有兴趣可以尝试,GetX优先。

参考资料:

《Flutter实战·第二版》

Flutter中文网

https://juejin.cn/post/6844903857697325069

https://www.cnblogs.com/tian874540961/p/10515020.html