文章目录
- [1 布局 约束](#1 布局 约束)
-
- [1.1 ConstrainedBox:父对子添加约束](#1.1 ConstrainedBox:父对子添加约束)
- [1.2 多重限制](#1.2 多重限制)
- [1.3 UnconstrainedBox 解除约束](#1.3 UnconstrainedBox 解除约束)
- [1.4 Row和Column](#1.4 Row和Column)
- [1.5 空间适配 FittedBox](#1.5 空间适配 FittedBox)
- [1.6 ListView中的弹性布局](#1.6 ListView中的弹性布局)
- [1.7 滚动监听及控制](#1.7 滚动监听及控制)
- 以上是通过ScrollController实现滚动监听,还有一种方式,通过NotificationListener,两者的区别在于:
- [1.8 PageView](#1.8 PageView)
- [1.9 TabBarView](#1.9 TabBarView)
- [1.10 SliverToBoxAdapter](#1.10 SliverToBoxAdapter)
- [1.11 NestedScrollView](#1.11 NestedScrollView)
- [2 功能性组件](#2 功能性组件)
-
- [2.1 InheritedWidget](#2.1 InheritedWidget)
- [2.2 跨组件状态共享](#2.2 跨组件状态共享)
-
- [2.2.1 实现一个Provider](#2.2.1 实现一个Provider)
- [2.3 按需rebuild:ValueListenableBuilder](#2.3 按需rebuild:ValueListenableBuilder)
- [2.4 对话框](#2.4 对话框)
- [2.5 对话框状态管理](#2.5 对话框状态管理)
- [3. 事件处理与通知](#3. 事件处理与通知)
-
- [3.1 手势冲突](#3.1 手势冲突)
- [3.2 事件总线](#3.2 事件总线)
- [3.3 通知 Notification](#3.3 通知 Notification)
1 布局 约束
1.1 ConstrainedBox:父对子添加约束

1.2 多重限制
某一个组件有多个父级ConstrainedBox限制,那么最终生效的是:父级的限制都不冲突。
1.3 UnconstrainedBox 解除约束

这正是因为AppBar中已经指定了actions按钮的约束条件,所以我们要自定义loading按钮大小,就必须通过UnconstrainedBox来 "去除" 父元素的限制,代码如下:
实际上将 UnconstrainedBox 换成 Center 或者 Align 也是可以的。
另外,需要注意,UnconstrainedBox 虽然在其子组件布局时可以取消约束(子组件可以为无限大),但是 UnconstrainedBox 自身是受其父组件约束的,所以当 UnconstrainedBox 随着其子组件变大后,如果UnconstrainedBox 的大小超过它父组件约束时,也会导致溢出报错
1.4 Row和Column

如果Row里面嵌套Row,或者Column里面再嵌套Column,那么只有最外面的Row或Column会占用尽可能大的空间,里面Row或Column所占用的空间为实际大小,下面以Column为例说明:
如果要让里面的Column占满外部Column,可以使用Expanded 组件:
1.5 空间适配 FittedBox
适配原理
- FittedBox 在布局子组件时会忽略其父组件传递的约束,可以允许子组件无限大,即FittedBox 传递给子组件的约束为(0<=width<=double.infinity, 0<= height <=double.infinity)。
- FittedBox 对子组件布局结束后就可以获得子组件真实的大小。
- FittedBox 知道子组件的真实大小也知道他父组件的约束,那么FittedBox 就可以通过指定的适配方式(BoxFit 枚举中指定),让起子组件在 FittedBox 父组件的约束范围内按照指定的方式显示。
因为父Container要比子Container 小,所以当没有指定任何适配方式时,子组件会按照其真实大小进行绘制,所以第一个蓝色区域会超出父组件的空间,因而看不到红色区域。第二个我们指定了适配方式为 BoxFit.contain,含义是按照子组件的比例缩放,尽可能多的占据父组件空间,因为子组件的长宽并不相同,所以按照比例缩放适配父组件后,父组件能显示一部分。
要注意一点,在未指定适配方式时,虽然 FittedBox 子组件的大小超过了 FittedBox 父 Container 的空间,但FittedBox 自身还是要遵守其父组件传递的约束,所以最终 FittedBox 的本身的大小是 50×50,这也是为什么蓝色会和下面文本重叠的原因,因为在布局空间内,父Container只占50×50的大小,接下来文本会紧挨着Container进行布局,而此时Container 中有子组件的大小超过了自己,所以最终的效果就是绘制范围超出了Container,但布局位置是正常的,所以就重叠了。如果我们不想让蓝色超出父组件布局范围,那么可以可以使用 ClipRect 对超出的部分剪裁掉即可:
当 Row 没有被 FittedBox 包裹时,此时父组件传给 Row 的约束的 maxWidth 为屏幕宽度,此时,Row 的宽度也就是屏幕宽度,而当被FittedBox 包裹时,FittedBox 传给 Row 的约束的 maxWidth 为无限大(double.infinity),因此Row 的最终宽度就是子组件的宽度之和。
1.6 ListView中的弹性布局
在使用ListView构建每一个子项时候,要指明ListView的高度界限,如果不指明会报错,但如果写死当尺寸变化时又不利于调整(尺寸变化指:ListView显示的为屏幕高 - 导航栏 - 状态栏,当这些尺寸变化时,ListView的高也会变化)。怎么动态适应?
使用Flex + Expanded
。自动拉伸组件大小。Column和Row继承Flex,所以可以使用Column + Expanded 实现自动拉伸组件大小
。
1.7 滚动监听及控制
需求:ListView滑动显示,当显示到1000 dp 时候,显示一个向上图标,点击时回到起始位置,没到1000 dp 时不显示此图标。
分析:在初始化Wodget时就监听滚动事件,然后判断 offset,当达到 1000 dp时候将 按钮显示标志位设置为 true(使用setState),随后触发重建,判断标志位变为 ture后 显示 按钮,点击按钮时候回到顶部。
以上是通过ScrollController实现滚动监听,还有一种方式,通过NotificationListener,两者的区别在于:
- NotificationListener可以在可滚动组件到widget树根之间
任意位置监听
。而ScrollController只能和具体的可滚动组件关联后
才可以。 - 收到滚动事件后获得的信息不同;NotificationListener在收到滚动事件时,通知中会携带
当前滚动位置和ViewPort
的一些信息,而ScrollController只能获取当前滚动位置
。
1.8 PageView
可以实现:Tab 换页
效果、图片轮动
以及抖音上下滑页
切换视频功能
PageView 不像ListView和GridView,默认没有缓存。但是在真实的业务场景中,对页面进行缓存是很常见的一个需求,比如一个新闻 App,下面有很多频道页,如果不支持页面缓存,则一旦滑到新的频道旧的频道页就会销毁,滑回去时又得重新请求数据和构建页面,这谁扛得住!
可使用可滚动组件子项缓存
可使用组件库 KeepAliveWrapper 详见:https://book.flutterchina.club/chapter6/keepalive.html#_6-8-1-automatickeepalive
1.9 TabBarView
TabBarView 是 Material 组件库中提供了 Tab 布局组件
,通常和 TabBar 配合使用
TabBar 为 TabBarView 的导航标题。TabController 用于监听和控制 TabBarView 的页面切换,通常和 TabBar 联动。如果没有指定,则会在组件树中向上查找并使用最近的一个 DefaultTabController 。
1.10 SliverToBoxAdapter
在实际布局中,我们通常需要往 CustomScrollView 中添加一些自定义的组件,而这些组件并非都有 Sliver 版本,为此 Flutter 提供了一个 SliverToBoxAdapter 组件,它是一个适配器:可以将 RenderBox 适配为 Sliver
注意:如果 CustomScrollView 有孩子也是一个完整的可滚动组件且它们的滑动方向一致,则 CustomScrollView 不能正常工作。要解决这个问题,可以使用 NestedScrollView。详见:https://book.flutterchina.club/chapter6/custom_scrollview.html#_6-10-2-flutter-中常用的-sliver
总结:
- CustomScrollView 组合 Sliver 的原理是为所有子 Sliver 提供一个
共享
的 Scrollable,然后统一处理指定滑动方向
的滑动事件。 - CustomScrollView 和 ListView、GridView、PageView 一样,都是完整的可滚动组件(同时拥有 Scrollable、Viewport、Sliver)。
- CustomScrollView
只能组合 Sliver
,如果有孩子也是一个完整的可滚动组件(通过SliverToBoxAdapter
嵌入)且它们的滑动方向一致时便不能正常工作
。
Sliver布局模型和盒布局模型
- 相同点:两者布局流程基本相同:父组件告诉子组件约束信息 > 子组件根据父组件的约束确定自生大小 > 父组件获得子组件大小调整其位置。不同是:
- 不同点:
*- 父组件传递给子组件的约束信息不同。盒模型传递的是 BoxConstraints,而 Sliver 传递的是 SliverConstraints。
-
- 描述子组件布局信息的对象不同。盒模型的布局信息通过 Size 和 offset描述 ,而 Sliver的是通过 SliverGeometry 描述。
-
- 布局的起点不同。Sliver布局的起点一般是Viewport ,而盒模型布局的起点可以是任意的组件。
自定义Sliver
详见:https://book.flutterchina.club/chapter6/sliver.html#_6-11-1-sliver-布局协议
Sliver 的布局协议如下:
- Viewport 将当前布局和配置信息通过 SliverConstraints 传递给 Sliver。
- Sliver 确定自身的位置、绘制等信息,保存在 geometry 中(一个 SliverGeometry 类型的对象)。
- Viewport 读取 geometry 中的信息来对 Sliver 进行布局和绘制
1.11 NestedScrollView

2 功能性组件
2.1 InheritedWidget
InheritedWidget是Flutter中非常重要的一个Widget,像国际化、主题等都是通过它来实现
作用:父组件共享数据给子组件
流程:父组件继承InheritedWidget 并声明想共享的数据,提供一个方法来供给子树获取数据,并重写updateShouldNotify来决定当数据发生改变时,是否通知子树中依赖该数据的Widget重新build。子组件build方法中依赖了父组件中的数据时,这部分数据将会局部更新,并且重写的didChangeDependencies方法将会被调用。
2.2 跨组件状态共享
Provider:InheritedWidget的天生特性就是能绑定InheritedWidget与依赖它的子孙组件的依赖关系,并且当InheritedWidget数据发生变化时,可以自动更新依赖的子孙组件!利用这个特性,可以将需要跨组件共享的状态保存在InheritedWidget
中,然后在子组件中引用InheritedWidget
即可。
2.2.1 实现一个Provider
总体流程:定义一个保存共享数据的组件,继承InheritedWidget(子组件依赖的共享数据变化时,能够局部更新
子组件 UI);基于发布订阅模式监听该共享数据,当数据改变时就会发布事件,监听到该事件后重建保存共享数据的组件,这样数据就是最新的数据;这样,所有依赖共享数据的子组件将局部更新。
- 定义一个能够保存共享数据的组件(继承InheritedWidget)
- 当数据发生变化时,重建该保存共享数据的组件,这样依赖共享数据的子组件就会局部更新该共享数据。问题关键就是:1. 数据发生变化怎么通知?2. 谁来重新构建InheritedProvider?
- 数据发生变化怎么通知?
- 可以使用eventBus来进行事件通知(本案例不使用)
- 可以使用发布者-订阅者模式的ChangeNotifier
- 谁来重新构建InheritedProvider?
答案就是:让共享数据继承ChangeNotifier(),这样当共享数据改变时候,只需要调用notifyListeners()来通知订阅者,然后由订阅者来重建InheritedProvider
。
定义订阅者类:
- 数据发生变化怎么通知?
使用示例:我们需要实现一个显示购物车中所有商品总价的功能,当点击添加商品时,总价会更新。如下图
步骤一:定义一个Item类,用于表示商品信息
dart
class Item {
Item(this.price, this.count);
double price; //商品单价
int count; // 商品份数
//... 省略其他属性
}
步骤二:定义一个保存购物车内商品数据的CartModel类(就是需要跨组件共享的数据)
上面案例有一点错误:添加商品按钮在每次点击时其自身也会重新build!
其实按钮是不需要变的,那为何上面示例会变,怎么不让其变呢?
为何会变:因为按钮中调用了ChangeNotifierProvider.of,也就是说依赖了Widget树上面的InheritedWidget,那当InheritedWidget重建完成后,数据更新了,此时依赖它的子孙Widget就会被重新构建,当然也包括这个按钮。
如何避免呢?解除按钮和InheritedWidget的依赖关系
上面代码写了consumer见名知意,消费共享变量。构造函数中需要传入一个函数(函数返回值是Widget),build方法中执行这个函数,并将返回值Widget返回,即作为Consumer的Widget。

2.3 按需rebuild:ValueListenableBuilder
和数据流向无关,可以实现任意流向的数据共享。
实践中,ValueListenableBuilder 的拆分粒度应该尽可能细,可以提高性能。
详见:https://book.flutterchina.club/chapter7/value_listenable_builder.html#_7-5-2-实例
2.4 对话框
对话框也算一个页面,关闭对话框使用Navigator.of(context).pop(result)。
alertDialog和SimpleDialog都使用了Dialog类。由于AlertDialog和SimpleDialog中使用了IntrinsicWidth来尝试通过子组件的实际尺寸来调整自身尺寸,这就导致他们的子组件不能是延迟加载模型
的组件(如ListView、GridView 、 CustomScrollView等)。要是子组件中要用ListView等延迟加载组件,需要用Dialog包一下。
2.5 对话框状态管理
当要实现如下功能时,该怎么实现?

那怎么解决呢?
方式一:那既然setState刷新最近的一个statefullwidget,那就抽离复选框的代码,抽离成一个statefullwidget,这样setState就能刷新复选框了。
具体实现:将checkBox的value和onChanged暴露给抽离的类(需要是statefullwidget),在我们原来需要使用checkBox的地方,现在使用抽离的类。还是那句话:父类负责拥有状态(即管理状态),而子类只负责触发状态。先看抽离类,它拥有checkBox的状态并管理着,checkBox只负责触发。那其实到这里就已经解决了,但是,抽离的类是一个对checkBox的封装的widget,只是一个复选框,而我们此时的页面是一个dialog,复选框只是一个dialog的一部分,所以一般情况下dialog里面也需要拥有这个状态,可以理解为抽离类的父类,那既然拥有状态,那何时更新呢?当然是checkBox里面点击事件触发时,所以当checkBox点击事件触发时,不止要更新抽离类的状态,还要同步到dialog类这个状态,同步状态可以用事件驱动,回调函数可以理解为一种事件,所以在抽离类中定义onChange回调函数,当checkBox点击触发时,抛出onChange事件,父类消费这个事件(即执行父类中的onChange回调),如此一来,解决。
一般写法:抽离类包装了checkBox,checkBox有什么属性都暴露给抽离类,在需要使用抽离类的地方,以构造函数赋值这些属性,如果赋值的是函数,就是当抽离类中调用这个函数时,其实调用的是父类构造函数中的回调。

方式二:StatefulBuilder包装,实现局部更新。精妙之处在于:build构建方法中传入statefullwidget的contex,执行builder回调,builder回调我们可以自由选择返回的Widget(可以是statelesswidget),传入参数setState执行StatefulBuilder的重建,build中又回调到builder,也就是我们自己定义的组件,实现更新我们自己定义的组件啦。很巧妙。
方式一对话框上所有可能会改变状态的组件都得单独封装在一个在内部管理状态的StatefulWidget中,这样不仅麻烦,而且复用性不大。上面的方法本质上就是将对话框的状态置于一个StatefulWidget的上下文中,由StatefulWidget在内部管理,那么我们有没有办法在不需要单独抽离组件
的情况下创建一个StatefulWidget的上下文呢?想到这里,我们可以从Builder
组件的实现获得灵感。
可以看到,Builder实际上只是继承了StatelessWidget,然后在build方法中获取当前context后将构建方法代理到了builder回调
,可见,Builder实际上是获取了StatelessWidget 的上下文(context)。那么我们能否用相同的方法获取StatefulWidget 的上下文,并代理其build方法呢?下面我们照猫画虎,来封装一个StatefulBuilder方法:

方式三:优雅的方式
我们知道在调用setState方法后StatefulWidget就会重新build,那setState方法做了什么呢?我们能不能从中找到方法?顺着这个思路,我们就得看一下setState的核心源码:
dart
void setState(VoidCallback fn) {
... //省略无关代码
_element.markNeedsBuild();
}
setState中调用了Element
的markNeedsBuild()
方法,我们前面说过,Flutter是一个响应式框架,要更新UI只需改变状态后通知框架页面需要重构即可,而Element
的markNeedsBuild()
方法正是来实现这个功能的!markNeedsBuild()方法会将当前的Element对象标记为"dirty"(脏的)
,在每一个Frame
,Flutter都会重新构建被标记为"dirty"Element对象
。既然如此,我们有没有办法获取到对话框内部UI的Element对象
,然后将其标记为"dirty"
呢?答案是肯定的!我们可以通过Context
来得到Element
对象,至于Element与Context的关系我们将会在后面"Flutter核心原理"一章中再深入介绍,现在只需要简单的认为:在组件树中,context实际上就是Element对象的引用。知道这个后,那么解决的方案就呼之欲出了,我们可以通过如下方式来让复选框可以更新:
上面的代码并不是最优,因为我们只需要更新复选框的状态,而此时的context
我们用的是对话框的根context
,所以会导致整个对话框UI组件全部rebuild
,因此最好的做法是将context的"范围"缩小
,也就是说只将Checkbox的Element标记为dirty
,优化后的代码为:
3. 事件处理与通知
3.1 手势冲突
详见:https://book.flutterchina.club/chapter8/gesture_conflict.html#_8-4-5-解决手势冲突
解决手势冲突的方法有两种:
- 使用 Listener。这相当于跳出了手势识别那套规则。
- 通过 Listener 解决手势冲突的原因是竞争只是针对手势的,而 Listener 是监听原始指针事件,原始指针事件并非语义话的手势,所以根本不会走手势竞争的逻辑,所以也就不会相互影响。
- 自定义手势手势识别器( Recognizer)。
- 自定义手势识别器的方式比较麻烦,原理时当确定手势竞争胜出者时,会调用胜出者的acceptGesture 方法,表示"宣布成功",然后会调用其他手势识别其的rejectGesture 方法,表示"宣布失败"。既然如此,我们可以自定义手势识别器(Recognizer),然后去重写它的rejectGesture 方法:在里面调用acceptGesture 方法,这就相当于它失败是强制将它也变成竞争的成功者了,这样它的回调也就会执行。(这种方式说明:一次手势处理过程也是可以有多个胜出者的。)
3.2 事件总线
事件总线较为简单,通常用于组件之间状态共享。但关于组件之间状态共享也有一些专门的包如Provider。
3.3 通知 Notification
由下到上。下发出Notification,上面NotificationListener监听到,进行处理。
Flutter的UI框架实现中,除了在可滚动组件
在滚动过程中会发出ScrollNotification之外,还有一些其他的通知,如SizeChangedLayoutNotification、KeepAliveNotification 、LayoutChangedNotification等,Flutter正是通过这种通知机制来使父元素可以在一些特定时机来做一些事情。
自定义通知:https://book.flutterchina.club/chapter8/notification.html#_8-6-2-自定义通知
小案例:实现的效果就是,点击Send Notification按钮发送 Hi,接受方收到后Text展示。
dart
class NotificationRoute extends StatefulWidget {
@override
NotificationRouteState createState() {
return NotificationRouteState();
}
}
class NotificationRouteState extends State<NotificationRoute> {
String _msg="";
@override
Widget build(BuildContext context) {
//监听通知
return NotificationListener<MyNotification>(
onNotification: (notification) {
setState(() {
_msg+=notification.msg+" ";
});
return true;
},
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
// ElevatedButton(
// onPressed: () => MyNotification("Hi").dispatch(context),
// child: Text("Send Notification"),
// ),
Builder(
builder: (context) {
return ElevatedButton(
//按钮点击时分发通知
onPressed: () => MyNotification("Hi").dispatch(context),
child: Text("Send Notification"),
);
},
),
Text(_msg)
],
),
),
);
}
}
class MyNotification extends Notification {
MyNotification(this.msg);
final String msg;
}
注意:代码中注释的部分是不能正常工作的,因为这个context是根Context,而NotificationListener是监听的子树(Center为根、Column子节点····),分发消息就是在最大的根上,由下往上传,NotificationListener收不到通知。所以我们通过Builder来构建ElevatedButton,来获得按钮位置的context,这样分发的消息会层层往上找,就能找到NotificationListener这个节点了,就能消费了。