mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 06:03:45 +08:00
mod
This commit is contained in:
165
极客时间专栏/Flutter核心技术与实战/Flutter基础/09 | Widget,构建Flutter界面的基石.md
Normal file
165
极客时间专栏/Flutter核心技术与实战/Flutter基础/09 | Widget,构建Flutter界面的基石.md
Normal file
@@ -0,0 +1,165 @@
|
||||
<audio id="audio" title="09 | Widget,构建Flutter界面的基石" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7f/7d/7f99eeb00ede7b844bccc4513a4e177d.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
在前面的Flutter开发起步和Dart基础模块中,我和你一起学习了Flutter框架的整体架构与基本原理,分析了Flutter的项目结构和运行机制,并从Flutter开发角度介绍了Dart语言的基本设计思路,也通过和其他高级语言的类比深入认识了Dart的语法特性。
|
||||
|
||||
这些内容,是我们接下来系统学习构建Flutter应用的基础,可以帮助我们更好地掌握Flutter的核心概念和技术。
|
||||
|
||||
在第4篇文章“[Flutter区别于其他方案的关键技术是什么?](https://time.geekbang.org/column/article/105703)”中,我和你分享了一张来自Flutter官方的架构图,不难看出Widget是整个视图描述的基础。这张架构图很重要,所以我在这里又放了一次。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ac/2f/ac7d1cec200f7ea7cb6cbab04eda252f.png" alt="">
|
||||
|
||||
备注:此图引自[Flutter System Overview](https://flutter.dev/docs/resources/technical-overview)
|
||||
|
||||
那么,Widget到底是什么呢?
|
||||
|
||||
Widget是Flutter功能的抽象描述,是视图的配置信息,同样也是数据的映射,是Flutter开发框架中最基本的概念。前端框架中常见的名词,比如视图(View)、视图控制器(View Controller)、活动(Activity)、应用(Application)、布局(Layout)等,在Flutter中都是Widget。
|
||||
|
||||
事实上,**Flutter的核心设计思想便是“一切皆Widget”**。所以,我们学习Flutter,首先得从学会使用Widget开始。
|
||||
|
||||
那么,在今天的这篇文章中,我会带着你一起学习Widget在Flutter中的设计思路和基本原理,以帮助你深入理解Flutter的视图构建过程。
|
||||
|
||||
## Widget渲染过程
|
||||
|
||||
在进行App开发时,我们往往会关注的一个问题是:如何结构化地组织视图数据,提供给渲染引擎,最终完成界面显示。
|
||||
|
||||
通常情况下,不同的UI框架中会以不同的方式去处理这一问题,但无一例外地都会用到视图树(View Tree)的概念。而Flutter将视图树的概念进行了扩展,把视图数据的组织和渲染抽象为三部分,即Widget,Element和 RenderObject。
|
||||
|
||||
这三部分之间的关系,如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b4/c9/b4ae98fe5b4c9a7a784c916fd140bbc9.png" alt="">
|
||||
|
||||
### Widget
|
||||
|
||||
Widget是Flutter世界里对视图的一种结构化描述,你可以把它看作是前端中的“控件”或“组件”。Widget是控件实现的基本逻辑单位,里面存储的是有关视图渲染的配置信息,包括布局、渲染属性、事件响应信息等。
|
||||
|
||||
在页面渲染上,**Flutter将“Simple is best”这一理念做到了极致**。为什么这么说呢?Flutter将Widget设计成不可变的,所以当视图渲染的配置信息发生变化时,Flutter会选择重建Widget树的方式进行数据更新,以数据驱动UI构建的方式简单高效。
|
||||
|
||||
但,这样做的缺点是,因为涉及到大量对象的销毁和重建,所以会对垃圾回收造成压力。不过,Widget本身并不涉及实际渲染位图,所以它只是一份轻量级的数据结构,重建的成本很低。
|
||||
|
||||
另外,由于Widget的不可变性,可以以较低成本进行渲染节点复用,因此在一个真实的渲染树中可能存在不同的Widget对应同一个渲染节点的情况,这无疑又降低了重建UI的成本。
|
||||
|
||||
### Element
|
||||
|
||||
Element是Widget的一个实例化对象,它承载了视图构建的上下文数据,是连接结构化的配置信息到完成最终渲染的桥梁。
|
||||
|
||||
Flutter渲染过程,可以分为这么三步:
|
||||
|
||||
- 首先,通过Widget树生成对应的Element树;
|
||||
- 然后,创建相应的RenderObject并关联到Element.renderObject属性上;
|
||||
- 最后,构建成RenderObject树,以完成最终的渲染。
|
||||
|
||||
可以看到,Element同时持有Widget和RenderObject。而无论是Widget还是Element,其实都不负责最后的渲染,只负责发号施令,真正去干活儿的只有RenderObject。那你可能会问,**既然都是发号施令,那为什么需要增加中间的这层Element树呢?直接由Widget命令RenderObject去干活儿不好吗?**
|
||||
|
||||
答案是,可以,但这样做会极大地增加渲染带来的性能损耗。
|
||||
|
||||
因为Widget具有不可变性,但Element却是可变的。实际上,Element树这一层将Widget树的变化(类似React 虚拟DOM diff)做了抽象,可以只将真正需要修改的部分同步到真实的RenderObject树中,最大程度降低对真实渲染视图的修改,提高渲染效率,而不是销毁整个渲染视图树重建。
|
||||
|
||||
这,就是Element树存在的意义。
|
||||
|
||||
### RenderObject
|
||||
|
||||
从其名字,我们就可以很直观地知道,RenderObject是主要负责实现视图渲染的对象。
|
||||
|
||||
在前面的第4篇文章“[Flutter区别于其他方案的关键技术是什么?](https://time.geekbang.org/column/article/105703)”中,我们提到,Flutter通过控件树(Widget树)中的每个控件(Widget)创建不同类型的渲染对象,组成渲染对象树。
|
||||
|
||||
而渲染对象树在Flutter的展示过程分为四个阶段,即布局、绘制、合成和渲染。 其中,布局和绘制在RenderObject中完成,Flutter采用深度优先机制遍历渲染对象树,确定树中各个对象的位置和尺寸,并把它们绘制到不同的图层上。绘制完毕后,合成和渲染的工作则交给Skia搞定。
|
||||
|
||||
Flutter通过引入Widget、Element与RenderObject这三个概念,把原本从视图数据到视图渲染的复杂构建过程拆分得更简单、直接,在易于集中治理的同时,保证了较高的渲染效率。
|
||||
|
||||
## RenderObjectWidget介绍
|
||||
|
||||
通过第5篇文章“[从标准模板入手,体会Flutter代码是如何运行在原生系统上的](https://time.geekbang.org/column/article/106199)”的介绍,你应该已经知道如何使用StatelessWidget和StatefulWidget了。
|
||||
|
||||
不过,StatelessWidget和StatefulWidget只是用来组装控件的容器,并不负责组件最后的布局和绘制。在Flutter中,布局和绘制工作实际上是在Widget的另一个子类RenderObjectWidget内完成的。
|
||||
|
||||
所以,在今天这篇文章的最后,我们再来看一下RenderObjectWidget的源码,来看看如何使用Element和RenderObject完成图形渲染工作。
|
||||
|
||||
```
|
||||
abstract class RenderObjectWidget extends Widget {
|
||||
@override
|
||||
RenderObjectElement createElement();
|
||||
@protected
|
||||
RenderObject createRenderObject(BuildContext context);
|
||||
@protected
|
||||
void updateRenderObject(BuildContext context, covariant RenderObject renderObject) { }
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
RenderObjectWidget是一个抽象类。我们通过源码可以看到,这个类中同时拥有创建Element、RenderObject,以及更新RenderObject的方法。
|
||||
|
||||
但实际上,**RenderObjectWidget本身并不负责这些对象的创建与更新**。
|
||||
|
||||
对于Element的创建,Flutter会在遍历Widget树时,调用createElement去同步Widget自身配置,从而生成对应节点的Element对象。而对于RenderObject的创建与更新,其实是在RenderObjectElement类中完成的。
|
||||
|
||||
```
|
||||
abstract class RenderObjectElement extends Element {
|
||||
RenderObject _renderObject;
|
||||
|
||||
@override
|
||||
void mount(Element parent, dynamic newSlot) {
|
||||
super.mount(parent, newSlot);
|
||||
_renderObject = widget.createRenderObject(this);
|
||||
attachRenderObject(newSlot);
|
||||
_dirty = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void update(covariant RenderObjectWidget newWidget) {
|
||||
super.update(newWidget);
|
||||
widget.updateRenderObject(this, renderObject);
|
||||
_dirty = false;
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在Element创建完毕后,Flutter会调用Element的mount方法。在这个方法里,会完成与之关联的RenderObject对象的创建,以及与渲染树的插入工作,插入到渲染树后的Element就可以显示到屏幕中了。
|
||||
|
||||
如果Widget的配置数据发生了改变,那么持有该Widget的Element节点也会被标记为dirty。在下一个周期的绘制时,Flutter就会触发Element树的更新,并使用最新的Widget数据更新自身以及关联的RenderObject对象,接下来便会进入Layout和Paint的流程。而真正的绘制和布局过程,则完全交由RenderObject完成:
|
||||
|
||||
```
|
||||
abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin implements HitTestTarget {
|
||||
...
|
||||
void layout(Constraints constraints, { bool parentUsesSize = false }) {...}
|
||||
|
||||
void paint(PaintingContext context, Offset offset) { }
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
布局和绘制完成后,接下来的事情就交给Skia了。在VSync信号同步时直接从渲染树合成Bitmap,然后提交给GPU。这部分内容,我已经在之前的“[Flutter区别于其他方案的关键技术是什么](https://time.geekbang.org/column/article/105703)?”中与你介绍过了,这里就不再赘述了。
|
||||
|
||||
接下来,我以下面的界面示例为例,与你说明Widget、Element与RenderObject在渲染过程中的关系。在下面的例子中,一个Row容器放置了4个子Widget,左边是Image,而右边则是一个Column容器下排布的两个Text。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f4/b9/f4d2ac98728cf4f24b0237958d0ce0b9.png" alt="">
|
||||
|
||||
那么,在Flutter遍历完Widget树,创建了各个子Widget对应的Element的同时,也创建了与之关联的、负责实际布局和绘制的RenderObject。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/35/6d/3536bd7bc00b42b220ce18ba86c2a26d.png" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天关于Widget的设计思路和基本原理的介绍,我们就先进行到这里。接下来,我们一起回顾下今天的主要内容吧。
|
||||
|
||||
首先,我与你介绍了Widget渲染过程,学习了在Flutter中视图数据的组织和渲染抽象的三个核心概念,即Widget、 Element和RenderObject。
|
||||
|
||||
其中,Widget是Flutter世界里对视图的一种结构化描述,里面存储的是有关视图渲染的配置信息;Element则是Widget的一个实例化对象,将Widget树的变化做了抽象,能够做到只将真正需要修改的部分同步到真实的Render Object树中,最大程度地优化了从结构化的配置信息到完成最终渲染的过程;而RenderObject,则负责实现视图的最终呈现,通过布局、绘制完成界面的展示。
|
||||
|
||||
最后,在对Flutter Widget渲染过程有了一定认识后,我带你阅读了RenderObjectWidget的代码,理解Widget、Element与RenderObject这三个对象之间是如何互相配合,实现图形渲染工作的。
|
||||
|
||||
熟悉了Widget、Element与RenderObject这三个概念,相信你已经对组件的渲染过程有了一个清晰而完整的认识。这样,我们后续再学习常用的组件和布局时,就能够从不同的视角去思考框架设计的合理性了。
|
||||
|
||||
不过在日常开发学习中,绝大多数情况下,我们只需要了解各种Widget特性及使用方法,而无需关心Element及RenderObject。因为Flutter已经帮我们做了大量优化工作,因此我们只需要在上层代码完成各类Widget的组装配置,其他的事情完全交给Flutter就可以了。
|
||||
|
||||
## 思考题
|
||||
|
||||
你是如何理解Widget、Element和RenderObject这三个概念的?它们之间是一一对应的吗?你能否在Android/iOS/Web中找到对应的概念呢?
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
209
极客时间专栏/Flutter核心技术与实战/Flutter基础/10 | Widget中的State到底是什么?.md
Normal file
209
极客时间专栏/Flutter核心技术与实战/Flutter基础/10 | Widget中的State到底是什么?.md
Normal file
@@ -0,0 +1,209 @@
|
||||
<audio id="audio" title="10 | Widget中的State到底是什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8b/39/8b13514c4f5385b637d254b5cddb0939.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
通过上一篇文章,我们已经深入理解了Widget是Flutter构建界面的基石,也认识了Widget、Element、RenderObject是如何互相配合,实现图形渲染工作的。Flutter在底层做了大量的渲染优化工作,使得我们只需要通过组合、嵌套不同类型的Widget,就可以构建出任意功能、任意复杂度的界面。
|
||||
|
||||
同时,我们通过前面的学习,也已经了解到Widget有StatelessWidget和StatefulWidget两种类型。StatefulWidget应对有交互、需要动态变化视觉效果的场景,而StatelessWidget则用于处理静态的、无状态的视图展示。StatefulWidget的场景已经完全覆盖了StatelessWidget,因此我们在构建界面时,往往会大量使用StatefulWidget来处理静态的视图展示需求,看起来似乎也没什么问题。
|
||||
|
||||
那么,StatelessWidget存在的必要性在哪里?StatefulWidget是否是Flutter中的万金油?在今天这篇文章中,我将着重和你介绍这两种类型的区别,从而帮你更好地理解Widget,掌握不同类型Widget的正确使用时机。
|
||||
|
||||
## UI编程范式
|
||||
|
||||
要想理解StatelessWidget与StatefulWidget的使用场景,我们首先需要了解,在Flutter中,如何调整一个控件(Widget)的展示样式,即UI编程范式。
|
||||
|
||||
如果你有过原生系统(Android、iOS)或原生JavaScript开发经验的话,应该知道视图开发是命令式的,需要精确地告诉操作系统或浏览器用何种方式去做事情。比如,如果我们想要变更界面的某个文案,则需要找到具体的文本控件并调用它的控件方法命令,才能完成文字变更。
|
||||
|
||||
下述代码分别展示了在Android、iOS及原生Javascript中,如何将一个文本控件的展示文案更改为Hello World:
|
||||
|
||||
```
|
||||
// Android设置某文本控件展示文案为Hello World
|
||||
TextView textView = (TextView) findViewById(R.id.txt);
|
||||
textView.setText("Hello World");
|
||||
|
||||
// iOS设置某文本控件展示文案为Hello World
|
||||
UILabel *label = (UILabel *)[self.view viewWithTag:1234];
|
||||
label.text = @"Hello World";
|
||||
|
||||
// 原生JavaScript设置某文本控件展示文案为Hello World
|
||||
document.querySelector("#demo").innerHTML = "Hello World!";
|
||||
|
||||
```
|
||||
|
||||
与此不同的是,**Flutter的视图开发是声明式的,其核心设计思想就是将视图和数据分离,这与React的设计思路完全一致**。
|
||||
|
||||
对我们来说,如果要实现同样的需求,则要稍微麻烦点:除了设计好Widget布局方案之外,还需要提前维护一套文案数据集,并为需要变化的Widget绑定数据集中的数据,使Widget根据这个数据集完成渲染。
|
||||
|
||||
但是,当需要变更界面的文案时,我们只要改变数据集中的文案数据,并通知Flutter框架触发Widget的重新渲染即可。这样一来,开发者将无需再精确关注UI编程中的各个过程细节,只要维护好数据集即可。比起命令式的视图开发方式需要挨个设置不同组件(Widget)的视觉属性,这种方式要便捷得多。
|
||||
|
||||
**总结来说,命令式编程强调精确控制过程细节;而声明式编程强调通过意图输出结果整体。**对应到Flutter中,意图是绑定了组件状态的State,结果则是重新渲染后的组件。在Widget的生命周期内,应用到State中的任何更改都将强制Widget重新构建。
|
||||
|
||||
其中,对于组件完成创建后就无需变更的场景,状态的绑定是可选项。这里“可选”就区分出了Widget的两种类型,即:StatelessWidget不带绑定状态,而StatefulWidget带绑定状态。**当你所要构建的用户界面不随任何状态信息的变化而变化时,需要选择使用StatelessWidget,反之则选用StatefulWidget。**前者一般用于静态内容的展示,而后者则用于存在交互反馈的内容呈现中。
|
||||
|
||||
接下来,我分别和你介绍StatelessWidget和StatefulWidget,从源码分析它们的区别,并总结一些关于Widget选型的基本原则。
|
||||
|
||||
## StatelessWidget
|
||||
|
||||
在Flutter中,Widget采用由父到子、自顶向下的方式进行构建,父Widget控制着子Widget的显示样式,其样式配置由父Widget在构建时提供。
|
||||
|
||||
用这种方式构建出的Widget,有些(比如Text、Container、Row、Column等)在创建时,除了这些配置参数之外不依赖于任何其他信息,换句话说,它们一旦创建成功就不再关心、也不响应任何数据变化进行重绘。在Flutter中,**这样的Widget被称为StatelessWidget(无状态组件)**。
|
||||
|
||||
这里有一张StatelessWidget的示意图,如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3e/cc/3ec97a9f584132c2bcdbca60fd2888cc.png" alt="">
|
||||
|
||||
接下来,我以Text的部分源码为例,和你说明StatelessWidget的构建过程。
|
||||
|
||||
```
|
||||
class Text extends StatelessWidget {
|
||||
//构造方法及属性声明部分
|
||||
const Text(this.data, {
|
||||
Key key,
|
||||
this.textAlign,
|
||||
this.textDirection,
|
||||
//其他参数
|
||||
...
|
||||
}) : assert(data != null),
|
||||
textSpan = null,
|
||||
super(key: key);
|
||||
|
||||
final String data;
|
||||
final TextAlign textAlign;
|
||||
final TextDirection textDirection;
|
||||
//其他属性
|
||||
...
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
...
|
||||
Widget result = RichText(
|
||||
//初始化配置
|
||||
...
|
||||
)
|
||||
);
|
||||
...
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,在构造方法将其属性列表赋值后,build方法随即将子组件RichText通过其属性列表(如文本data、对齐方式textAlign、文本展示方向textDirection等)初始化后返回,之后Text内部不再响应外部数据的变化。
|
||||
|
||||
那么,什么场景下应该使用StatelessWidget呢?
|
||||
|
||||
这里,我有一个简单的判断规则:**父Widget是否能通过初始化参数完全控制其UI展示效果?**如果能,那么我们就可以使用StatelessWidget来设计构造函数接口了。
|
||||
|
||||
我准备了两个简单的小例子,来帮助你理解这个判断规则。
|
||||
|
||||
第一个小例子是,我需要创建一个自定义的弹窗控件,把使用App过程中出现的一些错误信息提示给用户。这个组件的父Widget,能够完全在子Widget初始化时将组件所需要的样式信息和错误提示信息传递给它,也就意味着父Widget通过初始化参数就能完全控制其展示效果。所以,我可以采用继承StatelessWidget的方式,来进行组件自定义。
|
||||
|
||||
第二个小例子是,我需要定义一个计数器按钮,用户每次点击按钮后,按钮颜色都会随之加深。可以看到,这个组件的父Widget只能控制子Widget初始的样式展示效果,而无法控制在交互过程中发生的颜色变化。所以,我无法通过继承StatelessWidget的方式来自定义组件。那么,这个时候就轮到StatefulWidget出场了。
|
||||
|
||||
## StatefulWidget
|
||||
|
||||
与StatelessWidget相对应的,有一些Widget(比如Image、Checkbox)的展示,除了父Widget初始化时传入的静态配置之外,还需要处理用户的交互(比如,用户点击按钮)或其内部数据的变化(比如,网络数据回包),并体现在UI上。
|
||||
|
||||
换句话说,这些Widget创建完成后,还需要关心和响应数据变化来进行重绘。在Flutter中,**这一类Widget被称为StatefulWidget(有状态组件)**。这里有一张StatefulWidget的示意图,如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8a/f6/8ae7bf36f618a999da8847cbb4da4bf6.png" alt="">
|
||||
|
||||
看到这里,你可能有点困惑了。因为,我在上一篇文章“Widget,构建Flutter界面的基石”中和你分享到,Widget是不可变的,发生变化时需要销毁重建,所以谈不上状态。那么,这到底是怎么回事呢?
|
||||
|
||||
其实,StatefulWidget是以State类代理Widget构建的设计方式实现的。接下来,我就以Image的部分源码为例,和你说明StatefulWidget的构建过程,来帮助你理解这个知识点。
|
||||
|
||||
和上面提到的Text一样,Image类的构造函数会接收要被这个类使用的属性参数。然而,不同的是,Image类并没有build方法来创建视图,而是通过createState方法创建了一个类型为_ImageState的state对象,然后由这个对象负责视图的构建。
|
||||
|
||||
这个state对象持有并处理了Image类中的状态变化,所以我就以_imageInfo属性为例来和你展开说明。
|
||||
|
||||
_imageInfo属性用来给Widget加载真实的图片,一旦State对象通过_handleImageChanged方法监听到_imageInfo属性发生了变化,就会立即调用_ImageState类的setState方法通知Flutter框架:“我这儿的数据变啦,请使用更新后的_imageInfo数据重新加载图片!”。而,Flutter框架则会标记视图状态,更新UI。
|
||||
|
||||
```
|
||||
class Image extends StatefulWidget {
|
||||
//构造方法及属性声明部分
|
||||
const Image({
|
||||
Key key,
|
||||
@required this.image,
|
||||
//其他参数
|
||||
}) : assert(image != null),
|
||||
super(key: key);
|
||||
|
||||
final ImageProvider image;
|
||||
//其他属性
|
||||
...
|
||||
|
||||
@override
|
||||
_ImageState createState() => _ImageState();
|
||||
...
|
||||
}
|
||||
|
||||
class _ImageState extends State<Image> {
|
||||
ImageInfo _imageInfo;
|
||||
//其他属性
|
||||
...
|
||||
|
||||
void _handleImageChanged(ImageInfo imageInfo, bool synchronousCall) {
|
||||
setState(() {
|
||||
_imageInfo = imageInfo;
|
||||
});
|
||||
}
|
||||
...
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final RawImage image = RawImage(
|
||||
image: _imageInfo?.image,
|
||||
//其他初始化配置
|
||||
...
|
||||
);
|
||||
return image;
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,在这个例子中,Image以一种动态的方式运行:监听变化,更新视图。与StatelessWidget通过父Widget完全控制UI展示不同,StatefulWidget的父Widget仅定义了它的初始化状态,而其自身视图运行的状态则需要自己处理,并根据处理情况即时更新UI展示。
|
||||
|
||||
好了,至此我们已经通过StatelessWidget与StatefulWidget的源码,理解了这两种类型的Widget。这时,你可能会问,既然StatefulWidget不仅可以响应状态变化,又能展示静态UI,那么StatelessWidget这种只能展示静态UI的Widget,还有存在的必要吗?
|
||||
|
||||
## StatefulWidget不是万金油,要慎用
|
||||
|
||||
对于UI框架而言,同样的展示效果一般可以通过多种控件实现。从定义来看,StatefulWidget仿佛是万能的,替代StatelessWidget看起来合情合理。于是StatefulWidget的滥用,也容易因此变得顺理成章,难以避免。
|
||||
|
||||
但事实是,StatefulWidget的滥用会直接影响Flutter应用的渲染性能。
|
||||
|
||||
接下来,在今天这篇文章的最后,我就再带你回顾一下Widget的更新机制,来帮你意识到完全使用StatefulWidget的代价:
|
||||
|
||||
>
|
||||
Widget是不可变的,更新则意味着销毁+重建(build)。StatelessWidget是静态的,一旦创建则无需更新;而对于StatefulWidget来说,在State类中调用setState方法更新数据,会触发视图的销毁和重建,也将间接地触发其每个子Widget的销毁和重建。
|
||||
|
||||
|
||||
那么,这意味着什么呢?
|
||||
|
||||
如果我们的根布局是一个StatefulWidget,在其State中每调用一次更新UI,都将是一整个页面所有Widget的销毁和重建。
|
||||
|
||||
在上一篇文章中,我们了解到,虽然Flutter内部通过Element层可以最大程度地降低对真实渲染视图的修改,提高渲染效率,而不是销毁整个RenderObject树重建。但,大量Widget对象的销毁重建是无法避免的。如果某个子Widget的重建涉及到一些耗时操作,那页面的渲染性能将会急剧下降。
|
||||
|
||||
因此,**正确评估你的视图展示需求,避免无谓的StatefulWidget使用,是提高Flutter应用渲染性能最简单也是最直接的手段**。
|
||||
|
||||
在接下来的第29篇文章“为什么需要做状态管理,怎么做?”中,我会继续带你学习StatefulWidget常见的几种状态管理方法,与你更为具体地介绍在不同场景中,该选用何种Widget的基本原则。这些原则,你都可以根据实际需要应用到后续工作中。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天关于StatelessWidget与StatefulWidget的介绍,我们就到这里了。我们一起来回顾下今天的主要知识点。
|
||||
|
||||
首先,我带你了解了Flutter基于声明式的UI编程范式,并通过阅读两个典型Widget(Text与Image)源码的方式,与你一起学习了StatelessWidget与StatefulWidget的基本设计思路。
|
||||
|
||||
由于Widget采用由父到子、自顶向下的方式进行构建,因此在自定义组件时,我们可以根据父Widget是否能通过初始化参数完全控制其UI展示效果的基本原则,来判断究竟是继承StatelessWidget还是StatefulWidget。
|
||||
|
||||
然后,针对StatefulWidget的“万金油”误区,我带你重新回顾了Widget的UI更新机制。尽管Flutter会通过Element层去最大程度降低对真实渲染视图的修改,但大量的Widget销毁重建无法避免,因此避免StatefulWidget的滥用,是最简单、直接地提升应用渲染性能的手段。
|
||||
|
||||
需要注意的是,除了我们主动地通过State刷新UI之外,在一些特殊场景下,Widget的build方法有可能会执行多次。因此,我们不应该在这个方法内部,放置太多有耗时的操作。而关于这个build方法在哪些场景下会执行,以及为什么会执行多次,我会在下一篇文章“提到生命周期,我们是在说什么?”中,与你一起详细分析。
|
||||
|
||||
## 思考题
|
||||
|
||||
Flutter工程应用模板是计数器示例应用Demo,这个Demo的根节点是一个StatelessWidget。请在保持原有功能的情况下,将这个Demo改造为根节点为StatefulWidget的App。你能通过数据打点,得出这两种方式的性能差异吗?
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
207
极客时间专栏/Flutter核心技术与实战/Flutter基础/11 | 提到生命周期,我们是在说什么?.md
Normal file
207
极客时间专栏/Flutter核心技术与实战/Flutter基础/11 | 提到生命周期,我们是在说什么?.md
Normal file
@@ -0,0 +1,207 @@
|
||||
<audio id="audio" title="11 | 提到生命周期,我们是在说什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fe/55/fe2ed4c88658fc76c93410596642b455.mp3"></audio>
|
||||
|
||||
你好,我是陈航。今天,我要和你分享的主题是Flutter中的生命周期是什么。
|
||||
|
||||
在上一篇文章中,我们从常见的StatefulWidget的“万金油”误区出发,一起回顾了Widget的UI更新机制。
|
||||
|
||||
通过父Widget初始化时传入的静态配置,StatelessWidget就能完全控制其静态展示。而StatefulWidget,还需要借助于State对象,在特定的阶段来处理用户的交互或其内部数据的变化,并体现在UI上。这些特定的阶段,就涵盖了一个组件从加载到卸载的全过程,即生命周期。与iOS的ViewController、Android的Activity一样,Flutter中的Widget也存在生命周期,并且通过State来体现。
|
||||
|
||||
而App则是一个特殊的Widget。除了需要处理视图显示的各个阶段(即视图的生命周期)之外,还需要应对应用从启动到退出所经历的各个状态(App的生命周期)。
|
||||
|
||||
对于开发者来说,无论是普通Widget(的State)还是App,框架都给我们提供了生命周期的回调,可以让我们选择恰当的时机,做正确的事儿。所以,在对生命周期有了深入理解之后,我们可以写出更加连贯流畅、体验优良的程序。
|
||||
|
||||
那么,今天我就分别从Widget(的State)和App这两个维度,与你介绍它们的生命周期。
|
||||
|
||||
## State生命周期
|
||||
|
||||
State的生命周期,指的是在用户参与的情况下,其关联的Widget所经历的,从创建到显示再到更新最后到停止,直至销毁等各个过程阶段。
|
||||
|
||||
这些不同的阶段涉及到特定的任务处理,因此为了写出一个体验和性能良好的控件,正确理解State的生命周期至关重要。
|
||||
|
||||
State的生命周期流程,如图1所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/84/bba88ebb44b7fdd6735f3ddb41106784.png" alt="">
|
||||
|
||||
可以看到,State的生命周期可以分为3个阶段:创建(插入视图树)、更新(在视图树中存在)、销毁(从视图树中移除)。接下来,我们一起看看每一个阶段的具体流程。
|
||||
|
||||
### 创建
|
||||
|
||||
State初始化时会依次执行 :构造方法 -> initState -> didChangeDependencies -> build,随后完成页面渲染。
|
||||
|
||||
我们来看一下初始化过程中每个方法的意义。
|
||||
|
||||
- 构造方法是State生命周期的起点,Flutter会通过调用StatefulWidget.createState() 来创建一个State。我们可以通过构造方法,来接收父Widget传递的初始化UI配置数据。这些配置数据,决定了Widget最初的呈现效果。
|
||||
- initState,会在State对象被插入视图树的时候调用。这个函数在State的生命周期中只会被调用一次,所以我们可以在这里做一些初始化工作,比如为状态变量设定默认值。
|
||||
- didChangeDependencies则用来专门处理State对象依赖关系变化,会在initState() 调用结束后,被Flutter调用。
|
||||
- build,作用是构建视图。经过以上步骤,Framework认为State已经准备好了,于是调用build。我们需要在这个函数中,根据父Widget传递过来的初始化配置数据,以及State的当前状态,创建一个Widget然后返回。
|
||||
|
||||
### 更新
|
||||
|
||||
Widget的状态更新,主要由3个方法触发:setState、didchangeDependencies与didUpdateWidget。
|
||||
|
||||
接下来,我和你分析下这三个方法分别会在什么场景下调用。
|
||||
|
||||
- setState:我们最熟悉的方法之一。当状态数据发生变化时,我们总是通过调用这个方法告诉Flutter:“我这儿的数据变啦,请使用更新后的数据重建UI!”
|
||||
- didChangeDependencies:State对象的依赖关系发生变化后,Flutter会回调这个方法,随后触发组件构建。哪些情况下State对象的依赖关系会发生变化呢?典型的场景是,系统语言Locale或应用主题改变时,系统会通知State执行didChangeDependencies回调方法。
|
||||
- didUpdateWidget:当Widget的配置发生变化时,比如,父Widget触发重建(即父Widget的状态发生变化时),热重载时,系统会调用这个函数。
|
||||
|
||||
一旦这三个方法被调用,Flutter随后就会销毁老Widget,并调用build方法重建Widget。
|
||||
|
||||
### 销毁
|
||||
|
||||
组件销毁相对比较简单。比如组件被移除,或是页面销毁的时候,系统会调用deactivate和dispose这两个方法,来移除或销毁组件。
|
||||
|
||||
接下来,我们一起看一下它们的具体调用机制:
|
||||
|
||||
- 当组件的可见状态发生变化时,deactivate函数会被调用,这时State会被暂时从视图树中移除。值得注意的是,页面切换时,由于State对象在视图树中的位置发生了变化,需要先暂时移除后再重新添加,重新触发组件构建,因此这个函数也会被调用。
|
||||
- 当State被永久地从视图树中移除时,Flutter会调用dispose函数。而一旦到这个阶段,组件就要被销毁了,所以我们可以在这里进行最终的资源释放、移除监听、清理环境,等等。
|
||||
|
||||
如图2所示,左边部分展示了当父Widget状态发生变化时,父子双方共同的生命周期;而中间和右边部分则描述了页面切换时,两个关联的Widget的生命周期函数是如何响应的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/72/d8/72e066a4981e0e2381b1dab6e61307d8.png" alt="">
|
||||
|
||||
我准备了一张表格,从功能,调用时机和调用次数的维度总结了这些方法,帮助你去理解、记忆。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/aa/bc/aacfcfdb80038874251aa8ad93930abc.png" alt="">
|
||||
|
||||
另外,我强烈建议你打开自己的IDE,在应用模板中增加以上回调函数并添加打印代码,多运行几次看看各个函数的执行顺序,从而加深对State生命周期的印象。毕竟,实践出真知。
|
||||
|
||||
## App生命周期
|
||||
|
||||
视图的生命周期,定义了视图的加载到构建的全过程,其回调机制能够确保我们可以根据视图的状态选择合适的时机做恰当的事情。而App的生命周期,则定义了App从启动到退出的全过程。
|
||||
|
||||
在原生Android、iOS开发中,有时我们需要在对应的App生命周期事件中做相应处理,比如App从后台进入前台、从前台退到后台,或是在UI绘制完成后做一些处理。
|
||||
|
||||
这样的需求,在原生开发中,我们可以通过重写Activity、ViewController生命周期回调方法,或是注册应用程序的相关通知,来监听App的生命周期并做相应的处理。而在Flutter中,我们可以利用**WidgetsBindingObserver**类,来实现同样的需求。
|
||||
|
||||
接下来,我们就来看看具体如何实现这样的需求。
|
||||
|
||||
首先,我们来看看WidgetsBindingObserver中具体有哪些回调函数:
|
||||
|
||||
```
|
||||
abstract class WidgetsBindingObserver {
|
||||
//页面pop
|
||||
Future<bool> didPopRoute() => Future<bool>.value(false);
|
||||
//页面push
|
||||
Future<bool> didPushRoute(String route) => Future<bool>.value(false);
|
||||
//系统窗口相关改变回调,如旋转
|
||||
void didChangeMetrics() { }
|
||||
//文本缩放系数变化
|
||||
void didChangeTextScaleFactor() { }
|
||||
//系统亮度变化
|
||||
void didChangePlatformBrightness() { }
|
||||
//本地化语言变化
|
||||
void didChangeLocales(List<Locale> locale) { }
|
||||
//App生命周期变化
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) { }
|
||||
//内存警告回调
|
||||
void didHaveMemoryPressure() { }
|
||||
//Accessibility相关特性回调
|
||||
void didChangeAccessibilityFeatures() {}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,WidgetsBindingObserver这个类提供的回调函数非常丰富,常见的屏幕旋转、屏幕亮度、语言变化、内存警告都可以通过这个实现进行回调。我们通过给WidgetsBinding的单例对象设置监听器,就可以监听对应的回调方法。
|
||||
|
||||
考虑到其他的回调相对简单,你可以参考[官方文档](https://api.flutter.dev/flutter/widgets/WidgetsBindingObserver-class.html),对照着进行练习。因此,我今天主要和你分享App生命周期的回调didChangeAppLifecycleState,和帧绘制回调addPostFrameCallback与addPersistentFrameCallback。
|
||||
|
||||
### 生命周期回调
|
||||
|
||||
didChangeAppLifecycleState回调函数中,有一个参数类型为AppLifecycleState的枚举类,这个枚举类是Flutter对App生命周期状态的封装。它的常用状态包括resumed、inactive、paused这三个。
|
||||
|
||||
- resumed:可见的,并能响应用户的输入。
|
||||
- inactive:处在不活动状态,无法处理用户响应。
|
||||
- paused:不可见并不能响应用户的输入,但是在后台继续活动中。
|
||||
|
||||
这里,我来和你分享一个实际案例。
|
||||
|
||||
在下面的代码中,我们在initState时注册了监听器,在didChangeAppLifecycleState回调方法中打印了当前的App状态,最后在dispose时把监听器移除:
|
||||
|
||||
```
|
||||
class _MyHomePageState extends State<MyHomePage> with WidgetsBindingObserver{//这里你可以再回顾下,第7篇文章“函数、类与运算符:Dart是如何处理信息的?”中关于Mixin的内容
|
||||
...
|
||||
@override
|
||||
@mustCallSuper
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);//注册监听器
|
||||
}
|
||||
@override
|
||||
@mustCallSuper
|
||||
void dispose(){
|
||||
super.dispose();
|
||||
WidgetsBinding.instance.removeObserver(this);//移除监听器
|
||||
}
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) async {
|
||||
print("$state");
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
//do sth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们试着切换一下前、后台,观察控制台输出的App状态,可以发现:
|
||||
|
||||
- 从后台切入前台,控制台打印的App生命周期变化如下: AppLifecycleState.paused->AppLifecycleState.inactive->AppLifecycleState.resumed;
|
||||
- 从前台退回后台,控制台打印的App生命周期变化则变成了:AppLifecycleState.resumed->AppLifecycleState.inactive->AppLifecycleState.paused。
|
||||
|
||||
可以看到,App前后台切换过程中打印出的状态是完全符合预期的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/28/e6/2880ffdbe3c5df3552c0b22c34157ae6.png" alt="">
|
||||
|
||||
### 帧绘制回调
|
||||
|
||||
除了需要监听App的生命周期回调做相应的处理之外,有时候我们还需要在组件渲染之后做一些与显示安全相关的操作。
|
||||
|
||||
在iOS开发中,我们可以通过dispatch_async(dispatch_get_main_queue(),^{…})方法,让操作在下一个RunLoop执行;而在Android开发中,我们可以通过View.post()插入消息队列,来保证在组件渲染后进行相关操作。
|
||||
|
||||
其实,**在Flutter中实现同样的需求会更简单**:依然使用万能的WidgetsBinding来实现。
|
||||
|
||||
WidgetsBinding提供了单次Frame绘制回调,以及实时Frame绘制回调两种机制,来分别满足不同的需求:
|
||||
|
||||
- 单次Frame绘制回调,通过addPostFrameCallback实现。它会在当前Frame绘制完成后进行进行回调,并且只会回调一次,如果要再次监听则需要再设置一次。
|
||||
|
||||
```
|
||||
WidgetsBinding.instance.addPostFrameCallback((_){
|
||||
print("单次Frame绘制回调");//只回调一次
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
- 实时Frame绘制回调,则通过addPersistentFrameCallback实现。这个函数会在每次绘制Frame结束后进行回调,可以用做FPS监测。
|
||||
|
||||
```
|
||||
WidgetsBinding.instance.addPersistentFrameCallback((_){
|
||||
print("实时Frame绘制回调");//每帧都回调
|
||||
});
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
在今天这篇文章中,我和你介绍了State和App的生命周期,这是Flutter给我们提供的,感知Widget和应用在不同阶段状态变化的回调。
|
||||
|
||||
首先,我带你重新认识了Widget生命周期的实际承载者State。我将State的生命周期划分为了创建(插入视图树)、更新(在视图树中存在)、销毁(从视图树种移除)这3个阶段,并为你介绍了每个阶段中涉及的关键方法,希望你能够深刻理解Flutter组件从加载到卸载的完整周期。
|
||||
|
||||
然后,通过与原生Android、iOS平台能力的对比,以及查看WidgetsBindingObserver源码的方式,我与你讲述了Flutter常用的生命周期状态切换机制。希望你能掌握Flutter的App生命周期监听方法,并理解Flutter常用的生命周期状态切换机制。
|
||||
|
||||
最后,我和你一起学习了Flutter帧绘制回调机制,理解了单次Frame绘制回调与实时Frame绘制回调的异同与使用场景。
|
||||
|
||||
为了能够精确地控制Widget,Flutter提供了很多状态回调,所以今天这一篇文章,涉及到的方法有些多。但,**只要你分别记住创建、更新与销毁这三条主线的调用规则,就一定能把这些方法的调用顺序串起来,并能在实际开发中运用正确的方法去感知状态变更,写出合理的组件。**
|
||||
|
||||
我把今天分享所涉及的全部知识点打包成了一个[小项目](https://github.com/cyndibaby905/11_Flutter_lifecycle),你可以下载后在工程中实际运行,并对照着今天的课程学习,体会在不同场景下这些函数的调用时机。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,请你思考下这两个问题:
|
||||
|
||||
1. 构造方法与initState函数在State的生命周期中都只会被调用一次,也大都用于完成一些初始化的工作。根据我们今天的学习,你能否举出例子,比如哪些操作适合放在构造方法,哪些操作适合放在initState,而哪些操作必须放在initState。
|
||||
1. 通过didChangeDependencies触发Widget重建时,父子Widget之间的生命周期函数调用时序是怎样的?
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
<audio id="audio" title="12 | 经典控件(一):文本、图片和按钮在Flutter中怎么用?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b1/e4/b198d8fe89eac6745e60ae78ba78dfe4.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
在上一篇文章中,我与你介绍了Widget生命周期的实际承载者State,并详细介绍了初始化、状态更新与控件销毁,这3个不同阶段所涉及的关键方法调用顺序。深入理解视图从加载到构建再到销毁的过程,可以帮助你理解如何根据视图的状态在合适的时机做恰当的事情。
|
||||
|
||||
前面几次分享我们讲了很多关于Flutter框架视图渲染的基础知识和原理。但有些同学可能会觉得这些基础知识和原理在实践中并不常用,所以在学习时会选择忽视这些内容。
|
||||
|
||||
但其实,像视图数据流转机制、底层渲染方案、视图更新策略等知识,都是构成一个UI框架的根本,看似枯燥,却往往具有最长久的生命力。新框架每年层出不穷,可是扒下那层炫酷的“外衣”,里面其实还是那些最基础的知识和原理。
|
||||
|
||||
因此,**只有把这些最基础的知识弄明白了,修炼好了内功,才能触类旁通,由点及面形成自己的知识体系,也能够在框架之上思考应用层构建视图实现的合理性。**
|
||||
|
||||
在对视图的基础知识有了整体印象后,我们再来学习Flutter视图系统所提供的UI控件,就会事半功倍了。而作为一个UI框架,与Android、iOS和React类似的,Flutter自然也提供了很多UI控件。而文本、图片和按钮则是这些不同的UI框架中构建视图都要用到的三个最基本的控件。因此,在今天这篇文章中,我就与你一起学习在Flutter中该如何使用它们。
|
||||
|
||||
## 文本控件
|
||||
|
||||
文本是视图系统中的常见控件,用来显示一段特定样式的字符串,就比如Android里的TextView、iOS中的UILabel。而在Flutter中,文本展示是通过Text控件实现的。
|
||||
|
||||
Text支持两种类型的文本展示,一个是默认的展示单一样式的文本Text,另一个是支持多种混合样式的富文本Text.rich。
|
||||
|
||||
我们先来看看**如何使用单一样式的文本Text**。
|
||||
|
||||
单一样式文本Text的初始化,是要传入需要展示的字符串。而这个字符串的具体展示效果,受构造函数中的其他参数控制。这些参数大致可以分为两类:
|
||||
|
||||
- **控制整体文本布局的参数**,如文本对齐方式textAlign、文本排版方向textDirection,文本显示最大行数maxLines、文本截断规则overflow等等,这些都是构造函数中的参数;
|
||||
- **控制文本展示样式的参数**,如字体名称fontFamily、字体大小fontSize、文本颜色color、文本阴影shadows等等,这些参数被统一封装到了构造函数中的参数style中。
|
||||
|
||||
接下来,我们以一个具体的例子来看看Text控件的使用方法。如下所示,我在代码中定义了一段居中布局、20号红色粗体展示样式的字符串:
|
||||
|
||||
```
|
||||
Text(
|
||||
'文本是视图系统中的常见控件,用来显示一段特定样式的字符串,就比如Android里的TextView,或是iOS中的UILabel。',
|
||||
textAlign: TextAlign.center,//居中显示
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20, color: Colors.red),//20号红色粗体展示
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
运行效果如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/92/9e/926f77c8259fbd3ef914bf1b6039939e.png" alt="">
|
||||
|
||||
理解了展示单一样式的文本Text的使用方法后,我们再来看看**如何在一段字符串中支持多种混合展示样式**。
|
||||
|
||||
**混合展示样式与单一样式的关键区别在于分片**,即如何把一段字符串分为几个片段来管理,给每个片段单独设置样式。面对这样的需求,在Android中,我们使用SpannableString来实现;在iOS中,我们使用NSAttributedString来实现;而在Flutter中也有类似的概念,即TextSpan。
|
||||
|
||||
TextSpan定义了一个字符串片段该如何控制其展示样式,而将这些有着独立展示样式的字符串组装在一起,则可以支持混合样式的富文本展示。
|
||||
|
||||
如下方代码所示,我们分别定义了黑色与红色两种展示样式,随后把一段字符串分成了4个片段,并设置了不同的展示样式:
|
||||
|
||||
```
|
||||
TextStyle blackStyle = TextStyle(fontWeight: FontWeight.normal, fontSize: 20, color: Colors.black); //黑色样式
|
||||
|
||||
TextStyle redStyle = TextStyle(fontWeight: FontWeight.bold, fontSize: 20, color: Colors.red); //红色样式
|
||||
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: <TextSpan>[
|
||||
TextSpan(text:'文本是视图系统中常见的控件,它用来显示一段特定样式的字符串,类似', style: redStyle), //第1个片段,红色样式
|
||||
TextSpan(text:'Android', style: blackStyle), //第1个片段,黑色样式
|
||||
TextSpan(text:'中的', style:redStyle), //第1个片段,红色样式
|
||||
TextSpan(text:'TextView', style: blackStyle) //第1个片段,黑色样式
|
||||
]),
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
运行效果,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a9/a2/a97d5ee7941585a5185cdb56b4303fa2.png" alt="">
|
||||
|
||||
接下来,我们再看看Flutter中的图片控件Image。
|
||||
|
||||
## 图片
|
||||
|
||||
使用Image,可以让我们向用户展示一张图片。图片的显示方式有很多,比如资源图片、网络图片、文件图片等,图片格式也各不相同,因此在Flutter中也有多种方式,用来加载不同形式、支持不同格式的图片:
|
||||
|
||||
- 加载本地资源图片,如Image.asset(‘images/logo.png’);
|
||||
- 加载本地(File文件)图片,如Image.file(new File(’/storage/xxx/xxx/test.jpg’));
|
||||
- 加载网络图片,如Image.network(`'http://xxx/xxx/test.gif'`) 。
|
||||
|
||||
除了可以根据图片的显示方式设置不同的图片源之外,图片的构造方法还提供了填充模式fit、拉伸模式centerSlice、重复模式repeat等属性,可以针对图片与目标区域的宽高比差异制定排版模式。
|
||||
|
||||
这,和Android中ImageView、iOS里的UIImageView的属性都是类似的。因此,我在这里就不再过多展开了。你可以参考官方文档中的[Image的构造函数](https://api.flutter.dev/flutter/widgets/Image/Image.html)部分,去查看Image控件的具体使用方法。
|
||||
|
||||
关于图片展示,我还要和你分享下Flutter中的**FadeInImage**控件。在加载网络图片的时候,为了提升用户的等待体验,我们往往会加入占位图、加载动画等元素,但是默认的Image.network构造方法并不支持这些高级功能,这时候FadeInImage控件就派上用场了。
|
||||
|
||||
FadeInImage控件提供了图片占位的功能,并且支持在图片加载完成时淡入淡出的视觉效果。此外,由于Image支持gif格式,我们甚至还可以将一些炫酷的加载动画作为占位图。
|
||||
|
||||
下述代码展示了这样的场景。我们在加载大图片时,将一张loading的gif作为占位图展示给用户:
|
||||
|
||||
```
|
||||
FadeInImage.assetNetwork(
|
||||
placeholder: 'assets/loading.gif', //gif占位
|
||||
image: 'https://xxx/xxx/xxx.jpg',
|
||||
fit: BoxFit.cover, //图片拉伸模式
|
||||
width: 200,
|
||||
height: 200,
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/54/e7/547b9bf0bce3dd0cc1c39cbbbe79d2e7.gif" alt="">
|
||||
|
||||
Image控件需要根据图片资源异步加载的情况,决定自身的显示效果,因此是一个StatefulWidget。图片加载过程由ImageProvider触发,而ImageProvider表示异步获取图片数据的操作,可以从资源、文件和网络等不同的渠道获取图片。
|
||||
|
||||
首先,ImageProvider根据_ImageState中传递的图片配置生成对应的图片缓存key;然后,去ImageCache中查找是否有对应的图片缓存,如果有,则通知_ImageState刷新UI;如果没有,则启动ImageStream开始异步加载,加载完毕后,更新缓存;最后,通知_ImageState刷新UI。
|
||||
|
||||
图片展示的流程,可以用以下流程图表示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e8/0c/e84155b756a7c995821a209e1cd9120c.png" alt="">
|
||||
|
||||
值得注意的是,ImageCache使用LRU(Least Recently Used,最近最少使用)算法进行缓存更新策略,并且默认最多存储 1000张图片,最大缓存限制为100MB,当限定的空间已存满数据时,把最久没有被访问到的图片清除。图片**缓存只会在运行期间生效,也就是只缓存在内存中**。如果想要支持缓存到文件系统,可以使用第三方的[CachedNetworkImage](https://pub.dev/packages/cached_network_image/)控件。
|
||||
|
||||
CachedNetworkImage的使用方法与Image类似,除了支持图片缓存外,还提供了比FadeInImage更为强大的加载过程占位与加载错误占位,可以支持比用图片占位更灵活的自定义控件占位。
|
||||
|
||||
在下面的代码中,我们在加载图片时,不仅给用户展示了作为占位的转圈loading,还提供了一个错误图兜底,以备图片加载出错:
|
||||
|
||||
```
|
||||
CachedNetworkImage(
|
||||
imageUrl: "http://xxx/xxx/jpg",
|
||||
placeholder: (context, url) => CircularProgressIndicator(),
|
||||
errorWidget: (context, url, error) => Icon(Icons.error),
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
最后,我们再来看看Flutter中的按钮控件。
|
||||
|
||||
## 按钮
|
||||
|
||||
通过按钮,我们可以响应用户的交互事件。Flutter提供了三个基本的按钮控件,即FloatingActionButton、FlatButton和RaisedButton。
|
||||
|
||||
- FloatingActionButton:一个圆形的按钮,一般出现在屏幕内容的前面,用来处理界面中最常用、最基础的用户动作。在之前的第5篇文章“[从标准模板入手,体会Flutter代码是如何运行在原生系统上的](https://time.geekbang.org/column/article/106199)”中,计数器示例的“+”悬浮按钮就是一个FloatingActionButton。
|
||||
- RaisedButton:凸起的按钮,默认带有灰色背景,被点击后灰色背景会加深。
|
||||
- FlatButton:扁平化的按钮,默认透明背景,被点击后会呈现灰色背景。
|
||||
|
||||
这三个按钮控件的使用方法类似,唯一的区别只是默认样式不同而已。
|
||||
|
||||
下述代码中,我分别定义了FloatingActionButton、FlatButton与RaisedButton,它们的功能完全一样,在点击时打印一段文字:
|
||||
|
||||
```
|
||||
FloatingActionButton(onPressed: () => print('FloatingActionButton pressed'),child: Text('Btn'),);
|
||||
FlatButton(onPressed: () => print('FlatButton pressed'),child: Text('Btn'),);
|
||||
RaisedButton(onPressed: () => print('RaisedButton pressed'),child: Text('Btn'),);
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fb/ad/fbd51429fd339ebf715a0e0248270cad.png" alt="">
|
||||
|
||||
既然是按钮,因此除了控制基本样式之外,还需要响应用户点击行为。这就对应着按钮控件中的两个最重要的参数了:
|
||||
|
||||
- onPressed参数用于设置点击回调,告诉Flutter在按钮被点击时通知我们。如果onPressed参数为空,则按钮会处于禁用状态,不响应用户点击。
|
||||
- child参数用于设置按钮的内容,告诉Flutter控件应该长成什么样,也就是控制着按钮控件的基本样式。child可以接收任意的Widget,比如我们在上面的例子中传入的Text,除此之外我们还可以传入Image等控件。
|
||||
|
||||
虽然我们可以通过child参数来控制按钮控件的基本样式,但是系统默认的样式还是太单调了。因此通常情况下,我们还是会进行控件样式定制。
|
||||
|
||||
与Text控件类似,按钮控件也提供了丰富的样式定制功能,比如背景颜色color、按钮形状shape、主题颜色colorBrightness,等等。
|
||||
|
||||
接下来,我就以FlatButton为例,与你介绍按钮的样式定制:
|
||||
|
||||
```
|
||||
FlatButton(
|
||||
color: Colors.yellow, //设置背景色为黄色
|
||||
shape:BeveledRectangleBorder(borderRadius: BorderRadius.circular(20.0)), //设置斜角矩形边框
|
||||
colorBrightness: Brightness.light, //确保文字按钮为深色
|
||||
onPressed: () => print('FlatButton pressed'),
|
||||
child: Row(children: <Widget>[Icon(Icons.add), Text("Add")],)
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
可以看到,我们将一个加号Icon与文本组合,定义了按钮的基本外观;随后通过shape来指定其外形为一个斜角矩形边框,并将按钮的背景色设置为黄色。
|
||||
|
||||
因为按钮背景颜色是浅色的,为避免按钮文字看不清楚,我们通过设置按钮主题colorBrightness为Brightness.light,保证按钮文字颜色为深色。
|
||||
|
||||
展示效果如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9a/04/9ad900d3aaa384237aea4fc3205ca404.png" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
UI控件是构建一个视图的基本元素,而文本、图片和按钮则是其中最经典的控件。
|
||||
|
||||
接下来,我们简单回顾一下今天的内容,以便加深理解与记忆。
|
||||
|
||||
首先,我们认识了支持单一样式和混合样式两种类型的文本展示控件Text。其中,通过TextStyle控制字符串的展示样式,其他参数控制文本布局,可以实现单一样式的文本展示;而通过TextSpan将字符串分割为若干片段,对每个片段单独设置样式后组装,可以实现支持混合样式的富文本展示。
|
||||
|
||||
然后,我带你学习了支持多种图片源加载方式的图片控件Image。Image内部通过ImageProvider根据缓存状态,触发异步加载流程,通知_ImageState刷新UI。不过,由于图片缓存是内存缓存,因此只在运行期间生效。如果要支持缓存到文件系统,可以使用第三方的CachedNetworkImage。
|
||||
|
||||
最后,我们学习了按钮控件。Flutter提供了多种按钮控件,而它们的使用方法也都类似。其中,控件初始化的child参数用于设置按钮长什么样,而onPressed参数则用于设置点击回调。与Text类似,按钮内部也有丰富的UI定制接口,可以满足开发者的需求。
|
||||
|
||||
通过今天的学习,我们可以发现,在UI基本信息的表达上,Flutter的经典控件与原生Android、iOS系统提供的控件没有什么本质区别。但是,在自定义控件样式上,Flutter的这些经典控件提供了强大而简洁的扩展能力,使得我们可以快速开发出功能复杂、样式丰富的页面。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留下一道思考题吧。
|
||||
|
||||
请你打开IDE,阅读Flutter SDK中Text、Image、FadeInImage,以及按钮控件FloatingActionButton、FlatButton与RaisedButton的源码,在build函数中找出在内部真正承载其视觉功能的控件。请和我分享下,你在这一过程中发现了什么现象?
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
<audio id="audio" title="13 | 经典控件(二):UITableView/ListView在Flutter中是什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/76/4c/760c496fca059dda4e3941d02329614c.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
在上一篇文章中,我和你一起学习了文本、图片和按钮这3大经典组件在Flutter中的使用方法,以及如何在实际开发中根据不同的场景,去自定义展示样式。
|
||||
|
||||
文本、图片和按钮这些基本元素,需要进行排列组合,才能构成我们看到的UI视图。那么,当这些基本元素的排列布局超过屏幕显示尺寸(即超过一屏)时,我们就需要引入列表控件来展示视图的完整内容,并根据元素的多少进行自适应滚动展示。
|
||||
|
||||
这样的需求,在Android中是由ListView或RecyclerView实现的,在iOS中是用UITableView实现的;而在Flutter中,实现这种需求的则是列表控件ListView。
|
||||
|
||||
## ListView
|
||||
|
||||
在Flutter中,ListView可以沿一个方向(垂直或水平方向)来排列其所有子Widget,因此常被用于需要展示一组连续视图元素的场景,比如通信录、优惠券、商家列表等。
|
||||
|
||||
我们先来看看ListView怎么用。**ListView提供了一个默认构造函数ListView**,我们可以通过设置它的children参数,很方便地将所有的子Widget包含到ListView中。
|
||||
|
||||
不过,这种创建方式要求提前将所有子Widget一次性创建好,而不是等到它们真正在屏幕上需要显示时才创建,所以有一个很明显的缺点,就是性能不好。因此,**这种方式仅适用于列表中含有少量元素的场景**。
|
||||
|
||||
如下所示,我定义了一组列表项组件,并将它们放在了垂直滚动的ListView中:
|
||||
|
||||
```
|
||||
ListView(
|
||||
children: <Widget>[
|
||||
//设置ListTile组件的标题与图标
|
||||
ListTile(leading: Icon(Icons.map), title: Text('Map')),
|
||||
ListTile(leading: Icon(Icons.mail), title: Text('Mail')),
|
||||
ListTile(leading: Icon(Icons.message), title: Text('Message')),
|
||||
]);
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
<p>备注:ListTile是Flutter提供的用于快速构建列表项元素的一个小组件单元,用于1~3行(leading、title、subtitle)展示文本、图标等视图元素的场景,通常与ListView配合使用。<br>
|
||||
上面这段代码中用到ListTile,是为了演示ListView的能力。关于ListTile的具体使用细节,并不是本篇文章的重点,如果你想深入了解的话,可以参考[官方文档](https://api.flutter.dev/flutter/material/ListTile-class.html)。</p>
|
||||
|
||||
|
||||
运行效果,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b1/01/b152f47246c851c3c1878564f07de101.png" alt="">
|
||||
|
||||
除了默认的垂直方向布局外,ListView还可以通过设置scrollDirection参数支持水平方向布局。如下所示,我定义了一组不同颜色背景的组件,将它们的宽度设置为140,并包在了水平布局的ListView中,让它们可以横向滚动:
|
||||
|
||||
```
|
||||
ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemExtent: 140, //item延展尺寸(宽度)
|
||||
children: <Widget>[
|
||||
Container(color: Colors.black),
|
||||
Container(color: Colors.red),
|
||||
Container(color: Colors.blue),
|
||||
Container(color: Colors.green),
|
||||
Container(color: Colors.yellow),
|
||||
Container(color: Colors.orange),
|
||||
]);
|
||||
|
||||
```
|
||||
|
||||
运行效果,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/df/ac/df382224daeca7067d3a9c5acc5febac.gif" alt="">
|
||||
|
||||
在这个例子中,我们一次性创建了6个子Widget。但从图2的运行效果可以看到,由于屏幕的宽高有限,同一时间用户只能看到3个Widget。也就是说,是否一次性提前构建出所有要展示的子Widget,与用户而言并没有什么视觉上的差异。
|
||||
|
||||
所以,考虑到创建子Widget产生的性能问题,更好的方法是抽象出创建子Widget的方法,交由ListView统一管理,在真正需要展示该子Widget时再去创建。
|
||||
|
||||
**ListView的另一个构造函数ListView.builder,则适用于子Widget比较多的场景**。这个构造函数有两个关键参数:
|
||||
|
||||
- itemBuilder,是列表项的创建方法。当列表滚动到相应位置时,ListView会调用该方法创建对应的子Widget。
|
||||
- itemCount,表示列表项的数量,如果为空,则表示ListView为无限列表。
|
||||
|
||||
同样地,我通过一个案例,与你说明itemBuilder与itemCount这两个参数的具体用法。
|
||||
|
||||
我定义了一个拥有100个列表元素的ListView,在列表项的创建方法中,分别将index的值设置为ListTile的标题与子标题。比如,第一行列表项会展示title 0 body 0:
|
||||
|
||||
```
|
||||
ListView.builder(
|
||||
itemCount: 100, //元素个数
|
||||
itemExtent: 50.0, //列表项高度
|
||||
itemBuilder: (BuildContext context, int index) => ListTile(title: Text("title $index"), subtitle: Text("body $index"))
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
这里需要注意的是,**itemExtent并不是一个必填参数。但,对于定高的列表项元素,我强烈建议你提前设置好这个参数的值。**
|
||||
|
||||
因为如果这个参数为null,ListView会动态地根据子Widget创建完成的结果,决定自身的视图高度,以及子Widget在ListView中的相对位置。在滚动发生变化而列表项又很多时,这样的计算就会非常频繁。
|
||||
|
||||
但如果提前设置好itemExtent,ListView则可以提前计算好每一个列表项元素的相对位置,以及自身的视图高度,省去了无谓的计算。
|
||||
|
||||
因此,在ListView中,指定itemExtent比让子Widget自己决定自身高度会更高效。
|
||||
|
||||
运行这个示例,效果如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d6/5a/d654c5a28056afc017fe3f085230745a.png" alt="">
|
||||
|
||||
可能你已经发现了,我们的列表还缺少分割线。在ListView中,有两种方式支持分割线:
|
||||
|
||||
- 一种是,在itemBuilder中,根据index的值动态创建分割线,也就是将分割线视为列表项的一部分;
|
||||
- 另一种是,使用ListView的另一个构造方法ListView.separated,单独设置分割线的样式。
|
||||
|
||||
第一种方式实际上是视图的组合,之前的分享中我们已经多次提及,对你来说应该已经比较熟悉了,这里我就不再过多地介绍了。接下来,我和你演示一下**如何使用ListView.separated设置分割线。**
|
||||
|
||||
与ListView.builder抽离出了子Widget的构建方法类似,ListView.separated抽离出了分割线的创建方法separatorBuilder,以便根据index设置不同样式的分割线。
|
||||
|
||||
如下所示,我针对index为偶数的场景,创建了绿色的分割线,而针对index为奇数的场景,创建了红色的分割线:
|
||||
|
||||
```
|
||||
//使用ListView.separated设置分割线
|
||||
ListView.separated(
|
||||
itemCount: 100,
|
||||
separatorBuilder: (BuildContext context, int index) => index %2 ==0? Divider(color: Colors.green) : Divider(color: Colors.red),//index为偶数,创建绿色分割线;index为奇数,则创建红色分割线
|
||||
itemBuilder: (BuildContext context, int index) => ListTile(title: Text("title $index"), subtitle: Text("body $index"))//创建子Widget
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
运行效果,如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5e/3b/5e1ef0977150346fa95f23232d628e3b.png" alt="">
|
||||
|
||||
好了,我已经与你分享完了ListView的常见构造函数。接下来,我准备了一张表格,总结了ListView常见的构造方法及其适用场景,供你参考,以便理解与记忆:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/00/18/00e6c9f8724fcf50757b4a76fa4c9b18.png" alt="">
|
||||
|
||||
## CustomScrollView
|
||||
|
||||
好了,ListView实现了单一视图下可滚动Widget的交互模型,同时也包含了UI显示相关的控制逻辑和布局模型。但是,对于某些特殊交互场景,比如多个效果联动、嵌套滚动、精细滑动、视图跟随手势操作等,还需要嵌套多个ListView来实现。这时,各自视图的滚动和布局模型就是相互独立、分离的,就很难保证整个页面统一一致的滑动效果。
|
||||
|
||||
那么,**Flutter是如何解决多ListView嵌套时,页面滑动效果不一致的问题的呢?**
|
||||
|
||||
在Flutter中有一个专门的控件CustomScrollView,用来处理多个需要自定义滚动效果的Widget。在CustomScrollView中,**这些彼此独立的、可滚动的Widget被统称为Sliver**。
|
||||
|
||||
比如,ListView的Sliver实现为SliverList,AppBar的Sliver实现为SliverAppBar。这些Sliver不再维护各自的滚动状态,而是交由CustomScrollView统一管理,最终实现滑动效果的一致性。
|
||||
|
||||
接下来,我通过一个滚动视差的例子,与你演示CustomScrollView的使用方法。
|
||||
|
||||
**视差滚动**是指让多层背景以不同的速度移动,在形成立体滚动效果的同时,还能保证良好的视觉体验。 作为移动应用交互设计的热点趋势,越来越多的移动应用使用了这项技术。
|
||||
|
||||
以一个有着封面头图的列表为例,我们希望封面头图和列表这两层视图的滚动联动起来,当用户滚动列表时,头图会根据用户的滚动手势,进行缩小和展开。
|
||||
|
||||
经分析得出,要实现这样的需求,我们需要两个Sliver:作为头图的SliverAppBar,与作为列表的SliverList。具体的实现思路是:
|
||||
|
||||
- 在创建SliverAppBar时,把flexibleSpace参数设置为悬浮头图背景。flexibleSpace可以让背景图显示在AppBar下方,高度和SliverAppBar一样;
|
||||
- 而在创建SliverList时,通过SliverChildBuilderDelegate参数实现列表项元素的创建;
|
||||
- 最后,将它们一并交由CustomScrollView的slivers参数统一管理。
|
||||
|
||||
具体的示例代码如下所示:
|
||||
|
||||
```
|
||||
CustomScrollView(
|
||||
slivers: <Widget>[
|
||||
SliverAppBar(//SliverAppBar作为头图控件
|
||||
title: Text('CustomScrollView Demo'),//标题
|
||||
floating: true,//设置悬浮样式
|
||||
flexibleSpace: Image.network("https://xx.jpg",fit:BoxFit.cover),//设置悬浮头图背景
|
||||
expandedHeight: 300,//头图控件高度
|
||||
),
|
||||
SliverList(//SliverList作为列表控件
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => ListTile(title: Text('Item #$index')),//列表项创建方法
|
||||
childCount: 100,//列表元素个数
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
```
|
||||
|
||||
运行一下,视差滚动效果如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/dc/21/dcf89204e537f828d197dc3b916ca321.gif" alt="">
|
||||
|
||||
## ScrollController与ScrollNotification
|
||||
|
||||
现在,你应该已经知道如何实现滚动视图的视觉和交互效果了。接下来,我再与你分享一个更为复杂的问题:在某些情况下,我们希望获取视图的滚动信息,并进行相应的控制。比如,列表是否已经滑到底(顶)了?如何快速回到列表顶部?列表滚动是否已经开始,或者是否已经停下来了?
|
||||
|
||||
对于前两个问题,我们可以使用ScrollController进行滚动信息的监听,以及相应的滚动控制;而最后一个问题,则需要接收ScrollNotification通知进行滚动事件的获取。下面我将分别与你介绍。
|
||||
|
||||
在Flutter中,因为Widget并不是渲染到屏幕的最终视觉元素(RenderObject才是),所以我们无法像原生的Android或iOS系统那样,向持有的Widget对象获取或设置最终渲染相关的视觉信息,而必须通过对应的组件控制器才能实现。
|
||||
|
||||
ListView的组件控制器则是ScrollControler,我们可以通过它来获取视图的滚动信息,更新视图的滚动位置。
|
||||
|
||||
一般而言,获取视图的滚动信息往往是为了进行界面的状态控制,因此ScrollController的初始化、监听及销毁需要与StatefulWidget的状态保持同步。
|
||||
|
||||
如下代码所示,我们声明了一个有着100个元素的列表项,当滚动视图到特定位置后,用户可以点击按钮返回列表顶部:
|
||||
|
||||
- 首先,我们在State的初始化方法里,创建了ScrollController,并通过_controller.addListener注册了滚动监听方法回调,根据当前视图的滚动位置,判断当前是否需要展示“Top”按钮。
|
||||
- 随后,在视图构建方法build中,我们将ScrollController对象与ListView进行了关联,并且在RaisedButton中注册了对应的回调方法,可以在点击按钮时通过_controller.animateTo方法返回列表顶部。
|
||||
- 最后,在State的销毁方法中,我们对ScrollController进行了资源释放。
|
||||
|
||||
```
|
||||
class MyAPPState extends State<MyApp> {
|
||||
ScrollController _controller;//ListView控制器
|
||||
bool isToTop = false;//标示目前是否需要启用"Top"按钮
|
||||
@override
|
||||
void initState() {
|
||||
_controller = ScrollController();
|
||||
_controller.addListener(() {//为控制器注册滚动监听方法
|
||||
if(_controller.offset > 1000) {//如果ListView已经向下滚动了1000,则启用Top按钮
|
||||
setState(() {isToTop = true;});
|
||||
} else if(_controller.offset < 300) {//如果ListView向下滚动距离不足300,则禁用Top按钮
|
||||
setState(() {isToTop = false;});
|
||||
}
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
...
|
||||
//顶部Top按钮,根据isToTop变量判断是否需要注册滚动到顶部的方法
|
||||
RaisedButton(onPressed: (isToTop ? () {
|
||||
if(isToTop) {
|
||||
_controller.animateTo(.0,
|
||||
duration: Duration(milliseconds: 200),
|
||||
curve: Curves.ease
|
||||
);//做一个滚动到顶部的动画
|
||||
}
|
||||
}:null),child: Text("Top"),)
|
||||
...
|
||||
ListView.builder(
|
||||
controller: _controller,//初始化传入控制器
|
||||
itemCount: 100,//列表元素总数
|
||||
itemBuilder: (context, index) => ListTile(title: Text("Index : $index")),//列表项构造方法
|
||||
)
|
||||
...
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose(); //销毁控制器
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
ScrollController的运行效果如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/61/1b/61533dc0e445bd529879698ad3491b1b.gif" alt="">
|
||||
|
||||
介绍完了如何通过ScrollController来监听ListView滚动信息,以及怎样进行滚动控制之后,接下来我们再看看**如何获取ScrollNotification通知,从而感知ListView的各类滚动事件**。
|
||||
|
||||
在Flutter中,ScrollNotification通知的获取是通过NotificationListener来实现的。与ScrollController不同的是,NotificationListener是一个Widget,为了监听滚动类型的事件,我们需要将NotificationListener添加为ListView的父容器,从而捕获ListView中的通知。而这些通知,需要通过onNotification回调函数实现监听逻辑:
|
||||
|
||||
```
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'ScrollController Demo',
|
||||
home: Scaffold(
|
||||
appBar: AppBar(title: Text('ScrollController Demo')),
|
||||
body: NotificationListener<ScrollNotification>(//添加NotificationListener作为父容器
|
||||
onNotification: (scrollNotification) {//注册通知回调
|
||||
if (scrollNotification is ScrollStartNotification) {//滚动开始
|
||||
print('Scroll Start');
|
||||
} else if (scrollNotification is ScrollUpdateNotification) {//滚动位置更新
|
||||
print('Scroll Update');
|
||||
} else if (scrollNotification is ScrollEndNotification) {//滚动结束
|
||||
print('Scroll End');
|
||||
}
|
||||
},
|
||||
child: ListView.builder(
|
||||
itemCount: 30,//列表元素个数
|
||||
itemBuilder: (context, index) => ListTile(title: Text("Index : $index")),//列表项创建方法
|
||||
),
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
相比于ScrollController只能和具体的ListView关联后才可以监听到滚动信息;通过NotificationListener则可以监听其子Widget中的任意ListView,不仅可以得到这些ListView的当前滚动位置信息,还可以获取当前的滚动事件信息 。
|
||||
|
||||
## 总结
|
||||
|
||||
在处理用于展示一组连续、可滚动的视图元素的场景,Flutter提供了比原生Android、iOS系统更加强大的列表组件ListView与CustomScrollView,不仅可以支持单一视图下可滚动Widget的交互模型及UI控制模型,对于某些特殊交互,需要嵌套多重可滚动Widget的场景,也提供了统一管理的机制,最终实现体验一致的滑动效果。这些强大的组件,使得我们不仅可以开发出样式丰富的界面,更可以实现复杂的交互。
|
||||
|
||||
接下来,我们简单回顾一下今天的内容,以便加深你的理解与记忆。
|
||||
|
||||
首先,我们认识了ListView组件。它同时支持垂直方向和水平方向滚动,不仅提供了少量一次性创建子视图的默认构造方式,也提供了大量按需创建子视图的ListView.builder机制,并且支持自定义分割线。为了节省性能,对于定高的列表项视图,提前指定itemExtent比让子Widget自己决定要更高效。
|
||||
|
||||
随后,我带你学习了CustomScrollView组件。它引入了Sliver的概念,将多重嵌套的可滚动视图的交互与布局进行统一接管,使得像视差滚动这样的高级交互变得更加容易。
|
||||
|
||||
最后,我们学习了ScrollController与NotificationListener,前者与ListView绑定,进行滚动信息的监听,进行相应的滚动控制;而后者,通过将ListView纳入子Widget,实现滚动事件的获取。
|
||||
|
||||
我把今天分享讲的三个例子(视差、ScrollController、ScrollNotification)放到了[GitHub](https://github.com/cyndibaby905/13_listview_demo)上,你可以下载后在工程中实际运行,并对照着今天的知识点进行学习,体会ListView的一些高级用法。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留下两个小作业吧:
|
||||
|
||||
1. 在ListView.builder方法中,ListView根据Widget是否将要出现在可视区域内,按需创建。对于一些场景,为了避免Widget渲染时间过长(比如图片下载),我们需要提前将可视区域上下一定区域内的Widget提前创建好。那么,在Flutter中,如何才能实现呢?
|
||||
1. 请你使用NotificationListener,来实现图7 ScrollController示例中同样的功能。
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
249
极客时间专栏/Flutter核心技术与实战/Flutter基础/14 | 经典布局:如何定义子控件在父容器中排版的位置?.md
Normal file
249
极客时间专栏/Flutter核心技术与实战/Flutter基础/14 | 经典布局:如何定义子控件在父容器中排版的位置?.md
Normal file
@@ -0,0 +1,249 @@
|
||||
<audio id="audio" title="14 | 经典布局:如何定义子控件在父容器中排版的位置?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c1/47/c1c75807f99f8ce3e716aefd54dd2247.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
在前面两篇文章中,我们一起学习了构建视图的基本元素:文本、图片和按钮,用于展示一组连续视图元素的ListView,以及处理多重嵌套的可滚动视图的CustomScrollView。
|
||||
|
||||
在Flutter中,一个完整的界面通常就是由这些小型、单用途的基本控件元素依据特定的布局规则堆砌而成的。那么今天,我就带你一起学习一下,在Flutter中,搭建出一个漂亮的布局,我们需要了解哪些布局规则,以及这些规则与其他平台类似概念的差别在哪里。希望这样的设计,可以帮助你站在已有经验的基础上去高效学习Flutter的布局规则。
|
||||
|
||||
我们已经知道,在Flutter中一切皆Widget,那么布局也不例外。但与基本控件元素不同,布局类的Widget并不会直接呈现视觉内容,而是作为承载其他子Widget的容器。
|
||||
|
||||
这些布局类的Widget,内部都会包含一个或多个子控件,并且都提供了摆放子控件的不同布局方式,可以实现子控件的对齐、嵌套、层叠和缩放等。而我们要做的就是,通过各种定制化的参数,将其内部的子Widget依照自己的布局规则放置在特定的位置上,最终形成一个漂亮的布局。
|
||||
|
||||
Flutter提供了31种[布局Widget](https://flutter.dev/docs/development/ui/widgets/layout),对布局控件的划分非常详细,一些相同(或相似)的视觉效果可以通过多种布局控件实现,因此布局类型相比原生Android、iOS平台多了不少。比如,Android布局一般就只有FrameLayout、LinearLayout、RelativeLayout、GridLayout和TableLayout这5种,而iOS的布局更少,只有Frame布局和自动布局两种。
|
||||
|
||||
为了帮你建立起对布局类Widget的认知,了解基本布局类Widget的布局特点和用法,从而学以致用快速上手开发,在今天的这篇文章中,我特意挑选了几类在开发Flutter应用时,最常用也最有代表性的布局Widget,包括单子Widget布局、多子Widget布局、层叠Widget布局,与你展开介绍。
|
||||
|
||||
掌握了这些典型的Widget,你也就基本掌握了构建一个界面精美的App所需要的全部布局方式了。接下来,我们就先从单子Widget布局聊起吧。
|
||||
|
||||
## 单子Widget布局:Container、Padding与Center
|
||||
|
||||
单子Widget布局类容器比较简单,一般用来对其唯一的子Widget进行样式包装,比如限制大小、添加背景色样式、内间距、旋转变换等。这一类布局Widget,包括Container、Padding与Center三种。
|
||||
|
||||
Container,是一种允许在其内部添加其他控件的控件,也是UI框架中的一个常见概念。
|
||||
|
||||
在Flutter中,Container本身可以单独作为控件存在(比如单独设置背景色、宽高),也可以作为其他控件的父级存在:Container可以定义布局过程中子Widget如何摆放,以及如何展示。与其他框架不同的是,**Flutter的Container仅能包含一个子Widget**。
|
||||
|
||||
所以,对于多个子Widget的布局场景,我们通常会这样处理:先用一个根Widget去包装这些子Widget,然后把这个根Widget放到Container中,再由Container设置它的对齐alignment、边距padding等基础属性和样式属性。
|
||||
|
||||
接下来,我通过一个示例,与你演示如何定义一个Container。
|
||||
|
||||
在这个示例中,我将一段较长的文字,包装在一个红色背景、圆角边框的、固定宽高的Container中,并分别设置了Container的外边距(距离其父Widget的边距)和内边距(距离其子Widget的边距):
|
||||
|
||||
```
|
||||
Container(
|
||||
child: Text('Container(容器)在UI框架中是一个很常见的概念,Flutter也不例外。'),
|
||||
padding: EdgeInsets.all(18.0), // 内边距
|
||||
margin: EdgeInsets.all(44.0), // 外边距
|
||||
width: 180.0,
|
||||
height:240,
|
||||
alignment: Alignment.center, // 子Widget居中对齐
|
||||
decoration: BoxDecoration( //Container样式
|
||||
color: Colors.red, // 背景色
|
||||
borderRadius: BorderRadius.circular(10.0), // 圆角边框
|
||||
),
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fa/f7/fad72eb6917be0062df5a46a104f3ff7.png" alt="">
|
||||
|
||||
如果我们只需要将子Widget设定间距,则可以使用另一个单子容器控件Padding进行内容填充:
|
||||
|
||||
```
|
||||
Padding(
|
||||
padding: EdgeInsets.all(44.0),
|
||||
child: Text('Container(容器)在UI框架中是一个很常见的概念,Flutter也不例外。'),
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/24/ae/24a18be98054d93ddf8989aac1a5a5ae.png" alt="">
|
||||
|
||||
在需要设置内容间距时,我们可以通过EdgeInsets的不同构造函数,分别制定四个方向的不同补白方式,如均使用同样数值留白、只设置左留白或对称方向留白等。如果你想更深入地了解这部分内容,可以参考这个[API文档](https://api.flutter.dev/flutter/painting/EdgeInsets-class.html#constructors)。
|
||||
|
||||
接下来,我们再来看看单子Widget布局容器中另一个常用的容器Center。正如它的名字一样,Center会将其子Widget居中排列。
|
||||
|
||||
比如,我们可以把一个Text包在Center里,实现居中展示:
|
||||
|
||||
```
|
||||
Scaffold(
|
||||
body: Center(child: Text("Hello")) // This trailing comma makes auto-formatting nicer for build methods.
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c5/65/c500408d7df10249494a2a90cb815a65.png" alt="">
|
||||
|
||||
需要注意的是,为了实现居中布局,Center所占据的空间一定要比其子Widget要大才行,这也是显而易见的:如果Center和其子Widget一样大,自然就不需要居中,也没空间居中了。因此Center通常会结合Container一起使用。
|
||||
|
||||
现在,我们结合Container,一起看看Center的具体使用方法吧。
|
||||
|
||||
```
|
||||
Container(
|
||||
child: Center(child: Text('Container(容器)在UI框架中是一个很常见的概念,Flutter也不例外。')),
|
||||
padding: EdgeInsets.all(18.0), // 内边距
|
||||
margin: EdgeInsets.all(44.0), // 外边距
|
||||
width: 180.0,
|
||||
height:240,
|
||||
decoration: BoxDecoration( //Container样式
|
||||
color: Colors.red, // 背景色
|
||||
borderRadius: BorderRadius.circular(10.0), // 圆角边框
|
||||
),
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
可以看到,我们通过Center容器实现了Container容器中**alignment: Alignment.center**的效果。
|
||||
|
||||
事实上,为了达到这一效果,Container容器与Center容器底层都依赖了同一个容器Align,通过它实现子Widget的对齐方式。Align的使用也比较简单,如果你想深入了解的话,可以参考[官方文档](https://api.flutter.dev/flutter/widgets/Align-class.html),这里我就不再过多介绍了。
|
||||
|
||||
接下来,我们再看看多子Widget布局的三种方式,即Row、Column与Expanded。
|
||||
|
||||
## 多子Widget布局:Row、Column与Expanded
|
||||
|
||||
对于拥有多个子Widget的布局类容器而言,其布局行为无非就是两种规则的抽象:水平方向上应该如何布局、垂直方向上应该如何布局。
|
||||
|
||||
如同Android的LinearLayout、前端的Flex布局一样,Flutter中也有类似的概念,即将子Widget按行水平排列的Row,按列垂直排列的Column,以及负责分配这些子Widget在布局方向(行/列)中剩余空间的Expanded。
|
||||
|
||||
Row与Column的使用方法很简单,我们只需要将各个子Widget按序加入到children数组即可。在下面的代码中,我们把4个分别设置了不同的颜色和宽高的Container加到Row与Column中:
|
||||
|
||||
```
|
||||
//Row的用法示范
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Container(color: Colors.yellow, width: 60, height: 80,),
|
||||
Container(color: Colors.red, width: 100, height: 180,),
|
||||
Container(color: Colors.black, width: 60, height: 80,),
|
||||
Container(color: Colors.green, width: 60, height: 80,),
|
||||
],
|
||||
);
|
||||
|
||||
//Column的用法示范
|
||||
Column(
|
||||
children: <Widget>[
|
||||
Container(color: Colors.yellow, width: 60, height: 80,),
|
||||
Container(color: Colors.red, width: 100, height: 180,),
|
||||
Container(color: Colors.black, width: 60, height: 80,),
|
||||
Container(color: Colors.green, width: 60, height: 80,),
|
||||
],
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/90/72/909ad17a65cad573bca0c84c09b7fc72.png" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9a/86/9a1bd0067d1bbb03d5ab74f411afae86.png" alt="">
|
||||
|
||||
可以看到,单纯使用Row和Column控件,在子Widget的尺寸较小时,无法将容器填满,视觉样式比较难看。对于这样的场景,我们可以通过Expanded控件,来制定分配规则填满容器的剩余空间。
|
||||
|
||||
比如,我们希望Row组件(或Column组件)中的绿色容器与黄色容器均分剩下的空间,于是就可以设置它们的弹性系数参数flex都为1,这两个Expanded会按照其flex的比例(即1:1)来分割剩余的Row横向(Column纵向)空间:
|
||||
|
||||
```
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Expanded(flex: 1, child: Container(color: Colors.yellow, height: 60)), //设置了flex=1,因此宽度由Expanded来分配
|
||||
Container(color: Colors.red, width: 100, height: 180,),
|
||||
Container(color: Colors.black, width: 60, height: 80,),
|
||||
Expanded(flex: 1, child: Container(color: Colors.green,height: 60),)/设置了flex=1,因此宽度由Expanded来分配
|
||||
],
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0e/04/0ed3fba81215150607edddaafbe9b304.png" alt="">
|
||||
|
||||
于Row与Column而言,Flutter提供了依据坐标轴的布局对齐行为,即根据布局方向划分出主轴和纵轴:主轴,表示容器依次摆放子Widget的方向;纵轴,则是与主轴垂直的另一个方向。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/61/09/610157c35f4457a7fffa2005ea144609.png" alt="">
|
||||
|
||||
我们可以根据主轴与纵轴,设置子Widget在这两个方向上的对齐规则mainAxisAlignment与crossAxisAlignment。比如,主轴方向start表示靠左对齐、center表示横向居中对齐、end表示靠右对齐、spaceEvenly表示按固定间距对齐;而纵轴方向start则表示靠上对齐、center表示纵向居中对齐、end表示靠下对齐。
|
||||
|
||||
下图展示了在Row中设置不同方向的对齐规则后的呈现效果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9f/87/9f3a8a9e197b350f6c0aad6f5195fc87.png" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/9b/d8fc6d0aa98be8a6b1867b24a833b89b.png" alt="">
|
||||
|
||||
Column的对齐方式也是类似的,我就不再过多展开了。
|
||||
|
||||
这里需要注意的是,对于主轴而言,Flutter默认是让父容器决定其长度,即尽可能大,类似Android中的match_parent。
|
||||
|
||||
在上面的例子中,Row的宽度为屏幕宽度,Column的高度为屏幕高度。主轴长度大于所有子Widget的总长度,意味着容器在主轴方向的空间比子Widget要大,这也是我们能通过主轴对齐方式设置子Widget布局效果的原因。
|
||||
|
||||
如果想让容器与子Widget在主轴上完全匹配,我们可以通过设置Row的mainAxisSize参数为MainAxisSize.min,由所有子Widget来决定主轴方向的容器长度,即主轴方向的长度尽可能小,类似Android中的wrap_content:
|
||||
|
||||
```
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly, //由于容器与子Widget一样宽,因此这行设置排列间距的代码并未起作用
|
||||
mainAxisSize: MainAxisSize.min, //让容器宽度与所有子Widget的宽度一致
|
||||
children: <Widget>[
|
||||
Container(color: Colors.yellow, width: 60, height: 80,),
|
||||
Container(color: Colors.red, width: 100, height: 180,),
|
||||
Container(color: Colors.black, width: 60, height: 80,),
|
||||
Container(color: Colors.green, width: 60, height: 80,),
|
||||
],
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/96/d8d9cc480386bd11e60da51ddc081696.png" alt="">
|
||||
|
||||
可以看到,我们设置了主轴大小为MainAxisSize.min之后,Row的宽度变得和其子Widget一样大,因此再设置主轴的对齐方式也就不起作用了。
|
||||
|
||||
## 层叠Widget布局:Stack与Positioned
|
||||
|
||||
有些时候,我们需要让一个控件叠加在另一个控件的上面,比如在一张图片上放置一段文字,又或者是在图片的某个区域放置一个按钮。这时候,我们就需要用到层叠布局容器Stack了。
|
||||
|
||||
Stack容器与前端中的绝对定位、Android中的Frame布局非常类似,子Widget之间允许叠加,还可以根据父容器上、下、左、右四个角的位置来确定自己的位置。
|
||||
|
||||
**Stack提供了层叠布局的容器,而Positioned则提供了设置子Widget位置的能力**。接下来,我们就通过一个例子来看一下Stack和Positioned的具体用法吧。
|
||||
|
||||
在这个例子中,我先在Stack中放置了一块300**300的黄色画布,随后在(18,18)处放置了一个50**50的绿色控件,然后在(18,70)处放置了一个文本控件。
|
||||
|
||||
```
|
||||
Stack(
|
||||
children: <Widget>[
|
||||
Container(color: Colors.yellow, width: 300, height: 300),//黄色容器
|
||||
Positioned(
|
||||
left: 18.0,
|
||||
top: 18.0,
|
||||
child: Container(color: Colors.green, width: 50, height: 50),//叠加在黄色容器之上的绿色控件
|
||||
),
|
||||
Positioned(
|
||||
left: 18.0,
|
||||
top:70.0,
|
||||
child: Text("Stack提供了层叠布局的容器"),//叠加在黄色容器之上的文本
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
试着运行一下,可以看到,这三个子Widget都按照我们预定的规则叠加在一起了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/36/bb046cc53ea595a02a564a4387a99c36.png" alt="">
|
||||
|
||||
Stack控件允许其子Widget按照创建的先后顺序进行层叠摆放,而Positioned控件则用来控制这些子Widget的摆放位置。需要注意的是,Positioned控件只能在Stack中使用,在其他容器中使用会报错。
|
||||
|
||||
## 总结
|
||||
|
||||
Flutter的布局容器强大而丰富,可以将小型、单用途的基本视觉元素快速封装成控件。今天我选取了Flutter中最具代表性,也最常用的几类布局Widget,与你介绍了构建一个界面精美的App所需要的布局概念。
|
||||
|
||||
接下来,我们简单回顾一下今天的内容,以便加深理解与记忆:
|
||||
|
||||
首先,我们认识了单子容器Container、Padding与Center。其中,Container内部提供了间距、背景样式等基础属性,为子Widget的摆放方式,及展现样式都提供了定制能力。而Padding与Center提供的功能,则正如其名一样简洁,就是对齐与居中。
|
||||
|
||||
然后,我们深入学习了多子Widget布局中的Row和Column,各子Widget间对齐的规则,以及容器自身扩充的规则,以及如何通过Expanded控件使用容器内部的剩余空间,
|
||||
|
||||
最后,我们学习了层叠布局Stack,以及与之搭配使用的,定位子Widget位置的Positioned容器,你可以通过它们,实现多个控件堆放的布局效果。
|
||||
|
||||
通过今天的文章,相信你已经对如何搭建App的界面有了足够的知识储备,所以在下一篇文章中,我会通过一些实际的例子,带你认识在Flutter中,如何通过这些基本控件与布局规则,实现好看的界面。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留下一道思考题吧。
|
||||
|
||||
Row与Column自身的大小是如何决定的?当它们嵌套时,又会出现怎样的情况呢?
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
262
极客时间专栏/Flutter核心技术与实战/Flutter基础/15 | 组合与自绘,我该选用何种方式自定义Widget?.md
Normal file
262
极客时间专栏/Flutter核心技术与实战/Flutter基础/15 | 组合与自绘,我该选用何种方式自定义Widget?.md
Normal file
@@ -0,0 +1,262 @@
|
||||
<audio id="audio" title="15 | 组合与自绘,我该选用何种方式自定义Widget?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/86/f6/86409969f6b6747da9bb2cd6730aabf6.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
在上一次分享中,我们认识了Flutter中最常用也最经典的布局Widget,即单子容器Container、多子容器Row/Column,以及层叠容器Stack与Positioned,也学习了这些不同容器之间的摆放子Widget的布局规则,我们可以通过它们,来实现子控件的对齐、嵌套、层叠等,它们也是构建一个界面精美的App所必须的布局概念。
|
||||
|
||||
在实际开发中,我们会经常遇到一些复杂的UI需求,往往无法通过使用Flutter的基本Widget,通过设置其属性参数来满足。这个时候,我们就需要针对特定的场景自定义Widget了。
|
||||
|
||||
在Flutter中,自定义Widget与其他平台类似:可以使用基本Widget组装成一个高级别的Widget,也可以自己在画板上根据特殊需求来画界面。
|
||||
|
||||
接下来,我会分别与你介绍组合和自绘这两种自定义Widget的方式。
|
||||
|
||||
## 组装
|
||||
|
||||
使用组合的方式自定义Widget,即通过我们之前介绍的布局方式,摆放项目所需要的基础Widget,并在控件内部设置这些基础Widget的样式,从而组合成一个更高级的控件。
|
||||
|
||||
这种方式,对外暴露的接口比较少,减少了上层使用成本,但也因此增强了控件的复用性。在Flutter中,**组合的思想始终贯穿在框架设计之中**,这也是Flutter提供了如此丰富的控件库的原因之一。
|
||||
|
||||
比如,在新闻类应用中,我们经常需要将新闻Icon、标题、简介与日期组合成一个单独的控件,作为一个整体去响应用户的点击事件。面对这类需求,我们可以把现有的Image、Text及各类布局,组合成一个更高级的新闻Item控件,对外暴露设置model和点击回调的属性即可。
|
||||
|
||||
接下来,我通过一个例子为你说明如何通过组装去自定义控件。
|
||||
|
||||
下图是App Store的升级项UI示意图,图里的每一项,都有应用Icon、名称、更新日期、更新简介、应用版本、应用大小以及更新/打开按钮。可以看到,这里面的UI元素还是相对较多的,现在我们希望将升级项UI封装成一个单独的控件,节省使用成本,以及后续的维护成本。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/01/cc/0157ffe54a9cd933795af6c8d7141ecc.png" alt="">
|
||||
|
||||
在分析这个升级项UI的整体结构之前,我们先定义一个数据结构UpdateItemModel来存储升级信息。在这里为了方便讨论,我把所有的属性都定义为了字符串类型,你在实际使用中可以根据需要将属性定义得更规范(比如,将appDate定义为DateTime类型)。
|
||||
|
||||
```
|
||||
class UpdateItemModel {
|
||||
String appIcon;//App图标
|
||||
String appName;//App名称
|
||||
String appSize;//App大小
|
||||
String appDate;//App更新日期
|
||||
String appDescription;//App更新文案
|
||||
String appVersion;//App版本
|
||||
//构造函数语法糖,为属性赋值
|
||||
UpdateItemModel({this.appIcon, this.appName, this.appSize, this.appDate, this.appDescription, this.appVersion});
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接下来,我以Google Map为例,和你一起分析下这个升级项UI的整体结构。
|
||||
|
||||
按照子Widget的摆放方向,布局方式只有水平和垂直两种,因此我们也按照这两个维度对UI结构进行拆解。
|
||||
|
||||
按垂直方向,我们用绿色的框把这个UI拆解为上半部分与下半部分,如图2所示。下半部分比较简单,是两个文本控件的组合;上半部分稍微复杂一点,我们先将其包装为一个水平布局的Row控件。
|
||||
|
||||
接下来,我们再一起看看水平方向应该如何布局。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/dd/21/dd6241906557f49e184a5dc16d33e521.png" alt="">
|
||||
|
||||
我们先把升级项的上半部分拆解成对应的UI元素:
|
||||
|
||||
- 左边的应用图标拆解为Image;
|
||||
- 右边的按钮拆解为FlatButton;
|
||||
- 中间部分是两个文本在垂直方向上的组合,因此拆解为Column,Column内部则是两个Text。
|
||||
|
||||
拆解示意图,如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/29/0d/29c1762d9c6271049c9149b5ab06bb0d.png" alt="">
|
||||
|
||||
通过与拆解前的UI对比,你就会发现还有3个问题待解决:即控件间的边距如何设置、中间部分的伸缩(截断)规则又是怎样、图片圆角怎么实现。接下来,我们分别来看看。
|
||||
|
||||
Image、FlatButton,以及Column这三个控件,与父容器Row之间存在一定的间距,因此我们还需要在最左边的Image与最右边的FlatButton上包装一层Padding,用以留白填充。
|
||||
|
||||
另一方面,考虑到需要适配不同尺寸的屏幕,中间部分的两个文本应该是变长可伸缩的,但也不能无限制地伸缩,太长了还是需要截断的,否则就会挤压到右边按钮的固定空间了。
|
||||
|
||||
因此,我们需要在Column的外层用Expanded控件再包装一层,让Image与FlatButton之间的空间全留给Column。不过,通常情况下这两个文本并不能完全填满中间的空间,因此我们还需要设置对齐格式,按照垂直方向上居中,水平方向上居左的方式排列。
|
||||
|
||||
最后一项需要注意的是,升级项UI的App Icon是圆角的,但普通的Image并不支持圆角。这时,我们可以使用ClipRRect控件来解决这个问题。ClipRRect可以将其子Widget按照圆角矩形的规则进行裁剪,所以用ClipRRect将Image包装起来,就可以实现图片圆角的功能了。
|
||||
|
||||
下面的代码,就是控件上半部分的关键代码:
|
||||
|
||||
```
|
||||
Widget buildTopRow(BuildContext context) {
|
||||
return Row(//Row控件,用来水平摆放子Widget
|
||||
children: <Widget>[
|
||||
Padding(//Paddng控件,用来设置Image控件边距
|
||||
padding: EdgeInsets.all(10),//上下左右边距均为10
|
||||
child: ClipRRect(//圆角矩形裁剪控件
|
||||
borderRadius: BorderRadius.circular(8.0),//圆角半径为8
|
||||
child: Image.asset(model.appIcon, width: 80,height:80)图片控件//
|
||||
)
|
||||
),
|
||||
Expanded(//Expanded控件,用来拉伸中间区域
|
||||
child: Column(//Column控件,用来垂直摆放子Widget
|
||||
mainAxisAlignment: MainAxisAlignment.center,//垂直方向居中对齐
|
||||
crossAxisAlignment: CrossAxisAlignment.start,//水平方向居左对齐
|
||||
children: <Widget>[
|
||||
Text(model.appName,maxLines: 1),//App名字
|
||||
Text(model.appDate,maxLines: 1),//App更新日期
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(//Paddng控件,用来设置Widget间边距
|
||||
padding: EdgeInsets.fromLTRB(0,0,10,0),//右边距为10,其余均为0
|
||||
child: FlatButton(//按钮控件
|
||||
child: Text("OPEN"),
|
||||
onPressed: onPressed,//点击回调
|
||||
)
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
升级项UI的下半部分比较简单,是两个文本控件的组合。与上半部分的拆解类似,我们用一个Column控件将它俩装起来,如图4所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7d/3d/7da3ec3d2068550fc20481ae3457173d.png" alt="">
|
||||
|
||||
与上半部分类似,这两个文本与父容器之间存在些间距,因此在Column的最外层还需要用Padding控件给包装起来,设置父容器间距。
|
||||
|
||||
另一方面,Column的两个文本控件间也存在间距,因此我们仍然使用Padding控件将下面的文本包装起来,单独设置这两个文本之间的间距。
|
||||
|
||||
同样地,通常情况下这两个文本并不能完全填满下部空间,因此我们还需要设置对齐格式,即按照水平方向上居左的方式对齐。
|
||||
|
||||
控件下半部分的关键代码如下所示:
|
||||
|
||||
```
|
||||
Widget buildBottomRow(BuildContext context) {
|
||||
return Padding(//Padding控件用来设置整体边距
|
||||
padding: EdgeInsets.fromLTRB(15,0,15,0),//左边距和右边距为15
|
||||
child: Column(//Column控件用来垂直摆放子Widget
|
||||
crossAxisAlignment: CrossAxisAlignment.start,//水平方向距左对齐
|
||||
children: <Widget>[
|
||||
Text(model.appDescription),//更新文案
|
||||
Padding(//Padding控件用来设置边距
|
||||
padding: EdgeInsets.fromLTRB(0,10,0,0),//上边距为10
|
||||
child: Text("${model.appVersion} • ${model.appSize} MB")
|
||||
)
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最后,我们将上下两部分控件通过Column包装起来,这次升级项UI定制就完成了:
|
||||
|
||||
```
|
||||
class UpdatedItem extends StatelessWidget {
|
||||
final UpdatedItemModel model;//数据模型
|
||||
//构造函数语法糖,用来给model赋值
|
||||
UpdatedItem({Key key,this.model, this.onPressed}) : super(key: key);
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(//用Column将上下两部分合体
|
||||
children: <Widget>[
|
||||
buildTopRow(context),//上半部分
|
||||
buildBottomRow(context)//下半部分
|
||||
]);
|
||||
}
|
||||
Widget buildBottomRow(BuildContext context) {...}
|
||||
Widget buildTopRow(BuildContext context) {...}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
试着运行一下,效果如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/87/66/8737980f8b42caf33b77197a7a165f66.png" alt="">
|
||||
|
||||
搞定!
|
||||
|
||||
**按照从上到下、从左到右去拆解UI的布局结构,把复杂的UI分解成各个小UI元素,在以组装的方式去自定义UI中非常有用,请一定记住这样的拆解方法。**
|
||||
|
||||
## 自绘
|
||||
|
||||
Flutter提供了非常丰富的控件和布局方式,使得我们可以通过组合去构建一个新的视图。但对于一些不规则的视图,用SDK提供的现有Widget组合可能无法实现,比如饼图,k线图等,这个时候我们就需要自己用画笔去绘制了。
|
||||
|
||||
在原生iOS和Android开发中,我们可以继承UIView/View,在drawRect/onDraw方法里进行绘制操作。其实,在Flutter中也有类似的方案,那就是CustomPaint。
|
||||
|
||||
**CustomPaint是用以承接自绘控件的容器,并不负责真正的绘制**。既然是绘制,那就需要用到画布与画笔。
|
||||
|
||||
在Flutter中,画布是Canvas,画笔则是Paint,而画成什么样子,则由定义了绘制逻辑的CustomPainter来控制。将CustomPainter设置给容器CustomPaint的painter属性,我们就完成了一个自绘控件的封装。
|
||||
|
||||
对于画笔Paint,我们可以配置它的各种属性,比如颜色、样式、粗细等;而画布Canvas,则提供了各种常见的绘制方法,比如画线drawLine、画矩形drawRect、画点DrawPoint、画路径drawPath、画圆drawCircle、画圆弧drawArc等。
|
||||
|
||||
这样,我们就可以在CustomPainter的paint方法里,通过Canvas与Paint的配合,实现定制化的绘制逻辑。
|
||||
|
||||
接下来,我们看一个例子。
|
||||
|
||||
在下面的代码中,我们继承了CustomPainter,在定义了绘制逻辑的paint方法中,通过Canvas的drawArc方法,用6种不同颜色的画笔依次画了6个1/6圆弧,拼成了一张饼图。最后,我们使用CustomPaint容器,将painter进行封装,就完成了饼图控件Cake的定义。
|
||||
|
||||
```
|
||||
class WheelPainter extends CustomPainter {
|
||||
// 设置画笔颜色
|
||||
Paint getColoredPaint(Color color) {//根据颜色返回不同的画笔
|
||||
Paint paint = Paint();//生成画笔
|
||||
paint.color = color;//设置画笔颜色
|
||||
return paint;
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {//绘制逻辑
|
||||
double wheelSize = min(size.width,size.height)/2;//饼图的尺寸
|
||||
double nbElem = 6;//分成6份
|
||||
double radius = (2 * pi) / nbElem;//1/6圆
|
||||
//包裹饼图这个圆形的矩形框
|
||||
Rect boundingRect = Rect.fromCircle(center: Offset(wheelSize, wheelSize), radius: wheelSize);
|
||||
// 每次画1/6个圆弧
|
||||
canvas.drawArc(boundingRect, 0, radius, true, getColoredPaint(Colors.orange));
|
||||
canvas.drawArc(boundingRect, radius, radius, true, getColoredPaint(Colors.black38));
|
||||
canvas.drawArc(boundingRect, radius * 2, radius, true, getColoredPaint(Colors.green));
|
||||
canvas.drawArc(boundingRect, radius * 3, radius, true, getColoredPaint(Colors.red));
|
||||
canvas.drawArc(boundingRect, radius * 4, radius, true, getColoredPaint(Colors.blue));
|
||||
canvas.drawArc(boundingRect, radius * 5, radius, true, getColoredPaint(Colors.pink));
|
||||
}
|
||||
// 判断是否需要重绘,这里我们简单的做下比较即可
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) => oldDelegate != this;
|
||||
}
|
||||
//将饼图包装成一个新的控件
|
||||
class Cake extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CustomPaint(
|
||||
size: Size(200, 200),
|
||||
painter: WheelPainter(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
试着运行一下,效果如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fb/84/fb03c4222e150a29a41d53a773b94984.png" alt="">
|
||||
|
||||
可以看到,使用CustomPainter进行自绘控件并不算复杂。这里,我建议你试着用画笔和画布,去实现更丰富的功能。
|
||||
|
||||
**在实现视觉需求上,自绘需要自己亲自处理绘制逻辑,而组合则是通过子Widget的拼接来实现绘制意图。**因此从渲染逻辑处理上,自绘方案可以进行深度的渲染定制,从而实现少数通过组合很难实现的需求(比如饼图、k线图)。不过,当视觉效果需要调整时,采用自绘的方案可能需要大量修改绘制代码,而组合方案则相对简单:只要布局拆分设计合理,可以通过更换子Widget类型来轻松搞定。
|
||||
|
||||
## 总结
|
||||
|
||||
在面对一些复杂的UI视图时,Flutter提供的单一功能类控件往往不能直接满足我们的需求。于是,我们需要自定义Widget。Flutter提供了组装与自绘两种自定义Widget的方式,来满足我们对视图的自定义需求。
|
||||
|
||||
以组装的方式构建UI,我们需要将目标视图分解成各个UI小元素。通常,我们可以按照从上到下、从左到右的布局顺序去对控件层次结构进行拆解,将基本视觉元素封装到Column、Row中。对于有着固定间距的视觉元素,我们可以通过Padding对其进行包装,而对于大小伸缩可变的视觉元素,我们可以通过Expanded控件让其填充父容器的空白区域。
|
||||
|
||||
而以自绘的方式定义控件,则需要借助于CustomPaint容器,以及最终承接真实绘制逻辑的CustomPainter。CustomPainter是绘制逻辑的封装,在其paint方法中,我们可以使用不同类型的画笔Paint,利用画布Canvas提供的不同类型的绘制图形能力,实现控件自定义绘制。
|
||||
|
||||
无论是组合还是自绘,在自定义UI时,有了目标视图整体印象后,我们首先需要考虑的事情应该是如何将它化繁为简,把视觉元素拆解细分,变成自己立即可以着手去实现的一个小控件,然后再思考如何将这些小控件串联起来。把大问题拆成小问题后,实现目标也逐渐清晰,落地方案就自然浮出水面了。
|
||||
|
||||
这其实就和我们学习新知识的过程是一样的,在对整体知识概念有了初步认知之后,也需要具备将复杂的知识化繁为简的能力:先理清楚其逻辑脉络,然后再把不懂的知识拆成小点,最后逐个攻破。
|
||||
|
||||
我把今天分享讲的两个例子放到了[GitHub](https://github.com/cyndibaby905/15_custom_ui_demo)上,你可以下载后在工程中实际运行,并对照着今天的知识点进行学习,体会在不同场景下,组合和自绘这两种自定义Widget的具体使用方法。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留下两道作业题吧。
|
||||
|
||||
- 请扩展UpdatedItem控件,使其能自动折叠过长的更新文案,并能支持点击后展开的功能。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bf/bf/bf6c18f1f391a7f9999e21fdcaeff9bf.png" alt="">
|
||||
|
||||
- 请扩展Cake控件,使其能够根据传入的double数组(最多10个元素)中数值的大小,定义饼图的圆弧大小。
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
188
极客时间专栏/Flutter核心技术与实战/Flutter基础/16 | 从夜间模式说起,如何定制不同风格的App主题?.md
Normal file
188
极客时间专栏/Flutter核心技术与实战/Flutter基础/16 | 从夜间模式说起,如何定制不同风格的App主题?.md
Normal file
@@ -0,0 +1,188 @@
|
||||
<audio id="audio" title="16 | 从夜间模式说起,如何定制不同风格的App主题?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c1/49/c15090c701ab71e2313709e4e560a549.mp3"></audio>
|
||||
|
||||
你好,我是陈航。今天,我和你分享的主题是,从夜间模式说起,如何定制不同风格的App主题。
|
||||
|
||||
在上一篇文章中,我与你介绍了组装与自绘这两种自定义Widget的方式。对于组装,我们按照从上到下、从左到右的布局顺序去分解目标视图,将基本的Widget封装到Column、Row中,从而合成更高级别的Widget;而对于自绘,我们则通过承载绘制逻辑的载体CustomPainter,在其paint方法中使用画笔Paint与画布Canvas,绘制不同风格、不同类型的图形,从而实现基于自绘的自定义组件。
|
||||
|
||||
对于一个产品来说,在业务早期其实更多的是处理基本功能有和无的问题:工程师来负责实现功能,PM负责功能好用不好用。在产品的基本功能已经完善,做到了六七十分的时候,再往上的如何做增长就需要运营来介入了。
|
||||
|
||||
在这其中,如何通过用户分层去实现App的个性化是常见的增长运营手段,而主题样式更换则是实现个性化中的一项重要技术手段。
|
||||
|
||||
比如,微博、UC浏览器和电子书客户端都提供了对夜间模式的支持,而淘宝、京东这样的电商类应用,还会在特定的电商活动日自动更新主题样式,就连现在的手机操作系统也提供了系统级切换展示样式的能力。
|
||||
|
||||
那么,这些在应用内切换样式的功能是如何实现的呢?在Flutter中,在普通的应用上增加切换主题的功能又要做哪些事情呢?这些问题,我都会在今天的这篇文章中与你详细分享。
|
||||
|
||||
## 主题定制
|
||||
|
||||
主题,又叫皮肤、配色,一般由颜色、图片、字号、字体等组成,我们可以把它看做是视觉效果在不同场景下的可视资源,以及相应的配置集合。比如,App的按钮,无论在什么场景下都需要背景图片资源、字体颜色、字号大小等,而所谓的主题切换只是在不同主题之间更新这些资源及配置集合而已。
|
||||
|
||||
因此在App开发中,我们通常不关心资源和配置的视觉效果好不好看,只要关心资源提供的视觉功能能不能用。比如,对于图片类资源,我们并不需要关心它渲染出来的实际效果,只需要确定它渲染出来是一张固定宽高尺寸的区域,不影响页面布局,能把业务流程跑通即可。
|
||||
|
||||
**视觉效果是易变的,我们将这些变化的部分抽离出来,把提供不同视觉效果的资源和配置按照主题进行归类,整合到一个统一的中间层去管理,这样我们就能实现主题的管理和切换了。**
|
||||
|
||||
在iOS中,我们通常会将主题的配置信息预先写到plist文件中,通过一个单例来控制App应该使用哪种配置;而Android的配置信息则写入各个style属性值的xml中,通过activity的setTheme进行切换;前端的处理方式也类似,简单更换css就可以实现多套主题/配色之间的切换。
|
||||
|
||||
Flutter也提供了类似的能力,**由ThemeData来统一管理主题的配置信息**。
|
||||
|
||||
ThemeData涵盖了Material Design规范的可自定义部分样式,比如应用明暗模式brightness、应用主色调primaryColor、应用次级色调accentColor、文本字体fontFamily、输入框光标颜色cursorColor等。如果你想深入了解ThemeData的其他API参数,可以参考官方文档[ThemeData](https://api.flutter.dev/flutter/material/ThemeData/ThemeData.html)。
|
||||
|
||||
通过ThemeData来自定义应用主题,我们可以实现App全局范围,或是Widget局部范围的样式切换。接下来,我便分别与你讲述这两种范围的主题切换。
|
||||
|
||||
## 全局统一的视觉风格定制
|
||||
|
||||
在Flutter中,应用程序类MaterialApp的初始化方法,为我们提供了设置主题的能力。我们可以通过参数theme,选择改变App的主题色、字体等,设置界面在MaterialApp下的展示样式。
|
||||
|
||||
以下代码演示了如何设置App全局范围主题。在这段代码中,我们设置了App的明暗模式brightness为暗色、主色调为青色:
|
||||
|
||||
```
|
||||
MaterialApp(
|
||||
title: 'Flutter Demo',//标题
|
||||
theme: ThemeData(//设置主题
|
||||
brightness: Brightness.dark,//明暗模式为暗色
|
||||
primaryColor: Colors.cyan,//主色调为青色
|
||||
),
|
||||
home: MyHomePage(title: 'Flutter Demo Home Page'),
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
试着运行一下,效果如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9b/30/9b16f0a71c01b336399554ddf7591f30.png" alt="">
|
||||
|
||||
可以看到,虽然我们只修改了主色调和明暗模式两个参数,但按钮、文字颜色都随之调整了。这是因为默认情况下,**ThemeData中很多其他次级视觉属性,都会受到主色调与明暗模式的影响**。如果我们想要精确控制它们的展示样式,需要再细化一下主题配置。
|
||||
|
||||
下面的例子中,我们将icon的颜色调整为黄色,文字颜色调整为红色,按钮颜色调整为黑色:
|
||||
|
||||
```
|
||||
MaterialApp(
|
||||
title: 'Flutter Demo',//标题
|
||||
theme: ThemeData(//设置主题
|
||||
brightness: Brightness.dark,//设置明暗模式为暗色
|
||||
accentColor: Colors.black,//(按钮)Widget前景色为黑色
|
||||
primaryColor: Colors.cyan,//主色调为青色
|
||||
iconTheme:IconThemeData(color: Colors.yellow),//设置icon主题色为黄色
|
||||
textTheme: TextTheme(body1: TextStyle(color: Colors.red))//设置文本颜色为红色
|
||||
),
|
||||
home: MyHomePage(title: 'Flutter Demo Home Page'),
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
运行一下,可以看到图标、文字、按钮的颜色都随之更改了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2c/94/2c033e21d8c0d29735b1860378c35794.png" alt="">
|
||||
|
||||
## 局部独立的视觉风格定制
|
||||
|
||||
为整个App提供统一的视觉呈现效果固然很有必要,但有时我们希望为某个页面、或是某个区块设置不同于App风格的展现样式。以主题切换功能为例,我们希望为不同的主题提供不同的展示预览。
|
||||
|
||||
在Flutter中,我们可以使用Theme来对App的主题进行局部覆盖。Theme是一个单子Widget容器,与MaterialApp类似的,我们可以通过设置其data属性,对其子Widget进行样式定制:
|
||||
|
||||
- 如果我们不想继承任何App全局的颜色或字体样式,可以直接新建一个ThemeData实例,依次设置对应的样式;
|
||||
- 而如果我们不想在局部重写所有的样式,则可以继承App的主题,使用copyWith方法,只更新部分样式。
|
||||
|
||||
下面的代码演示了这两种方式的用法:
|
||||
|
||||
```
|
||||
// 新建主题
|
||||
Theme(
|
||||
data: ThemeData(iconTheme: IconThemeData(color: Colors.red)),
|
||||
child: Icon(Icons.favorite)
|
||||
);
|
||||
|
||||
// 继承主题
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(iconTheme: IconThemeData(color: Colors.green)),
|
||||
child: Icon(Icons.feedback)
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/31/1f/31523a1f0bd4f6150b3d3c59102c831f.png" alt="">
|
||||
|
||||
对于上述例子而言,由于Theme的子Widget只有一个Icon组件,因此这两种方式都可以实现覆盖全局主题,从而更改Icon样式的需求。而像这样使用局部主题覆盖全局主题的方式,在Flutter中是一种常见的自定义子Widget展示样式的方法。
|
||||
|
||||
**除了定义Material Design规范中那些可自定义部分样式外,主题的另一个重要用途是样式复用。**
|
||||
|
||||
比如,如果我们想为一段文字复用Materia Design规范中的title样式,或是为某个子Widget的背景色复用App的主题色,我们就可以通过Theme.of(context)方法,取出对应的属性,应用到这段文字的样式中。
|
||||
|
||||
Theme.of(context)方法将向上查找Widget树,并返回Widget树中最近的主题Theme。如果Widget的父Widget们有一个单独的主题定义,则使用该主题。如果不是,那就使用App全局主题。
|
||||
|
||||
在下面的例子中,我们创建了一个包装了一个Text组件的Container容器。在Text组件的样式定义中,我们复用了全局的title样式,而在Container的背景色定义中,则复用了App的主题色:
|
||||
|
||||
```
|
||||
Container(
|
||||
color: Theme.of(context).primaryColor,//容器背景色复用应用主题色
|
||||
child: Text(
|
||||
'Text with a background color',
|
||||
style: Theme.of(context).textTheme.title,//Text组件文本样式复用应用文本样式
|
||||
));
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ad/90/adeef600fa271f6ebb4eb41f60620290.png" alt="">
|
||||
|
||||
## 分平台主题定制
|
||||
|
||||
有时候,**为了满足不同平台的用户需求,我们希望针对特定的平台设置不同的样式**。比如,在iOS平台上设置浅色主题,在Android平台上设置深色主题。面对这样的需求,我们可以根据defaultTargetPlatform来判断当前应用所运行的平台,从而根据系统类型来设置对应的主题。
|
||||
|
||||
在下面的例子中,我们为iOS与Android分别创建了两个主题。在MaterialApp的初始化方法中,我们根据平台类型,设置了不同的主题:
|
||||
|
||||
```
|
||||
// iOS浅色主题
|
||||
final ThemeData kIOSTheme = ThemeData(
|
||||
brightness: Brightness.light,//亮色主题
|
||||
accentColor: Colors.white,//(按钮)Widget前景色为白色
|
||||
primaryColor: Colors.blue,//主题色为蓝色
|
||||
iconTheme:IconThemeData(color: Colors.grey),//icon主题为灰色
|
||||
textTheme: TextTheme(body1: TextStyle(color: Colors.black))//文本主题为黑色
|
||||
);
|
||||
// Android深色主题
|
||||
final ThemeData kAndroidTheme = ThemeData(
|
||||
brightness: Brightness.dark,//深色主题
|
||||
accentColor: Colors.black,//(按钮)Widget前景色为黑色
|
||||
primaryColor: Colors.cyan,//主题色Wie青色
|
||||
iconTheme:IconThemeData(color: Colors.blue),//icon主题色为蓝色
|
||||
textTheme: TextTheme(body1: TextStyle(color: Colors.red))//文本主题色为红色
|
||||
);
|
||||
// 应用初始化
|
||||
MaterialApp(
|
||||
title: 'Flutter Demo',
|
||||
theme: defaultTargetPlatform == TargetPlatform.iOS ? kIOSTheme : kAndroidTheme,//根据平台选择不同主题
|
||||
home: MyHomePage(title: 'Flutter Demo Home Page'),
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
试着运行一下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ef/c6/efdee5c8d3e46d3b889274bbe3cf80c6.png" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c6/70/c61983ef7ab9047562c338ecd1b46970.png" alt="">
|
||||
|
||||
当然,除了主题之外,你也可以用defaultTargetPlatform这个变量去实现一些其他需要判断平台的逻辑,比如在界面上使用更符合Android或iOS设计风格的组件。
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的分享就到这里。我们简单回顾一下今天的主要内容吧。
|
||||
|
||||
主题设置属于App开发的高级特性,归根结底其实是提供了一种视觉资源与视觉配置的管理机制。与其他平台类似,Flutter也提供了集中式管理主题的机制,可以在遵循Material Design规范的ThemeData中,定义那些可定制化的样式。
|
||||
|
||||
我们既可以通过设置MaterialApp全局主题实现应用整体视觉风格的统一,也可以通过Theme单子Widget容器使用局部主题覆盖全局主题,实现局部独立的视觉风格。
|
||||
|
||||
除此之外,在自定义组件过程中,我们还可以使用Theme.of方法取出主题对应的属性值,从而实现多种组件在视觉风格上的复用。
|
||||
|
||||
最后,面对常见的分平台设置主题场景,我们可以根据defaultTargetPlatform,来精确识别当前应用所处的系统,从而配置对应的主题。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留下一个课后小作业吧。
|
||||
|
||||
在上一篇文章中,我与你介绍了如何实现App Store升级项UI自定义组件布局。现在,请在这个自定义Widget的基础上,增加切换夜间模式的功能。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/87/54/87fe49b5f8ba32823619040845c19d54.png" alt="">
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
<audio id="audio" title="17 | 依赖管理(一):图片、配置和字体在Flutter中怎么用?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/16/b2/16bd4d37485415b29979f4571760e5b2.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
在上一篇文章中,我与你介绍了Flutter的主题设置,也就是将视觉资源与视觉配置进行集中管理的机制。
|
||||
|
||||
Flutter提供了遵循Material Design规范的ThemeData,可以对样式进行定制化:既可以初始化App时实现全局整体视觉风格统一,也可以在使用单子Widget容器Theme实现局部主题的覆盖,还可以在自定义组件时取出主题对应的属性值,实现视觉风格的复用。
|
||||
|
||||
一个应用程序主要由两部分内容组成:代码和资源。代码关注逻辑功能,而如图片、字符串、字体、配置文件等资源则关注视觉功能。如果说上一次文章更多的是从逻辑层面分享应该如何管理资源的配置,那今天的分享则会从物理存储入手与你介绍Flutter整体的资源管理机制。
|
||||
|
||||
资源外部化,即把代码与资源分离,是现代UI框架的主流设计理念。因为这样不仅有利于单独维护资源,还可以对特定设备提供更准确的兼容性支持,使得我们的应用程序可以自动根据实际运行环境来组织视觉功能,适应不同的屏幕大小和密度等。
|
||||
|
||||
随着各类配置各异的终端设备越来越多,资源管理也越来越重要。那么今天,我们就先看看Flutter中的图片、配置和字体的管理机制吧。
|
||||
|
||||
## 资源管理
|
||||
|
||||
在移动开发中,常见的资源类型包括JSON文件、配置文件、图标、图片以及字体文件等。它们都会被打包到App安装包中,而App中的代码可以在运行时访问这些资源。
|
||||
|
||||
在Android、iOS平台中,为了区分不同分辨率的手机设备,图片和其他原始资源是区别对待的:
|
||||
|
||||
- iOS使用Images.xcassets来管理图片,其他的资源直接拖进工程项目即可;
|
||||
- Android的资源管理粒度则更为细致,使用以drawable+分辨率命名的文件夹来分别存放不同分辨率的图片,其他类型的资源也都有各自的存放方式,比如布局文件放在res/layout目录下,资源描述文件放在res/values目录下,原始文件放在assets目录下等。
|
||||
|
||||
而在Flutter中,资源管理则简单得多:资源(assets)可以是任意类型的文件,比如JSON配置文件或是字体文件等,而不仅仅是图片。
|
||||
|
||||
而关于资源的存放位置,Flutter并没有像Android那样预先定义资源的目录结构,所以我们可以把资源存放在项目中的任意目录下,只需要使用根目录下的pubspec.yaml文件,对这些资源的所在位置进行显式声明就可以了,以帮助Flutter识别出这些资源。
|
||||
|
||||
而在指定路径名的过程中,我们既可以对每一个文件进行挨个指定,也可以采用子目录批量指定的方式。
|
||||
|
||||
接下来,**我以一个示例和你说明挨个指定和批量指定这两种方式的区别。**
|
||||
|
||||
如下所示,我们将资源放入assets目录下,其中,两张图片background.jpg、loading.gif与JSON文件result.json在assets根目录,而另一张图片food_icon.jpg则在assets的子目录icons下。
|
||||
|
||||
```
|
||||
assets
|
||||
├── background.jpg
|
||||
├── icons
|
||||
│ └── food_icon.jpg
|
||||
├── loading.gif
|
||||
└── result.json
|
||||
|
||||
```
|
||||
|
||||
对于上述资源文件存放的目录结构,以下代码分别演示了挨个指定和子目录批量指定这两种方式:通过单个文件声明的,我们需要完整展开资源的相对路径;而对于目录批量指定的方式,只需要在目录名后加路径分隔符就可以了:
|
||||
|
||||
```
|
||||
flutter:
|
||||
assets:
|
||||
- assets/background.jpg #挨个指定资源路径
|
||||
- assets/loading.gif #挨个指定资源路径
|
||||
- assets/result.json #挨个指定资源路径
|
||||
- assets/icons/ #子目录批量指定
|
||||
- assets/ #根目录也是可以批量指定的
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,**目录批量指定并不递归,只有在该目录下的文件才可以被包括,如果下面还有子目录的话,需要单独声明子目录下的文件。**
|
||||
|
||||
完成资源的声明后,我们就可以在代码中访问它们了。**在Flutter中,对不同类型的资源文件处理方式略有差异**,接下来我将分别与你介绍。
|
||||
|
||||
对于图片类资源的访问,我们可以使用Image.asset构造方法完成图片资源的加载及显示,在第12篇文章“[经典控件(一):文本、图片和按钮在Flutter中怎么用?](https://time.geekbang.org/column/article/110292)”中,你应该已经了解了具体的用法,这里我就不再赘述了。
|
||||
|
||||
而对于其他资源文件的加载,我们可以通过Flutter应用的主资源Bundle对象rootBundle,来直接访问。
|
||||
|
||||
对于字符串文件资源,我们使用loadString方法;而对于二进制文件资源,则通过load方法。
|
||||
|
||||
以下代码演示了获取result.json文件,并将其打印的过程:
|
||||
|
||||
```
|
||||
rootBundle.loadString('assets/result.json').then((msg)=>print(msg));
|
||||
|
||||
```
|
||||
|
||||
与Android、iOS开发类似,**Flutter也遵循了基于像素密度的管理方式**,如1.0x、2.0x、3.0x或其他任意倍数,Flutter可以根据当前设备分辨率加载最接近设备像素比例的图片资源。而为了让Flutter更好地识别,我们的资源目录应该将1.0x、2.0x与3.0x的图片资源分开管理。
|
||||
|
||||
以background.jpg图片为例,这张图片位于assets目录下。如果想让Flutter适配不同的分辨率,我们需要将其他分辨率的图片放到对应的分辨率子目录中,如下所示:
|
||||
|
||||
```
|
||||
assets
|
||||
├── background.jpg //1.0x图
|
||||
├── 2.0x
|
||||
│ └── background.jpg //2.0x图
|
||||
└── 3.0x
|
||||
└── background.jpg //3.0x图
|
||||
|
||||
```
|
||||
|
||||
而在pubspec.yaml文件声明这个图片资源时,仅声明1.0x图资源即可:
|
||||
|
||||
```
|
||||
flutter:
|
||||
assets:
|
||||
- assets/background.jpg #1.0x图资源
|
||||
|
||||
```
|
||||
|
||||
1.0x分辨率的图片是资源标识符,而Flutter则会根据实际屏幕像素比例加载相应分辨率的图片。这时,如果主资源缺少某个分辨率资源,Flutter会在剩余的分辨率资源中选择最接近的分辨率资源去加载。
|
||||
|
||||
举个例子,如果我们的App包只包括了2.0x资源,对于屏幕像素比为3.0的设备,则会自动降级读取2.0x的资源。不过需要注意的是,即使我们的App包没有包含1.0x资源,我们仍然需要像上面那样在pubspec.yaml中将它显示地声明出来,因为它是资源的标识符。
|
||||
|
||||
**字体则是另外一类较为常用的资源**。手机操作系统一般只有默认的几种字体,在大部分情况下可以满足我们的正常需求。但是,在一些特殊的情况下,我们可能需要使用自定义字体来提升视觉体验。
|
||||
|
||||
在Flutter中,使用自定义字体同样需要在pubspec.yaml文件中提前声明。需要注意的是,字体实际上是字符图形的映射。所以,除了正常字体文件外,如果你的应用需要支持粗体和斜体,同样也需要有对应的粗体和斜体字体文件。
|
||||
|
||||
在将RobotoCondensed字体摆放至assets目录下的fonts子目录后,下面的代码演示了如何将支持斜体与粗体的RobotoCondensed字体加到我们的应用中:
|
||||
|
||||
```
|
||||
fonts:
|
||||
- family: RobotoCondensed #字体名字
|
||||
fonts:
|
||||
- asset: assets/fonts/RobotoCondensed-Regular.ttf #普通字体
|
||||
- asset: assets/fonts/RobotoCondensed-Italic.ttf
|
||||
style: italic #斜体
|
||||
- asset: assets/fonts/RobotoCondensed-Bold.ttf
|
||||
weight: 700 #粗体
|
||||
|
||||
```
|
||||
|
||||
这些声明其实都对应着TextStyle中的样式属性,如字体名family对应着 fontFamily属性、斜体italic与正常normal对应着style属性、字体粗细weight对应着fontWeight属性等。在使用时,我们只需要在TextStyle中指定对应的字体即可:
|
||||
|
||||
```
|
||||
Text("This is RobotoCondensed", style: TextStyle(
|
||||
fontFamily: 'RobotoCondensed',//普通字体
|
||||
));
|
||||
Text("This is RobotoCondensed", style: TextStyle(
|
||||
fontFamily: 'RobotoCondensed',
|
||||
fontWeight: FontWeight.w700, //粗体
|
||||
));
|
||||
Text("This is RobotoCondensed italic", style: TextStyle(
|
||||
fontFamily: 'RobotoCondensed',
|
||||
fontStyle: FontStyle.italic, //斜体
|
||||
));
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8a/59/8a8a853b0718dffde0fa409746368259.png" alt="">
|
||||
|
||||
## 原生平台的资源设置
|
||||
|
||||
在前面的第5篇文章“[从标准模板入手,体会Flutter代码是如何运行在原生系统上的](https://time.geekbang.org/column/article/106199)”中,我与你介绍了Flutter应用,实际上最终会以原生工程的方式打包运行在Android和iOS平台上,因此Flutter启动时依赖的是原生Android和iOS的运行环境。
|
||||
|
||||
上面介绍的资源管理机制其实都是在Flutter应用内的,而在Flutter框架运行之前,我们是没有办法访问这些资源的。Flutter需要原生环境才能运行,但是有些资源我们需要在Flutter框架运行之前提前使用,比如要给应用添加图标,或是希望在等待Flutter框架启动时添加启动图,我们就需要在对应的原生工程中完成相应的配置,所以**下面介绍的操作步骤都是在原生系统中完成的。**
|
||||
|
||||
我们先看一下**如何更换App启动图标**。
|
||||
|
||||
对于Android平台,启动图标位于根目录android/app/src/main/res/mipmap下。我们只需要遵守对应的像素密度标准,保留原始图标名称,将图标更换为目标资源即可:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9d/99/9d8d84ec282488f9c3d184646bec6599.png" alt="">
|
||||
|
||||
对于iOS平台,启动图位于根目录ios/Runner/Assets.xcassets/AppIcon.appiconset下。同样地,我们只需要遵守对应的像素密度标准,将其替换为目标资源并保留原始图标名称即可:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b1/36/b1c2f7d4181b58a778fade3dfd1c7336.png" alt="">
|
||||
|
||||
然后。我们来看一下**如何更换启动图**。
|
||||
|
||||
对于Android平台,启动图位于根目录android/app/src/main/res/drawable下,是一个名为launch_background的XML界面描述文件。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c4/d3/c40510574d63ddd1e8909722c8fc8fd3.png" alt="">
|
||||
|
||||
我们可以在这个界面描述文件中自定义启动界面,也可以换一张启动图片。在下面的例子中,我们更换了一张居中显示的启动图片:
|
||||
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- 白色背景 -->
|
||||
<item android:drawable="@android:color/white" />
|
||||
<item>
|
||||
<!-- 内嵌一张居中展示的图片 -->
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/bitmap_launcher" />
|
||||
</item>
|
||||
</layer-list>
|
||||
|
||||
```
|
||||
|
||||
而对于iOS平台,启动图位于根目录ios/Runner/Assets.xcassets/LaunchImage.imageset下。我们保留原始启动图名称,将图片依次按照对应像素密度标准,更换为目标启动图即可。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ff/21/ffa2c557a267efad08391236bf5ea921.png" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的分享就到这里。我们简单回顾一下今天的内容。
|
||||
|
||||
将代码与资源分离,不仅有助于单独维护资源,还可以更精确地对特定设备提供兼容性支持。在Flutter中,资源可以是任意类型的文件,可以被放到任意目录下,但需要通过pubspec.yaml文件将它们的路径进行统一地显式声明。
|
||||
|
||||
Flutter对图片提供了基于像素密度的管理方式,我们需要将1.0x,2.0x与3.0x的资源分开管理,但只需要在pubspec.yaml中声明一次。如果应用中缺少对于高像素密度设备的资源支持,Flutter会进行自动降级。
|
||||
|
||||
对于字体这种基于字符图形映射的资源文件,Flutter提供了精细的管理机制,可以支持除了正常字体外,还支持粗体、斜体等样式。
|
||||
|
||||
最后,由于Flutter启动时依赖原生系统运行环境,因此我们还需要去原生工程中,设置相应的App启动图标和启动图。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留下两道思考题吧。
|
||||
|
||||
1. 如果我们只提供了1.0x与2.0x的资源图片,对于像素密度为3.0的设备,Flutter会自动降级到哪套资源?
|
||||
1. 如果我们只提供了2.0x的资源图片,对于像素密度为1.0的设备,Flutter会如何处理呢?
|
||||
|
||||
你可以参考原生平台的经验,在模拟器或真机上实验一下。
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<audio id="audio" title="18 | 依赖管理(二):第三方组件库在Flutter中要如何管理?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/03/ee/030439504f9d02f8d98e331bdd8ac7ee.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
在上一篇文章中,我与你介绍了Flutter工程的资源管理机制。在Flutter中,资源采用先声明后使用的机制,在pubspec.yaml显式地声明资源路径后,才可以使用。
|
||||
|
||||
对于图片,Flutter基于像素密度,设立不同分辨率的目录分开管理,但只需要在pubspec.yaml声明一次;而字体则基于样式支持,除了正常字体,还可以支持粗体、斜体等样式。最后,由于Flutter需要原生运行环境,因此对于在其启动之前所需的启动图和图标这两类特殊资源,我们还需要分别去原生工程中进行相应的设置。
|
||||
|
||||
其实,除了管理这些资源外,pubspec.yaml更为重要的作用是管理Flutter工程代码的依赖,比如第三方库、Dart运行环境、Flutter SDK版本都可以通过它来进行统一管理。所以,pubspec.yaml与iOS中的Podfile、Android中的build.gradle、前端的package.json在功能上是类似的。
|
||||
|
||||
那么,今天这篇文章,我就主要与你分享,在Flutter中如何通过配置文件来管理工程代码依赖。
|
||||
|
||||
## Pub
|
||||
|
||||
Dart提供了包管理工具Pub,用来管理代码和资源。从本质上说,包(package)实际上就是一个包含了pubspec.yaml文件的目录,其内部可以包含代码、资源、脚本、测试和文档等文件。包中包含了需要被外部依赖的功能抽象,也可以依赖其他包。
|
||||
|
||||
与Android中的JCenter/Maven、iOS中的CocoaPods、前端中的npm库类似,Dart提供了官方的包仓库Pub。通过Pub,我们可以很方便地查找到有用的第三方包。
|
||||
|
||||
当然,这并不意味着我们可以简单地拿别人的库来拼凑成一个应用程序。**Dart提供包管理工具Pub的真正目的是,让你能够找到真正好用的、经过线上大量验证的库,复用他人的成果来缩短开发周期,提升软件质量。**
|
||||
|
||||
在Dart中,库和应用都属于包。pubspec.yaml是包的配置文件,包含了包的元数据(比如,包的名称和版本)、运行环境(也就是Dart SDK与Fluter SDK版本)、外部依赖、内部配置(比如,资源管理)。
|
||||
|
||||
在下面的例子中,我们声明了一个flutter_app_example的应用配置文件,其版本为1.0,Dart运行环境支持2.1至3.0之间,依赖flutter和cupertino_icon:
|
||||
|
||||
```
|
||||
name: flutter_app_example #应用名称
|
||||
description: A new Flutter application. #应用描述
|
||||
version: 1.0.0
|
||||
#Dart运行环境区间
|
||||
environment:
|
||||
sdk: ">=2.1.0 <3.0.0"
|
||||
#Flutter依赖库
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
cupertino_icons: ">0.1.1"
|
||||
|
||||
```
|
||||
|
||||
运行环境和依赖库cupertino_icons冒号后面的部分是版本约束信息,由一组空格分隔的版本描述组成,可以支持指定版本、版本号区间,以及任意版本这三种版本约束方式。比如上面的例子中,cupertino_icons引用了大于0.1.1的版本。
|
||||
|
||||
需要注意的是,由于元数据与名称使用空格分隔,因此版本号中不能出现空格;同时又由于大于符号“>”也是YAML语法中的折叠换行符号,因此在指定版本范围的时候,必须使用引号, 比如">=2.1.0 < 3.0.0"。
|
||||
|
||||
**对于包,我们通常是指定版本区间,而很少直接指定特定版本**,因为包升级变化很频繁,如果有其他的包直接或间接依赖这个包的其他版本时,就会经常发生冲突。
|
||||
|
||||
而**对于运行环境,如果是团队多人协作的工程,建议将Dart与Flutter的SDK环境写死,统一团队的开发环境**,避免因为跨SDK版本出现的API差异进而导致工程问题。
|
||||
|
||||
比如,在上面的示例中,我们可以将Dart SDK写死为2.3.0,Flutter SDK写死为1.2.1。
|
||||
|
||||
```
|
||||
environment:
|
||||
sdk: 2.3.0
|
||||
flutter: 1.2.1
|
||||
|
||||
```
|
||||
|
||||
基于版本的方式引用第三方包,需要在其Pub上进行公开发布,我们可以访问[https://pub.dev/](https://pub.dev/)来获取可用的第三方包。而对于不对外公开发布,或者目前处于开发调试阶段的包,我们需要设置数据源,使用本地路径或Git地址的方式进行包声明。
|
||||
|
||||
在下面的例子中,我们分别以路径依赖以及Git依赖的方式,声明了package1和package2这两个包:
|
||||
|
||||
```
|
||||
dependencies:
|
||||
package1:
|
||||
path: ../package1/ #路径依赖
|
||||
date_format:
|
||||
git:
|
||||
url: https://github.com/xxx/package2.git #git依赖
|
||||
|
||||
```
|
||||
|
||||
在开发应用时,我们可以不写明具体的版本号,而是以区间的方式声明包的依赖;但对于一个程序而言,其运行时具体引用哪个版本的依赖包必须要确定下来。因此,**除了管理第三方依赖,包管理工具Pub的另一个职责是,找出一组同时满足每个包版本约束的包版本。**包版本一旦确定,接下来就是下载对应版本的包了。
|
||||
|
||||
对于dependencies中的不同数据源,Dart会使用不同的方式进行管理,最终会将远端的包全部下载到本地。比如,对于Git声明依赖的方式,Pub会clone Git仓库;对于版本号的方式,Pub则会从pub.dartlang.org下载包。如果包还有其他的依赖包,比如package1包还依赖package3包,Pub也会一并下载。
|
||||
|
||||
然后,在完成了所有依赖包的下载后,**Pub会在应用的根目录下创建.packages文件**,将依赖的包名与系统缓存中的包文件路径进行映射,方便后续维护。
|
||||
|
||||
最后,**Pub会自动创建pubspec.lock文件**。pubspec.lock文件的作用类似iOS的Podfile.lock或前端的package-lock.json文件,用于记录当前状态下实际安装的各个直接依赖、间接依赖的包的具体来源和版本号。
|
||||
|
||||
比较活跃的第三方包的升级通常比较频繁,因此对于多人协作的Flutter应用来说,我们需要把pubspec.lock文件也一并提交到代码版本管理中,这样团队中的所有人在使用这个应用时安装的所有依赖都是完全一样的,以避免出现库函数找不到或者其他的依赖错误。
|
||||
|
||||
**除了提供功能和代码维度的依赖之外,包还可以提供资源的依赖**。在依赖包中的pubspec.yaml文件已经声明了同样资源的情况下,为节省应用程序安装包大小,我们需要复用依赖包中的资源。
|
||||
|
||||
在下面的例子中,我们的应用程序依赖了一个名为package4的包,而它的目录结构是这样的:
|
||||
|
||||
```
|
||||
pubspec.yaml
|
||||
└──assets
|
||||
├──2.0x
|
||||
│ └── placeholder.png
|
||||
└──3.0x
|
||||
└── placeholder.png
|
||||
|
||||
```
|
||||
|
||||
其中,placeholder.png是可复用资源。因此,在应用程序中,我们可以通过Image和AssetImage提供的package参数,根据设备实际分辨率去加载图像。
|
||||
|
||||
```
|
||||
Image.asset('assets/placeholder.png', package: 'package4');
|
||||
|
||||
AssetImage('assets/placeholder.png', package: 'package4');
|
||||
例子
|
||||
|
||||
```
|
||||
|
||||
## 例子
|
||||
|
||||
接下来,我们通过一个日期格式化的例子,来演示如何使用第三方库。
|
||||
|
||||
在Flutter中,提供了表达日期的数据结构[DateTime](https://api.flutter.dev/flutter/dart-core/DateTime-class.html),这个类拥有极大的表示范围,可以表达1970-01-01 UTC时间后 100,000,000天内的任意时刻。不过,如果我们想要格式化显示日期和时间,DateTime并没有提供非常方便的方法,我们不得不自己取出年、月、日、时、分、秒,来定制显示方式。
|
||||
|
||||
值得庆幸的是,我们可以通过date_format这个第三方包来实现我们的诉求:date_format提供了若干常用的日期格式化方法,可以很方便地实现格式化日期的功能。
|
||||
|
||||
**首先**,我们在Pub上找到date_format这个包,确定其使用说明:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/f9/5ad48b85c516aea99ea464c4da6ac2f9.png" alt="">
|
||||
|
||||
date_format包最新的版本是1.0.6,于是**接下来**我们把date_format添加到pubspec.yaml中:
|
||||
|
||||
```
|
||||
dependencies:
|
||||
date_format: 1.0.6
|
||||
|
||||
```
|
||||
|
||||
**随后**,IDE(Android Studio)监测到了配置文件的改动,提醒我们进行安装包依赖更新。于是,我们点击Get dependencies,下载date_format :
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/87/a635ff7d4eb26aa287bb2c904b9bb887.png" alt="">
|
||||
|
||||
下载完成后,我们就可以在工程中使用date_format来进行日期的格式化了:
|
||||
|
||||
```
|
||||
print(formatDate(DateTime.now(), [mm, '月', dd, '日', hh, ':', n]));
|
||||
//输出2019年06月30日01:56
|
||||
print(formatDate(DateTime.now(), [m, '月第', w, '周']));
|
||||
//输出6月第5周
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的分享就到这里。我们简单回顾一下今天的内容。
|
||||
|
||||
在Flutter中,资源与工程代码依赖属于包管理范畴,采用包的配置文件pubspec.yaml进行统一管理。
|
||||
|
||||
我们可以通过pubspec.yaml设置包的元数据(比如,包的名称和版本)、运行环境(比如,Dart SDK与Fluter SDK版本)、外部依赖和内部配置。
|
||||
|
||||
对于依赖的指定,可以以区间的方式确定版本兼容范围,也可以指定本地路径、Git、Pub这三种不同的数据源,包管理工具会找出同时满足每个依赖包版本约束的包版本,然后依次下载,并通过.packages文件建立下载缓存与包名的映射,最后统一将当前状态下,实际安装的各个包的具体来源和版本号记录至pubspec.lock文件。
|
||||
|
||||
现代编程语言大都自带第依赖管理机制,其核心功能是为工程中所有直接或间接依赖的代码库找到合适的版本,但这并不容易。就比如前端的依赖管理器npm的早期版本,就曾因为不太合理的算法设计,导致计算依赖耗时过长,依赖文件夹也高速膨胀,一度被开发者们戏称为“黑洞”。而Dart使用的Pub依赖管理机制所采用的[PubGrub算法](https://github.com/dart-lang/pub/blob/master/doc/solver.md)则解决了这些问题,因此被称为下一代版本依赖解决算法,在2018年底被苹果公司吸纳,成为Swift所采用的[依赖管理器算法](https://github.com/apple/swift-package-manager/pull/1918)。
|
||||
|
||||
当然,如果你的工程里的依赖比较多,并且依赖关系比较复杂,即使再优秀的依赖解决算法也需要花费较长的时间才能计算出合适的依赖库版本。如果我们想减少依赖管理器为你寻找代码库依赖版本所耗费的时间,一个简单的做法就是从源头抓起,在pubspec.yaml文件中固定那些依赖关系复杂的第三方库们,及它们递归依赖的第三方库的版本号。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留下两道思考题吧。
|
||||
|
||||
1. pubspec.yaml、.packages与pubspec.lock这三个文件,在包管理中的具体作用是什么?
|
||||
1. .packages与pubspec.lock是否需要做代码版本管理呢?为什么?
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
220
极客时间专栏/Flutter核心技术与实战/Flutter基础/19 | 用户交互事件该如何响应?.md
Normal file
220
极客时间专栏/Flutter核心技术与实战/Flutter基础/19 | 用户交互事件该如何响应?.md
Normal file
@@ -0,0 +1,220 @@
|
||||
<audio id="audio" title="19 | 用户交互事件该如何响应?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f7/1a/f7ebc8651bb709cc64538f30f6630c1a.mp3"></audio>
|
||||
|
||||
你好,我是陈航。今天,我和你分享的主题是,如何响应用户交互事件。
|
||||
|
||||
在前面两篇文章中,我和你一起学习了Flutter依赖的包管理机制。在Flutter中,包是包含了外部依赖的功能抽象。对于资源和工程代码依赖,我们采用包配置文件pubspec.yaml进行统一管理。
|
||||
|
||||
通过前面几个章节的学习,我们已经掌握了如何在Flutter中通过内部实现和外部依赖去实现自定义UI,完善业务逻辑。但除了按钮和ListView这些动态的组件之外,我们还无法响应用户交互行为。那今天的分享中,我就着重与你讲述Flutter是如何监听和响应用户的手势操作的。
|
||||
|
||||
手势操作在Flutter中分为两类:
|
||||
|
||||
- 第一类是原始的指针事件(Pointer Event),即原生开发中常见的触摸事件,表示屏幕上触摸(或鼠标、手写笔)行为触发的位移行为;
|
||||
- 第二类则是手势识别(Gesture Detector),表示多个原始指针事件的组合操作,如点击、双击、长按等,是指针事件的语义化封装。
|
||||
|
||||
接下来,我们先看一下原始的指针事件。
|
||||
|
||||
## 指针事件
|
||||
|
||||
指针事件表示用户交互的原始触摸数据,如手指接触屏幕PointerDownEvent、手指在屏幕上移动PointerMoveEvent、手指抬起PointerUpEvent,以及触摸取消PointerCancelEvent,这与原生系统的底层触摸事件抽象是一致的。
|
||||
|
||||
在手指接触屏幕,触摸事件发起时,Flutter会确定手指与屏幕发生接触的位置上究竟有哪些组件,并将触摸事件交给最内层的组件去响应。与浏览器中的事件冒泡机制类似,事件会从这个最内层的组件开始,沿着组件树向根节点向上冒泡分发。
|
||||
|
||||
不过Flutter无法像浏览器冒泡那样取消或者停止事件进一步分发,我们只能通过hitTestBehavior去调整组件在命中测试期内应该如何表现,比如把触摸事件交给子组件,或者交给其视图层级之下的组件去响应。
|
||||
|
||||
关于组件层面的原始指针事件的监听,Flutter提供了Listener Widget,可以监听其子Widget的原始指针事件。
|
||||
|
||||
现在,我们一起看一个Listener的案例。我定义了一个宽度为300的红色正方形Container,利用Listener监听其内部Down、Move及Up事件:
|
||||
|
||||
```
|
||||
Listener(
|
||||
child: Container(
|
||||
color: Colors.red,//背景色红色
|
||||
width: 300,
|
||||
height: 300,
|
||||
),
|
||||
onPointerDown: (event) => print("down $event"),//手势按下回调
|
||||
onPointerMove: (event) => print("move $event"),//手势移动回调
|
||||
onPointerUp: (event) => print("up $event"),//手势抬起回调
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
我们试着在红色正方形区域内进行触摸点击、移动、抬起,可以看到Listener监听到了一系列原始指针事件,并打印出了这些事件的位置信息:
|
||||
|
||||
```
|
||||
I/flutter (13829): up PointerUpEvent(Offset(97.7, 287.7))
|
||||
I/flutter (13829): down PointerDownEvent(Offset(150.8, 313.4))
|
||||
I/flutter (13829): move PointerMoveEvent(Offset(152.0, 313.4))
|
||||
I/flutter (13829): move PointerMoveEvent(Offset(154.6, 313.4))
|
||||
I/flutter (13829): up PointerUpEvent(Offset(157.1, 312.3))
|
||||
|
||||
```
|
||||
|
||||
## 手势识别
|
||||
|
||||
使用Listener可以直接监听指针事件。不过指针事件毕竟太原始了,如果我们想要获取更多的触摸事件细节,比如判断用户是否正在拖拽控件,直接使用指针事件的话就会非常复杂。
|
||||
|
||||
通常情况下,响应用户交互行为的话,我们会使用封装了手势语义操作的Gesture,如点击onTap、双击onDoubleTap、长按onLongPress、拖拽onPanUpdate、缩放onScaleUpdate等。另外,Gesture可以支持同时分发多个手势交互行为,意味着我们可以通过Gesture同时监听多个事件。
|
||||
|
||||
**Gesture是手势语义的抽象,而如果我们想从组件层监听手势,则需要使用GestureDetector**。GestureDetector是一个处理各种高级用户触摸行为的Widget,与Listener一样,也是一个功能性组件。
|
||||
|
||||
接下来,我们通过一个案例来看看GestureDetector的用法。
|
||||
|
||||
我定义了一个Stack层叠布局,使用Positioned组件将1个红色的Container放置在左上角,并同时监听点击、双击、长按和拖拽事件。在拖拽事件的回调方法中,我们更新了Container的位置:
|
||||
|
||||
```
|
||||
//红色container坐标
|
||||
double _top = 0.0;
|
||||
double _left = 0.0;
|
||||
Stack(//使用Stack组件去叠加视图,便于直接控制视图坐标
|
||||
children: <Widget>[
|
||||
Positioned(
|
||||
top: _top,
|
||||
left: _left,
|
||||
child: GestureDetector(//手势识别
|
||||
child: Container(color: Colors.red,width: 50,height: 50),//红色子视图
|
||||
onTap: ()=>print("Tap"),//点击回调
|
||||
onDoubleTap: ()=>print("Double Tap"),//双击回调
|
||||
onLongPress: ()=>print("Long Press"),//长按回调
|
||||
onPanUpdate: (e) {//拖动回调
|
||||
setState(() {
|
||||
//更新位置
|
||||
_left += e.delta.dx;
|
||||
_top += e.delta.dy;
|
||||
});
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
运行这段代码,并查看控制台输出,可以看到,红色的Container除了可以响应我们的拖拽行为外,还能够同时响应点击、双击、长按这些事件。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b8/4e/b8c4fa6e898ef154afc217d5ebf0b54e.gif" alt="">
|
||||
|
||||
尽管在上面的例子中,我们对一个Widget同时监听了多个手势事件,但最终只会有一个手势能够得到本次事件的处理权。对于多个手势的识别,Flutter引入了**手势竞技场(Arena)**的概念,用来识别究竟哪个手势可以响应用户事件。手势竞技场会考虑用户触摸屏幕的时长、位移以及拖动方向,来确定最终手势。
|
||||
|
||||
**那手势竞技场具体是怎么实现的呢?**
|
||||
|
||||
实际上,GestureDetector内部对每一个手势都建立了一个工厂类(Gesture Factory)。而工厂类的内部会使用手势识别类(GestureRecognizer),来确定当前处理的手势。
|
||||
|
||||
而所有手势的工厂类都会被交给RawGestureDetector类,以完成监测手势的大量工作:使用Listener监听原始指针事件,并在状态改变时把信息同步给所有的手势识别器,然后这些手势会在竞技场决定最后由谁来响应用户事件。
|
||||
|
||||
有些时候我们可能会在应用中给多个视图注册同类型的手势监听器,比如微博的信息流列表中的微博,点击不同区域会有不同的响应:点击头像会进入用户个人主页,点击图片会进入查看大图页面,点击其他部分会进入微博详情页等。
|
||||
|
||||
像这样的手势识别发生在多个存在父子关系的视图时,手势竞技场会一并检查父视图和子视图的手势,并且通常最终会确认由子视图来响应事件。而这也是合乎常理的:从视觉效果上看,子视图的视图层级位于父视图之上,相当于对其进行了遮挡,因此从事件处理上看,子视图自然是事件响应的第一责任人。
|
||||
|
||||
在下面的示例中,我定义了两个嵌套的Container容器,分别加入了点击识别事件:
|
||||
|
||||
```
|
||||
GestureDetector(
|
||||
onTap: () => print('Parent tapped'),//父视图的点击回调
|
||||
child: Container(
|
||||
color: Colors.pinkAccent,
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
onTap: () => print('Child tapped'),//子视图的点击回调
|
||||
child: Container(
|
||||
color: Colors.blueAccent,
|
||||
width: 200.0,
|
||||
height: 200.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
运行这段代码,然后在蓝色区域进行点击,可以发现:尽管父容器也监听了点击事件,但Flutter只响应了子容器的点击事件。
|
||||
|
||||
```
|
||||
I/flutter (16188): Child tapped
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8e/da/8e99c7bbc5d0b52b3ba80263f989e7da.png" alt="">
|
||||
|
||||
为了让父容器也能接收到手势,我们需要同时使用RawGestureDetector和GestureFactory,来改变竞技场决定由谁来响应用户事件的结果。
|
||||
|
||||
在此之前,**我们还需要自定义一个手势识别器**,让这个识别器在竞技场被PK失败时,能够再把自己重新添加回来,以便接下来还能继续去响应用户事件。
|
||||
|
||||
在下面的代码中,我定义了一个继承自点击手势识别器TapGestureRecognizer的类,并重写了其rejectGesture方法,手动地把自己又复活了:
|
||||
|
||||
```
|
||||
class MultipleTapGestureRecognizer extends TapGestureRecognizer {
|
||||
@override
|
||||
void rejectGesture(int pointer) {
|
||||
acceptGesture(pointer);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接下来,我们需要将手势识别器和其工厂类传递给RawGestureDetector,以便用户产生手势交互事件时能够立刻找到对应的识别方法。事实上,RawGestureDetector的初始化函数所做的配置工作,就是定义不同手势识别器和其工厂类的映射关系。
|
||||
|
||||
这里,由于我们只需要处理点击事件,所以只配置一个识别器即可。工厂类的初始化采用GestureRecognizerFactoryWithHandlers函数完成,这个函数提供了手势识别对象创建,以及对应的初始化入口。
|
||||
|
||||
在下面的代码中,我们完成了自定义手势识别器的创建,并设置了点击事件回调方法。需要注意的是,由于我们只需要在父容器监听子容器的点击事件,所以只需要将父容器用RawGestureDetector包装起来就可以了,而子容器保持不变:
|
||||
|
||||
```
|
||||
RawGestureDetector(//自己构造父Widget的手势识别映射关系
|
||||
gestures: {
|
||||
//建立多手势识别器与手势识别工厂类的映射关系,从而返回可以响应该手势的recognizer
|
||||
MultipleTapGestureRecognizer: GestureRecognizerFactoryWithHandlers<
|
||||
MultipleTapGestureRecognizer>(
|
||||
() => MultipleTapGestureRecognizer(),
|
||||
(MultipleTapGestureRecognizer instance) {
|
||||
instance.onTap = () => print('parent tapped ');//点击回调
|
||||
},
|
||||
)
|
||||
},
|
||||
child: Container(
|
||||
color: Colors.pinkAccent,
|
||||
child: Center(
|
||||
child: GestureDetector(//子视图可以继续使用GestureDetector
|
||||
onTap: () => print('Child tapped'),
|
||||
child: Container(...),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
运行一下这段代码,我们可以看到,当点击蓝色容器时,其父容器也收到了Tap事件。
|
||||
|
||||
```
|
||||
I/flutter (16188): Child tapped
|
||||
I/flutter (16188): parent tapped
|
||||
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的分享就到这里。我们来简单回顾下Flutter是如何响应用户事件的。
|
||||
|
||||
首先,我们了解了Flutter底层原始指针事件,以及对应的监听方式和冒泡分发机制。
|
||||
|
||||
然后,我们学习了封装了底层指针事件手势语义的Gesture,了解了多个手势的识别方法,以及其同时支持多个手势交互的能力。
|
||||
|
||||
最后,我与你介绍了Gesture的事件处理机制:在Flutter中,尽管我们可以对一个Widget监听多个手势,或是对多个Widget监听同一个手势,但Flutter会使用手势竞技场来进行各个手势的PK,以保证最终只会有一个手势能够响应用户行为。如果我们希望同时能有多个手势去响应用户行为,需要去自定义手势,利用RawGestureDetector和手势工厂类,在竞技场PK失败时,手动把它复活。
|
||||
|
||||
在处理多个手势识别场景,很容易出现手势冲突的问题。比如,当需要对图片进行点击、长按、旋转、缩放、拖动等操作的时候,如何识别用户当前是点击还是长按,是旋转还是缩放。如果想要精确地处理复杂交互手势,我们势必需要介入手势识别过程,解决异常。
|
||||
|
||||
不过需要注意的是,冲突的只是手势的语义化识别过程,原始指针事件是不会冲突的。所以,在遇到复杂的冲突场景通过手势很难搞定时,我们也可以通过Listener直接识别原始指针事件,从而解决手势识别的冲突。
|
||||
|
||||
我把今天分享所涉及到的[事件处理demo](https://github.com/cyndibaby905/19_gesture_demo)放到了GitHub上,你可以下载下来自己运行,进一步巩固学习效果。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留下两个思考题吧。
|
||||
|
||||
1. 对于一个父容器中存在按钮FlatButton的界面,在父容器使用GestureDetector监听了onTap事件的情况下,如果我们点击按钮,父容器的点击事件会被识别吗,为什么?
|
||||
1. 如果监听的是onDoubleTap事件,在按钮上双击,父容器的双击事件会被识别吗,为什么?
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
310
极客时间专栏/Flutter核心技术与实战/Flutter基础/20 | 关于跨组件传递数据,你只需要记住这三招.md
Normal file
310
极客时间专栏/Flutter核心技术与实战/Flutter基础/20 | 关于跨组件传递数据,你只需要记住这三招.md
Normal file
@@ -0,0 +1,310 @@
|
||||
<audio id="audio" title="20 | 关于跨组件传递数据,你只需要记住这三招" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1e/fc/1e5f7703bd5ef647cb041a7f8e2398fc.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
在上一篇文章中,我带你一起学习了在Flutter中如何响应用户交互事件(手势)。手势处理在Flutter中分为两种:原始的指针事件处理和高级的手势识别。
|
||||
|
||||
其中,指针事件以冒泡机制分发,通过Listener完成监听;而手势识别则通过Gesture处理。但需要注意的是,虽然Flutter可以同时支持多个手势(包括一个Widget监听多个手势,或是多个Widget监听同一个手势),但最终只会有一个Widget的手势能够响应用户行为。为了改变这一点,我们需要自定义手势,修改手势竞技场对于多手势优先级判断的默认行为。
|
||||
|
||||
除了需要响应外部的事件之外,UI框架的另一个重要任务是,处理好各个组件之间的数据同步关系。尤其对于Flutter这样大量依靠组合Widget的行为来实现用户界面的框架来说,如何确保数据的改变能够映射到最终的视觉效果上就显得更为重要。所以,在今天这篇文章中,我就与你介绍在Flutter中如何进行跨组件数据传递。
|
||||
|
||||
在之前的分享中,通过组合嵌套的方式,利用数据对基础Widget的样式进行视觉属性定制,我们已经实现了多种界面布局。所以,你应该已经体会到了,在Flutter中实现跨组件数据传递的标准方式是通过属性传值。
|
||||
|
||||
但是,对于稍微复杂一点的、尤其视图层级比较深的UI样式,一个属性可能需要跨越很多层才能传递给子组件,这种传递方式就会导致中间很多并不需要这个属性的组件也需要接收其子Widget的数据,不仅繁琐而且冗余。
|
||||
|
||||
所以,对于数据的跨层传递,Flutter还提供了三种方案:InheritedWidget、Notification和EventBus。接下来,我将依次为你讲解这三种方案。
|
||||
|
||||
## InheritedWidget
|
||||
|
||||
InheritedWidget是Flutter中的一个功能型Widget,适用于在Widget树中共享数据的场景。通过它,我们可以高效地将数据在Widget树中进行跨层传递。
|
||||
|
||||
在前面的第16篇文章“[从夜间模式说起,如何定制不同风格的App主题?](https://time.geekbang.org/column/article/112148)”中,我与你介绍了如何通过Theme去访问当前界面的样式风格,从而进行样式复用的例子,比如Theme.of(context).primaryColor。
|
||||
|
||||
**Theme类是通过InheritedWidget实现的典型案例**。在子Widget中通过Theme.of方法找到上层Theme的Widget,获取到其属性的同时,建立子Widget和上层父Widget的观察者关系,当上层父Widget属性修改的时候,子Widget也会触发更新。
|
||||
|
||||
接下来,我就以Flutter工程模板中的计数器为例,与你说明InheritedWidget的使用方法。
|
||||
|
||||
- 首先,为了使用InheritedWidget,我们定义了一个继承自它的新类CountContainer。
|
||||
- 然后,我们将计数器状态count属性放到CountContainer中,并提供了一个of方法方便其子Widget在Widget树中找到它。
|
||||
- 最后,我们重写了updateShouldNotify方法,这个方法会在Flutter判断InheritedWidget是否需要重建,从而通知下层观察者组件更新数据时被调用到。在这里,我们直接判断count是否相等即可。
|
||||
|
||||
```
|
||||
class CountContainer extends InheritedWidget {
|
||||
//方便其子Widget在Widget树中找到它
|
||||
static CountContainer of(BuildContext context) => context.inheritFromWidgetOfExactType(CountContainer) as CountContainer;
|
||||
|
||||
final int count;
|
||||
|
||||
CountContainer({
|
||||
Key key,
|
||||
@required this.count,
|
||||
@required Widget child,
|
||||
}): super(key: key, child: child);
|
||||
|
||||
// 判断是否需要更新
|
||||
@override
|
||||
bool updateShouldNotify(CountContainer oldWidget) => count != oldWidget.count;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,我们使用CountContainer作为根节点,并用0初始化count。随后在其子Widget Counter中,我们通过InheritedCountContainer.of方法找到它,获取计数状态count并展示:
|
||||
|
||||
```
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//将CountContainer作为根节点,并使用0作为初始化count
|
||||
return CountContainer(
|
||||
count: 0,
|
||||
child: Counter()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Counter extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//获取InheritedWidget节点
|
||||
CountContainer state = CountContainer.of(context);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text("InheritedWidget demo")),
|
||||
body: Text(
|
||||
'You have pushed the button this many times: ${state.count}',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行一下,效果如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/da/2e/da4662202739ac00969b111d1c30f12e.png" alt="">
|
||||
|
||||
可以看到InheritedWidget的使用方法还是比较简单的,无论Counter在CountContainer下层什么位置,都能获取到其父Widget的计数属性count,再也不用手动传递属性了。
|
||||
|
||||
**不过,InheritedWidget仅提供了数据读的能力,如果我们想要修改它的数据,则需要把它和StatefulWidget中的State配套使用**。我们需要把InheritedWidget中的数据和相关的数据修改方法,全部移到StatefulWidget中的State上,而InheritedWidget只需要保留对它们的引用。
|
||||
|
||||
我们对上面的代码稍加修改,删掉CountContainer中持有的count属性,增加对数据持有者State,以及数据修改方法的引用:
|
||||
|
||||
```
|
||||
class CountContainer extends InheritedWidget {
|
||||
...
|
||||
final _MyHomePageState model;//直接使用MyHomePage中的State获取数据
|
||||
final Function() increment;
|
||||
|
||||
CountContainer({
|
||||
Key key,
|
||||
@required this.model,
|
||||
@required this.increment,
|
||||
@required Widget child,
|
||||
}): super(key: key, child: child);
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,我们将count数据和其对应的修改方法放在了State中,仍然使用CountContainer作为根节点,完成了数据和修改方法的初始化。
|
||||
|
||||
在其子Widget Counter中,我们还是通过InheritedCountContainer.of方法找到它,将计数状态count与UI展示同步,将按钮的点击事件与数据修改同步:
|
||||
|
||||
```
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
int count = 0;
|
||||
void _incrementCounter() => setState(() {count++;});//修改计数器
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CountContainer(
|
||||
model: this,//将自身作为model交给CountContainer
|
||||
increment: _incrementCounter,//提供修改数据的方法
|
||||
child:Counter()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Counter extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//获取InheritedWidget节点
|
||||
CountContainer state = CountContainer.of(context);
|
||||
return Scaffold(
|
||||
...
|
||||
body: Text(
|
||||
'You have pushed the button this many times: ${state.model.count}', //关联数据读方法
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(onPressed: state.increment), //关联数据修改方法
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行一下,可以看到,我们已经实现InheritedWidget数据的读写了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d1/35/d18d85bdd7d8ccf095535db077f3cd35.gif" alt="">
|
||||
|
||||
## Notification
|
||||
|
||||
Notification是Flutter中进行跨层数据共享的另一个重要的机制。如果说InheritedWidget的数据流动方式是从父Widget到子Widget逐层传递,那Notificaiton则恰恰相反,数据流动方式是从子Widget向上传递至父Widget。这样的数据传递机制适用于子Widget状态变更,发送通知上报的场景。
|
||||
|
||||
在前面的第13篇文章“[经典控件(二):UITableView/ListView在Flutter中是什么?](https://time.geekbang.org/column/article/110859)”中,我与你介绍了ScrollNotification的使用方法:ListView在滚动时会分发通知,我们可以在上层使用NotificationListener监听ScrollNotification,根据其状态做出相应的处理。
|
||||
|
||||
自定义通知的监听与ScrollNotification并无不同,而如果想要实现自定义通知,我们首先需要继承Notification类。Notification类提供了dispatch方法,可以让我们沿着context对应的Element节点树向上逐层发送通知。
|
||||
|
||||
接下来,我们一起看一个具体的案例吧。在下面的代码中,我们自定义了一个通知和子Widget。子Widget是一个按钮,在点击时会发送通知:
|
||||
|
||||
```
|
||||
class CustomNotification extends Notification {
|
||||
CustomNotification(this.msg);
|
||||
final String msg;
|
||||
}
|
||||
|
||||
//抽离出一个子Widget用来发通知
|
||||
class CustomChild extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RaisedButton(
|
||||
//按钮点击时分发通知
|
||||
onPressed: () => CustomNotification("Hi").dispatch(context),
|
||||
child: Text("Fire Notification"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
而在子Widget的父Widget中,我们监听了这个通知,一旦收到通知,就会触发界面刷新,展示收到的通知信息:
|
||||
|
||||
```
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
String _msg = "通知:";
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//监听通知
|
||||
return NotificationListener<CustomNotification>(
|
||||
onNotification: (notification) {
|
||||
setState(() {_msg += notification.msg+" ";});//收到子Widget通知,更新msg
|
||||
},
|
||||
child:Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[Text(_msg),CustomChild()],//将子Widget加入到视图树中
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行一下代码,可以看到,我们每次点击按钮之后,界面上都会出现最新的通知信息:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e8/dd/e8c504730c0580dc699a0807039c76dd.gif" alt="">
|
||||
|
||||
## EventBus
|
||||
|
||||
无论是InheritedWidget还是Notificaiton,它们的使用场景都需要依靠Widget树,也就意味着只能在有父子关系的Widget之间进行数据共享。但是,组件间数据传递还有一种常见场景:这些组件间不存在父子关系。这时,事件总线EventBus就登场了。
|
||||
|
||||
事件总线是在Flutter中实现跨组件通信的机制。它遵循发布/订阅模式,允许订阅者订阅事件,当发布者触发事件时,订阅者和发布者之间可以通过事件进行交互。发布者和订阅者之间无需有父子关系,甚至非Widget对象也可以发布/订阅。这些特点与其他平台的事件总线机制是类似的。
|
||||
|
||||
接下来,我们通过一个跨页面通信的例子,来看一下事件总线的具体使用方法。需要注意的是,EventBus是一个第三方插件,因此我们需要在pubspec.yaml文件中声明它:
|
||||
|
||||
```
|
||||
dependencies:
|
||||
event_bus: 1.1.0
|
||||
|
||||
```
|
||||
|
||||
EventBus的使用方式灵活,可以支持任意对象的传递。所以在这里,我们传输数据的载体就选择了一个有字符串属性的自定义事件类CustomEvent:
|
||||
|
||||
```
|
||||
class CustomEvent {
|
||||
String msg;
|
||||
CustomEvent(this.msg);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,我们定义了一个全局的eventBus对象,并在第一个页面监听了CustomEvent事件,一旦收到事件,就会刷新UI。需要注意的是,千万别忘了在State被销毁时清理掉事件注册,否则你会发现State永远被EventBus持有着,无法释放,从而造成内存泄漏:
|
||||
|
||||
```
|
||||
//建立公共的event bus
|
||||
EventBus eventBus = new EventBus();
|
||||
//第一个页面
|
||||
class _FirstScreenState extends State<FirstScreen> {
|
||||
|
||||
String msg = "通知:";
|
||||
StreamSubscription subscription;
|
||||
@override
|
||||
initState() {
|
||||
//监听CustomEvent事件,刷新UI
|
||||
subscription = eventBus.on<CustomEvent>().listen((event) {
|
||||
setState(() {msg+= event.msg;});//更新msg
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
dispose() {
|
||||
subscription.cancel();//State销毁时,清理注册
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return new Scaffold(
|
||||
body:Text(msg),
|
||||
...
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最后,我们在第二个页面以按钮点击回调的方式,触发了CustomEvent事件:
|
||||
|
||||
```
|
||||
class SecondScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return new Scaffold(
|
||||
...
|
||||
body: RaisedButton(
|
||||
child: Text('Fire Event'),
|
||||
// 触发CustomEvent事件
|
||||
onPressed: ()=> eventBus.fire(CustomEvent("hello"))
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行一下,多点击几下第二个页面的按钮,然后返回查看第一个页面上的消息:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/d5/5a50441b322568f4f3d77aea87d2aed5.gif" alt="">
|
||||
|
||||
可以看到,EventBus的使用方法还是比较简单的,使用限制也相对最少。
|
||||
|
||||
这里我准备了一张表格,把属性传值、InheritedWidget、Notification与EventBus这四种数据共享方式的特点和使用场景做了简单总结,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b2/66/b2a78dbefdf30895504b2017355ae066.png" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的分享就到这里。我们来简单回顾下在Flutter中,如何实现跨组件的数据共享。
|
||||
|
||||
首先,我们认识了InheritedWidget。对于视图层级比较深的UI样式,直接通过属性传值的方式会导致很多中间层增加冗余属性,而使用InheritedWidget可以实现子Widget跨层共享父Widget的属性。需要注意的是,InheritedWidget中的属性在子Widget中只能读,如果有修改的场景,我们需要把它和StatefulWidget中的State配套使用。
|
||||
|
||||
然后,我们学习了Notification,这种由下到上传递数据的跨层共享机制。我们可以使用NotificationListener,在父Widget监听来自子Widget的事件。
|
||||
|
||||
最后,我与你介绍了EventBus,这种无需发布者与订阅者之间存在父子关系的数据同步机制。
|
||||
|
||||
我把今天分享所涉及到的三种跨组件的[数据共享方式demo](https://github.com/cyndibaby905/20_data_transfer)放到了GitHub,你可以下载下来自己运行,体会它们之间的共同点和差异。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我来给你留下一个思考题吧。
|
||||
|
||||
请你分别概括属性传值、InheritedWidget、Notification与EventBus的优缺点。
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
217
极客时间专栏/Flutter核心技术与实战/Flutter基础/21 | 路由与导航,Flutter是这样实现页面切换的.md
Normal file
217
极客时间专栏/Flutter核心技术与实战/Flutter基础/21 | 路由与导航,Flutter是这样实现页面切换的.md
Normal file
@@ -0,0 +1,217 @@
|
||||
<audio id="audio" title="21 | 路由与导航,Flutter是这样实现页面切换的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/08/1d/080c8bed2049e96e96328047b45cea1d.mp3"></audio>
|
||||
|
||||
你好,我是陈航。
|
||||
|
||||
在上一篇文章中,我带你一起学习了如何在Flutter中实现跨组件数据传递。其中,InheritedWidget适用于子Widget跨层共享父Widget数据的场景,如果子Widget还需要修改父Widget数据,则需要和State一起配套使用。而Notification,则适用于父Widget监听子Widget事件的场景。对于没有父子关系的通信双方,我们还可以使用EventBus实现基于订阅/发布模式的机制实现数据交互。
|
||||
|
||||
如果说UI框架的视图元素的基本单位是组件,那应用程序的基本单位就是页面了。对于拥有多个页面的应用程序而言,如何从一个页面平滑地过渡到另一个页面,我们需要有一个统一的机制来管理页面之间的跳转,通常被称为**路由管理或导航管理**。
|
||||
|
||||
我们首先需要知道目标页面对象,在完成目标页面初始化后,用框架提供的方式打开它。比如,在Android/iOS中我们通常会初始化一个Intent或ViewController,通过startActivity或pushViewController来打开一个新的页面;而在React中,我们使用navigation来管理所有页面,只要知道页面的名称,就可以立即导航到这个页面。
|
||||
|
||||
其实,Flutter的路由管理也借鉴了这两种设计思路。那么,今天我们就来看看,如何在一个Flutter应用中管理不同页面的命名和过渡。
|
||||
|
||||
## 路由管理
|
||||
|
||||
在Flutter中,页面之间的跳转是通过Route和Navigator来管理的:
|
||||
|
||||
- Route是页面的抽象,主要负责创建对应的界面,接收参数,响应Navigator打开和关闭;
|
||||
- 而Navigator则会维护一个路由栈管理Route,Route打开即入栈,Route关闭即出栈,还可以直接替换栈内的某一个Route。
|
||||
|
||||
而根据是否需要提前注册页面标识符,Flutter中的路由管理可以分为两种方式:
|
||||
|
||||
- 基本路由。无需提前注册,在页面切换时需要自己构造页面实例。
|
||||
- 命名路由。需要提前注册页面标识符,在页面切换时通过标识符直接打开新的路由。
|
||||
|
||||
接下来,我们先一起看看基本路由这种管理方式吧。
|
||||
|
||||
### 基本路由
|
||||
|
||||
在Flutter中,**基本路由的使用方法和Android/iOS打开新页面的方式非常相似**。要导航到一个新的页面,我们需要创建一个MaterialPageRoute的实例,调用Navigator.push方法将新页面压到堆栈的顶部。
|
||||
|
||||
其中,MaterialPageRoute是一种路由模板,定义了路由创建及切换过渡动画的相关配置,可以针对不同平台,实现与平台页面切换动画风格一致的路由切换动画。
|
||||
|
||||
而如果我们想返回上一个页面,则需要调用Navigator.pop方法从堆栈中删除这个页面。
|
||||
|
||||
下面的代码演示了基本路由的使用方法:在第一个页面的按钮事件中打开第二个页面,并在第二个页面的按钮事件中回退到第一个页面:
|
||||
|
||||
```
|
||||
class FirstScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RaisedButton(
|
||||
//打开页面
|
||||
onPressed: ()=> Navigator.push(context, MaterialPageRoute(builder: (context) => SecondScreen()));
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SecondPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RaisedButton(
|
||||
// 回退页面
|
||||
onPressed: ()=> Navigator.pop(context)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行一下代码,效果如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/18/10/18956211d36dffacea40592a2cc9cd10.gif" alt="">
|
||||
|
||||
可以看到,基本路由的使用还是比较简单的。接下来,我们再看看命名路由的使用方法。
|
||||
|
||||
### 命名路由
|
||||
|
||||
基本路由使用方式相对简单灵活,适用于应用中页面不多的场景。而在应用中页面比较多的情况下,再使用基本路由方式,那么每次跳转到一个新的页面,我们都要手动创建MaterialPageRoute实例,初始化页面,然后调用push方法打开它,还是比较麻烦的。
|
||||
|
||||
所以,Flutter提供了另外一种方式来简化路由管理,即命名路由。我们给页面起一个名字,然后就可以直接通过页面名字打开它了。这种方式简单直观,**与React中的navigation使用方式类似**。
|
||||
|
||||
要想通过名字来指定页面切换,我们必须先给应用程序MaterialApp提供一个页面名称映射规则,即路由表routes,这样Flutter才知道名字与页面Widget的对应关系。
|
||||
|
||||
路由表实际上是一个Map<String,WidgetBuilder>,其中key值对应页面名字,而value值则是一个WidgetBuilder回调函数,我们需要在这个函数中创建对应的页面。而一旦在路由表中定义好了页面名字,我们就可以使用Navigator.pushNamed来打开页面了。
|
||||
|
||||
下面的代码演示了命名路由的使用方法:在MaterialApp完成了页面的名字second_page及页面的初始化方法注册绑定,后续我们就可以在代码中以second_page这个名字打开页面了:
|
||||
|
||||
```
|
||||
MaterialApp(
|
||||
...
|
||||
//注册路由
|
||||
routes:{
|
||||
"second_page":(context)=>SecondPage(),
|
||||
},
|
||||
);
|
||||
//使用名字打开页面
|
||||
Navigator.pushNamed(context,"second_page");
|
||||
|
||||
```
|
||||
|
||||
可以看到,命名路由的使用也很简单。
|
||||
|
||||
不过**由于路由的注册和使用都采用字符串来标识,这就会带来一个隐患**:如果我们打开了一个不存在的路由会怎么办?
|
||||
|
||||
也许你会想到,我们可以约定使用字符串常量去定义、使用路由,但我们无法避免通过接口数据下发的错误路由标识符场景。面对这种情况,无论是直接报错或是不响应错误路由,都不是一个用户体验良好的解决办法。
|
||||
|
||||
**更好的办法是**,对用户进行友好的错误提示,比如跳转到一个统一的NotFoundScreen页面,也方便我们对这类错误进行统一收集、上报。
|
||||
|
||||
在注册路由表时,Flutter提供了UnknownRoute属性,我们可以对未知的路由标识符进行统一的页面跳转处理。
|
||||
|
||||
下面的代码演示了如何注册错误路由处理。和基本路由的使用方法类似,我们只需要返回一个固定的页面即可。
|
||||
|
||||
```
|
||||
MaterialApp(
|
||||
...
|
||||
//注册路由
|
||||
routes:{
|
||||
"second_page":(context)=>SecondPage(),
|
||||
},
|
||||
//错误路由处理,统一返回UnknownPage
|
||||
onUnknownRoute: (RouteSettings setting) => MaterialPageRoute(builder: (context) => UnknownPage()),
|
||||
);
|
||||
|
||||
//使用错误名字打开页面
|
||||
Navigator.pushNamed(context,"unknown_page");
|
||||
|
||||
```
|
||||
|
||||
运行一下代码,可以看到,我们的应用不仅可以处理正确的页面路由标识,对错误的页面路由标识符也可以统一跳转到固定的错误处理页面了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/dc/97/dc007d9b1313c88a22aa27b3e1f5a897.gif" alt="">
|
||||
|
||||
### 页面参数
|
||||
|
||||
与基本路由能够精确地控制目标页面初始化方式不同,命名路由只能通过字符串名字来初始化固定目标页面。为了解决不同场景下目标页面的初始化需求,Flutter提供了路由参数的机制,可以在打开路由时传递相关参数,在目标页面通过RouteSettings来获取页面参数。
|
||||
|
||||
下面的代码演示了如何传递并获取参数:使用页面名称second_page打开页面时,传递了一个字符串参数,随后在SecondPage中,我们取出了这个参数,并将它展示在了文本中。
|
||||
|
||||
```
|
||||
//打开页面时传递字符串参数
|
||||
Navigator.of(context).pushNamed("second_page", arguments: "Hey");
|
||||
|
||||
class SecondPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//取出路由参数
|
||||
String msg = ModalRoute.of(context).settings.arguments as String;
|
||||
return Text(msg);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
除了页面打开时需要传递参数,对于特定的页面,在其关闭时,也需要传递参数告知页面处理结果。
|
||||
|
||||
比如在电商场景下,我们会在用户把商品加入购物车时,打开登录页面让用户登录,而在登录操作完成之后,关闭登录页面返回到当前页面时,登录页面会告诉当前页面新的用户身份,当前页面则会用新的用户身份刷新页面。
|
||||
|
||||
与Android提供的startActivityForResult方法可以监听目标页面的处理结果类似,Flutter也提供了**返回参数**的机制。在push目标页面时,可以设置目标页面关闭时监听函数,以获取返回参数;而目标页面可以在关闭路由时传递相关参数。
|
||||
|
||||
下面的代码演示了如何获取参数:在SecondPage页面关闭时,传递了一个字符串参数,随后在上一页监听函数中,我们取出了这个参数,并将它展示了出来。
|
||||
|
||||
```
|
||||
class SecondPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: <Widget>[
|
||||
Text('Message from first screen: $msg'),
|
||||
RaisedButton(
|
||||
child: Text('back'),
|
||||
//页面关闭时传递参数
|
||||
onPressed: ()=> Navigator.pop(context,"Hi")
|
||||
)
|
||||
]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
class _FirstPageState extends State<FirstPage> {
|
||||
String _msg='';
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return new Scaffold(
|
||||
body: Column(children: <Widget>[
|
||||
RaisedButton(
|
||||
child: Text('命名路由(参数&回调)'),
|
||||
//打开页面,并监听页面关闭时传递的参数
|
||||
onPressed: ()=> Navigator.pushNamed(context, "third_page",arguments: "Hey").then((msg)=>setState(()=>_msg=msg)),
|
||||
),
|
||||
Text('Message from Second screen: $_msg'),
|
||||
|
||||
],),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
运行一下,可以看到在关闭SecondPage,重新回到FirstPage页面时,FirstPage把接收到的msg参数展示了出来:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/df/90/dfb17d88a9755a0a8bafde69ff1df090.gif" alt="">
|
||||
|
||||
## 总结
|
||||
|
||||
好了,今天的分享就到这里。我们简单回顾一下今天的主要内容吧。
|
||||
|
||||
Flutter提供了基本路由和命名路由两种方式,来管理页面间的跳转。其中,基本路由需要自己手动创建页面实例,通过Navigator.push完成页面跳转;而命名路由需要提前注册页面标识符和页面创建方法,通过Navigator.pushNamed传入标识符实现页面跳转。
|
||||
|
||||
对于命名路由,如果我们需要响应错误路由标识符,还需要一并注册UnknownRoute。为了精细化控制路由切换,Flutter提供了页面打开与页面关闭的参数机制,我们可以在页面创建和目标页面关闭时,取出相应的参数。
|
||||
|
||||
可以看到,关于路由导航,Flutter综合了Android、iOS和React的特点,简洁而不失强大。
|
||||
|
||||
而在中大型应用中,我们通常会使用命名路由来管理页面间的切换。命名路由的最重要作用,就是建立了字符串标识符与各个页面之间的映射关系,使得各个页面之间完全解耦,应用内页面的切换只需要通过一个字符串标识符就可以搞定,为后期模块化打好基础。
|
||||
|
||||
我把今天分享所涉及的的知识点打包到了[GitHub](https://github.com/cyndibaby905/21_router_demo)上,你可以下载工程到本地,多运行几次,从而加深对基本路由、命名路由以及路由参数具体用法的印象。
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我给你留下两个小作业吧。
|
||||
|
||||
1. 对于基本路由,如何传递页面参数?
|
||||
1. 请实现一个计算页面,这个页面可以对前一个页面传入的2个数值参数进行求和,并在该页面关闭时告知上一页面计算的结果。
|
||||
|
||||
欢迎你在评论区给我留言分享你的观点,我会在下一篇文章中等待你!感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user