3.1 Widget简介

3.1.1 概念

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

3.1.2 Widget与Element

在Flutter中,Widget的功能是“描述一个UI元素的配置数据”,它就是说,Widget其实并不是表示最终绘制在设备屏幕上的显示元素,而它只是描述显示元素的一个配置数据。
实际上,Flutter中真正代表屏幕上显示元素的类是Element,也就是说Widget只是描述Element的配置数据!有关Element的详细介绍我们将在本书后面的高级部分深入介绍,现在,读者只需要知道:Widget只是UI元素的一个配置数据,并且一个Widget可以对应多个Element。这是因为同一个Widget对象可以被添加到UI树的不同部分,而真正渲染时,UI树的每一个Element节点都会对应一个Widget对象。总结一下:

  • Widget实际上就是Element的配置数据,Widget树实际上是一个配置树,而真正的UI渲染树是由Element构成;不过,由于Element是通过Widget生成的,所以它们之间有对应关系,在大多数场景,我们可以宽泛地认为Widget树就是指UI控件树或UI渲染树。
  • 一个Widget对象可以对应多个Element对象。这很好理解,根据同一份配置(Widget),可以创建多个实例(Element)。

读者应该将这两点牢记在心中。

3.1.3 Widget主要接口

我们先来看一下Widget类的声明:

  1. @immutable
  2. abstract class Widget extends DiagnosticableTree {
  3. const Widget({ this.key });
  4. final Key key;
  5. @protected
  6. Element createElement();
  7. @override
  8. String toStringShort() {
  9. return key == null ? '$runtimeType' : '$runtimeType-$key';
  10. }
  11. @override
  12. void debugFillProperties(DiagnosticPropertiesBuilder properties) {
  13. super.debugFillProperties(properties);
  14. properties.defaultDiagnosticsTreeStyle = DiagnosticsTreeStyle.dense;
  15. }
  16. static bool canUpdate(Widget oldWidget, Widget newWidget) {
  17. return oldWidget.runtimeType == newWidget.runtimeType
  18. && oldWidget.key == newWidget.key;
  19. }
  20. }
  • Widget类继承自DiagnosticableTreeDiagnosticableTree即“诊断树”,主要作用是提供调试信息。
  • Key: 这个key属性类似于React/Vue中的key,主要的作用是决定是否在下一次build时复用旧的widget,决定的条件在canUpdate()方法中。
  • createElement():正如前文所述“一个Widget可以对应多个Element”;Flutter Framework在构建UI树时,会先调用此方法生成对应节点的Element对象。此方法是Flutter Framework隐式调用的,在我们开发过程中基本不会调用到。
  • debugFillProperties(...) 复写父类的方法,主要是设置诊断树的一些特性。
  • canUpdate(...)是一个静态方法,它主要用于在Widget树重新build时复用旧的widget,其实具体来说,应该是:是否用新的Widget对象去更新旧UI树上所对应的Element对象的配置;通过其源码我们可以看到,只要newWidgetoldWidgetruntimeTypekey同时相等时就会用newWidget去更新Element对象的配置,否则就会创建新的Element

有关Key和Widget复用的细节将会在本书后面高级部分深入讨论,读者现在只需知道,为Widget显式添加key的话可能(但不一定)会使UI在重新构建时变的高效,读者目前可以先忽略此参数。本书后面的示例中,只会在构建列表项UI时会显式指定Key。
另外Widget类本身是一个抽象类,其中最核心的就是定义了createElement()接口,在Flutter开发中,我们一般都不用直接继承Widget类来实现一个新组件,相反,我们通常会通过继承StatelessWidgetStatefulWidget来间接继承Widget类来实现。StatelessWidgetStatefulWidget都是直接继承自Widget类,而这两个类也正是Flutter中非常重要的两个抽象类,它们引入了两种Widget模型,接下来我们将重点介绍一下这两个类。

3.1.4 StatelessWidget

在之前的章节中,我们已经简单介绍过StatelessWidgetStatelessWidget相对比较简单,它继承自Widget类,重写了createElement()方法:

  1. @override
  2. StatelessElement createElement() => new StatelessElement(this);

StatelessElement 间接继承自Element类,与StatelessWidget相对应(作为其配置数据)。
StatelessWidget用于不需要维护状态的场景,它通常在build方法中通过嵌套其它Widget来构建UI,在构建过程中会递归的构建其嵌套的Widget。我们看一个简单的例子:

  1. class Echo extends StatelessWidget {
  2. const Echo({
  3. Key key,
  4. @required this.text,
  5. this.backgroundColor:Colors.grey,
  6. }):super(key:key);
  7. final String text;
  8. final Color backgroundColor;
  9. @override
  10. Widget build(BuildContext context) {
  11. return Center(
  12. child: Container(
  13. color: backgroundColor,
  14. child: Text(text),
  15. ),
  16. );
  17. }
  18. }

上面的代码,实现了一个回显字符串的Echo widget。

按照惯例,widget的构造函数参数应使用命名参数,命名参数中的必要参数要添加@required标注,这样有利于静态代码分析器进行检查。另外,在继承widget时,第一个参数通常应该是Key,另外,如果Widget需要接收子Widget,那么childchildren参数通常应被放在参数列表的最后。同样是按照惯例,Widget的属性应尽可能的被声明为final,防止被意外改变。

然后我们可以通过如下方式使用它:

  1. Widget build(BuildContext context) {
  2. return Echo(text: "hello world");
  3. }

运行后效果如图3-1所示:
3.基础组件 - 图1

Context

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

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

运行后效果如图3-1-1所示:
3.基础组件 - 图2

注意:对于BuildContext读者现在可以先作了解,随着本书后面内容的展开,也会用到Context的一些方法,读者可以通过具体的场景对其有个直观的认识。关于BuildContext更多的内容,我们也将在后面高级部分再深入介绍。

3.1.5 StatefulWidget

StatelessWidget一样,StatefulWidget也是继承自Widget类,并重写了createElement()方法,不同的是返回的Element 对象并不相同;另外StatefulWidget类中添加了一个新的接口createState()
下面我们看看StatefulWidget的类定义:

  1. abstract class StatefulWidget extends Widget {
  2. const StatefulWidget({ Key key }) : super(key: key);
  3. @override
  4. StatefulElement createElement() => new StatefulElement(this);
  5. @protected
  6. State createState();
  7. }
  • StatefulElement 间接继承自Element类,与StatefulWidget相对应(作为其配置数据)。StatefulElement中可能会多次调用createState()来创建状态(State)对象。
  • createState() 用于创建和Stateful widget相关的状态,它在Stateful widget的生命周期中可能会被多次调用。例如,当一个Stateful widget同时插入到widget树的多个位置时,Flutter framework就会调用该方法为每一个位置生成一个独立的State实例,其实,本质上就是一个StatefulElement对应一个State实例。

    在本书中经常会出现“树”的概念,在不同的场景可能指不同的意思,在说“widget树”时它可以指widget结构树,但由于widget与Element有对应关系(一可能对多),在有些场景(Flutter的SDK文档中)也代指“UI树”的意思。而在stateful widget中,State对象也和StatefulElement具有对应关系(一对一),所以在Flutter的SDK文档中,可以经常看到“从树中移除State对象”或“插入State对象到树中”这样的描述。其实,无论哪种描述,其意思都是在描述“一棵构成用户界面的节点元素的树”,读者不必纠结于这些概念,还是那句话“得其神,忘其形”,因此,本书中出现的各种“树”,如果没有特别说明,读者都可抽象的认为它是“一棵构成用户界面的节点元素的树”。

3.1.6 State

一个StatefulWidget类会对应一个State类,State表示与其对应的StatefulWidget要维护的状态,State中的保存的状态信息可以:

  1. 在widget 构建时可以被同步读取。
  2. 在widget生命周期中可以被改变,当State被改变时,可以手动调用其setState()方法通知Flutter framework状态发生改变,Flutter framework在收到消息后,会重新调用其build方法重新构建widget树,从而达到更新UI的目的。

State中有两个常用属性:

  1. widget,它表示与该State实例关联的widget实例,由Flutter framework动态设置。注意,这种关联并非永久的,因为在应用生命周期中,UI树上的某一个节点的widget实例在重新构建时可能会变化,但State实例只会在第一次插入到树中时被创建,当在重新构建时,如果widget被修改了,Flutter framework会动态设置State.widget为新的widget实例。
  2. context。StatefulWidget对应的BuildContext,作用同StatelessWidget的BuildContext。

    State生命周期

    理解State的生命周期对flutter开发非常重要,为了加深读者印象,本节我们通过一个实例来演示一下State的生命周期。在接下来的示例中,我们实现一个计数器widget,点击它可以使计数器加1,由于要保存计数器的数值状态,所以我们应继承StatefulWidget,代码如下:
    1. class CounterWidget extends StatefulWidget {
    2. const CounterWidget({
    3. Key key,
    4. this.initValue: 0
    5. });
    6. final int initValue;
    7. @override
    8. _CounterWidgetState createState() => new _CounterWidgetState();
    9. }
    CounterWidget接收一个initValue整型参数,它表示计数器的初始值。下面我们看一下State的代码:
    1. class _CounterWidgetState extends State<CounterWidget> {
    2. int _counter;
    3. @override
    4. void initState() {
    5. super.initState();
    6. //初始化状态
    7. _counter=widget.initValue;
    8. print("initState");
    9. }
    10. @override
    11. Widget build(BuildContext context) {
    12. print("build");
    13. return Scaffold(
    14. body: Center(
    15. child: FlatButton(
    16. child: Text('$_counter'),
    17. //点击后计数器自增
    18. onPressed:()=>setState(()=> ++_counter,
    19. ),
    20. ),
    21. ),
    22. );
    23. }
    24. @override
    25. void didUpdateWidget(CounterWidget oldWidget) {
    26. super.didUpdateWidget(oldWidget);
    27. print("didUpdateWidget");
    28. }
    29. @override
    30. void deactivate() {
    31. super.deactivate();
    32. print("deactive");
    33. }
    34. @override
    35. void dispose() {
    36. super.dispose();
    37. print("dispose");
    38. }
    39. @override
    40. void reassemble() {
    41. super.reassemble();
    42. print("reassemble");
    43. }
    44. @override
    45. void didChangeDependencies() {
    46. super.didChangeDependencies();
    47. print("didChangeDependencies");
    48. }
    49. }
    接下来,我们创建一个新路由,在新路由中,我们只显示一个CounterWidget
    1. Widget build(BuildContext context) {
    2. return CounterWidget();
    3. }
    我们运行应用并打开该路由页面,在新路由页打开后,屏幕中央就会出现一个数字0,然后控制台日志输出:
    1. I/flutter ( 5436): initState
    2. I/flutter ( 5436): didChangeDependencies
    3. I/flutter ( 5436): build
    可以看到,在StatefulWidget插入到Widget树时首先initState方法会被调用。
    然后我们点击⚡️按钮热重载,控制台输出日志如下:
    1. I/flutter ( 5436): reassemble
    2. I/flutter ( 5436): didUpdateWidget
    3. I/flutter ( 5436): build
    可以看到此时initStatedidChangeDependencies都没有被调用,而此时didUpdateWidget被调用。
    接下来,我们在widget树中移除CounterWidget,将路由build方法改为:
    1. Widget build(BuildContext context) {
    2. //移除计数器
    3. //return CounterWidget();
    4. //随便返回一个Text()
    5. return Text("xxx");
    6. }
    然后热重载,日志如下:
    1. I/flutter ( 5436): reassemble
    2. I/flutter ( 5436): deactive
    3. I/flutter ( 5436): dispose
    我们可以看到,在CounterWidget从widget树中移除时,deactivedispose会依次被调用。
    下面我们来看看各个回调函数:
  • initState:当Widget第一次插入到Widget树时会被调用,对于每一个State对象,Flutter framework只会调用一次该回调,所以,通常在该回调中做一些一次性的操作,如状态初始化、订阅子树的事件通知等。不能在该回调中调用BuildContext.dependOnInheritedWidgetOfExactType(该方法用于在Widget树上获取离当前widget最近的一个父级InheritFromWidget,关于InheritedWidget我们将在后面章节介绍),原因是在初始化完成后,Widget树中的InheritFromWidget也可能会发生变化,所以正确的做法应该在在build()方法或didChangeDependencies()中调用它。
  • didChangeDependencies():当State对象的依赖发生变化时会被调用;例如:在之前build() 中包含了一个InheritedWidget,然后在之后的build()InheritedWidget发生了变化,那么此时InheritedWidget的子widget的didChangeDependencies()回调都会被调用。典型的场景是当系统语言Locale或应用主题改变时,Flutter framework会通知widget调用此回调。
  • build():此回调读者现在应该已经相当熟悉了,它主要是用于构建Widget子树的,会在如下场景被调用:
    1. 在调用initState()之后。
    2. 在调用didUpdateWidget()之后。
    3. 在调用setState()之后。
    4. 在调用didChangeDependencies()之后。
    5. 在State对象从树中一个位置移除后(会调用deactivate)又重新插入到树的其它位置之后。
  • reassemble():此回调是专门为了开发调试而提供的,在热重载(hot reload)时会被调用,此回调在Release模式下永远不会被调用。
  • didUpdateWidget():在widget重新构建时,Flutter framework会调用Widget.canUpdate来检测Widget树中同一位置的新旧节点,然后决定是否需要更新,如果Widget.canUpdate返回true则会调用此回调。正如之前所述,Widget.canUpdate会在新旧widget的key和runtimeType同时相等时会返回true,也就是说在在新旧widget的key和runtimeType同时相等时didUpdateWidget()就会被调用。
  • deactivate():当State对象从树中被移除时,会调用此回调。在一些场景下,Flutter framework会将State对象重新插到树中,如包含此State对象的子树在树的一个位置移动到另一个位置时(可以通过GlobalKey来实现)。如果移除后没有重新插入到树中则紧接着会调用dispose()方法。
  • dispose():当State对象从树中被永久移除时调用;通常在此回调中释放资源。

StatefulWidget生命周期如图3-2所示:
3.基础组件 - 图3

注意:在继承StatefulWidget重写其方法时,对于包含@mustCallSuper标注的父类方法,都要在子类方法中先调用父类方法。

为什么要将build方法放在State中,而不是放在StatefulWidget中?

现在,我们回答之前提出的问题,为什么build()方法放在State(而不是StatefulWidget)中 ?这主要是为了提高开发的灵活性。如果将build()方法在StatefulWidget中则会有两个问题:

  • 状态访问不便。
    试想一下,如果我们的StatefulWidget有很多状态,而每次状态改变都要调用build方法,由于状态是保存在State中的,如果build方法在StatefulWidget中,那么build方法和状态分别在两个类中,那么构建时读取状态将会很不方便!试想一下,如果真的将build方法放在StatefulWidget中的话,由于构建用户界面过程需要依赖State,所以build方法将必须加一个State参数,大概是下面这样:

    1. Widget build(BuildContext context, State state){
    2. //state.counter
    3. ...
    4. }
  • 这样的话就只能将State的所有状态声明为公开的状态,这样才能在State类外部访问状态!但是,将状态设置为公开后,状态将不再具有私密性,这就会导致对状态的修改将会变的不可控。但如果将build()方法放在State中的话,构建过程不仅可以直接访问状态,而且也无需公开私有状态,这会非常方便。

  • 继承StatefulWidget不便。例如,Flutter中有一个动画widget的基类AnimatedWidget,它继承自StatefulWidget类。AnimatedWidget中引入了一个抽象方法build(BuildContext context),继承自AnimatedWidget的动画widget都要实现这个build方法。现在设想一下,如果StatefulWidget类中已经有了一个build方法,正如上面所述,此时build方法需要接收一个state对象,这就意味着AnimatedWidget必须将自己的State对象(记为_animatedWidgetState)提供给其子类,因为子类需要在其build方法中调用父类的build方法,代码可能如下:

    1. class MyAnimationWidget extends AnimatedWidget{
    2. @override
    3. Widget build(BuildContext context, State state){
    4. //由于子类要用到AnimatedWidget的状态对象_animatedWidgetState,
    5. //所以AnimatedWidget必须通过某种方式将其状态对象_animatedWidgetState
    6. //暴露给其子类
    7. super.build(context, _animatedWidgetState)
    8. }
    9. }
  • 这样很显然是不合理的,因为

    1. AnimatedWidget的状态对象是AnimatedWidget内部实现细节,不应该暴露给外部。
    2. 如果要将父类状态暴露给子类,那么必须得有一种传递机制,而做这一套传递机制是无意义的,因为父子类之间状态的传递和子类本身逻辑是无关的。

综上所述,可以发现,对于StatefulWidget,将build方法放在State中,可以给开发带来很大的灵活性。

3.1.7 在Widget树中获取State对象

由于StatefulWidget的的具体逻辑都在其State中,所以很多时候,我们需要获取StatefulWidget对应的State对象来调用一些方法,比如Scaffold组件对应的状态类ScaffoldState中就定义了打开SnackBar(路由页底部提示条)的方法。我们有两种方法在子widget树中获取父级StatefulWidget的State对象。

通过Context获取

context对象有一个findAncestorStateOfType()方法,该方法可以从当前节点沿着widget树向上查找指定类型的StatefulWidget对应的State对象。下面是实现打开SnackBar的示例:

  1. Scaffold(
  2. appBar: AppBar(
  3. title: Text("子树中获取State对象"),
  4. ),
  5. body: Center(
  6. child: Builder(builder: (context) {
  7. return RaisedButton(
  8. onPressed: () {
  9. // 查找父级最近的Scaffold对应的ScaffoldState对象
  10. ScaffoldState _state = context.findAncestorStateOfType<ScaffoldState>();
  11. //调用ScaffoldState的showSnackBar来弹出SnackBar
  12. _state.showSnackBar(
  13. SnackBar(
  14. content: Text("我是SnackBar"),
  15. ),
  16. );
  17. },
  18. child: Text("显示SnackBar"),
  19. );
  20. }),
  21. ),
  22. );

上面示例运行后,点击”显示SnackBar“,效果如图3-1-2所示:
3.基础组件 - 图4
一般来说,如果StatefulWidget的状态是私有的(不应该向外部暴露),那么我们代码中就不应该去直接获取其State对象;如果StatefulWidget的状态是希望暴露出的(通常还有一些组件的操作方法),我们则可以去直接获取其State对象。但是通过context.findAncestorStateOfType获取StatefulWidget的状态的方法是通用的,我们并不能在语法层面指定StatefulWidget的状态是否私有,所以在Flutter开发中便有了一个默认的约定:如果StatefulWidget的状态是希望暴露出的,应当在StatefulWidget中提供一个of静态方法来获取其State对象,开发者便可直接通过该方法来获取;如果State不希望暴露,则不提供of方法。这个约定在Flutter SDK里随处可见。所以,上面示例中的Scaffold也提供了一个of方法,我们其实是可以直接调用它的:

  1. ...//省略无关代码
  2. // 直接通过of静态方法来获取ScaffoldState
  3. ScaffoldState _state=Scaffold.of(context);
  4. _state.showSnackBar(
  5. SnackBar(
  6. content: Text("我是SnackBar"),
  7. ),
  8. );

通过GlobalKey

Flutter还有一种通用的获取State对象的方法——通过GlobalKey来获取! 步骤分两步:

  1. 给目标StatefulWidget添加GlobalKey

    1. //定义一个globalKey, 由于GlobalKey要保持全局唯一性,我们使用静态变量存储
    2. static GlobalKey<ScaffoldState> _globalKey= GlobalKey();
    3. ...
    4. Scaffold(
    5. key: _globalKey , //设置key
    6. ...
    7. )
  2. 通过GlobalKey来获取State对象

    1. _globalKey.currentState.openDrawer()

    GlobalKey是Flutter提供的一种在整个APP中引用element的机制。如果一个widget设置了GlobalKey,那么我们便可以通过globalKey.currentWidget获得该widget对象、globalKey.currentElement来获得widget对应的element对象,如果当前widget是StatefulWidget,则可以通过globalKey.currentState来获得该widget对应的state对象。

    注意:使用GlobalKey开销较大,如果有其他可选方案,应尽量避免使用它。另外同一个GlobalKey在整个widget树中必须是唯一的,不能重复。

3.1.8 Flutter SDK内置组件库介绍

Flutter提供了一套丰富、强大的基础组件,在基础组件库之上Flutter又提供了一套Material风格(Android默认的视觉风格)和一套Cupertino风格(iOS视觉风格)的组件库。要使用基础组件库,需要先导入:

  1. import 'package:flutter/widgets.dart';

下面我们介绍一下常用的组件。

基础组件

  • Text:该组件可让您创建一个带格式的文本。
  • RowColumn: 这些具有弹性空间的布局类Widget可让您在水平(Row)和垂直(Column)方向上创建灵活的布局。其设计是基于Web开发中的Flexbox布局模型。
  • Stack: 取代线性布局 (译者语:和Android中的FrameLayout相似),Stack允许子 widget 堆叠, 你可以使用 Positioned 来定位他们相对于Stack的上下左右四条边的位置。Stacks是基于Web开发中的绝对定位(absolute positioning )布局模型设计的。
  • ContainerContainer 可让您创建矩形视觉元素。container 可以装饰一个BoxDecoration, 如 background、一个边框、或者一个阴影。 Container 也可以具有边距(margins)、填充(padding)和应用于其大小的约束(constraints)。另外, Container可以使用矩阵在三维空间中对其进行变换。

    Material组件

    Flutter提供了一套丰富的Material组件,它可以帮助我们构建遵循Material Design设计规范的应用程序。Material应用程序以MaterialApp 组件开始, 该组件在应用程序的根部创建了一些必要的组件,比如Theme组件,它用于配置应用的主题。 是否使用MaterialApp完全是可选的,但是使用它是一个很好的做法。在之前的示例中,我们已经使用过多个Material 组件了,如:ScaffoldAppBarFlatButton等。要使用Material 组件,需要先引入它:

    1. import 'package:flutter/material.dart';

    Cupertino组件

    Flutter也提供了一套丰富的Cupertino风格的组件,尽管目前还没有Material 组件那么丰富,但是它仍在不断的完善中。值得一提的是在Material 组件库中有一些组件可以根据实际运行平台来切换表现风格,比如MaterialPageRoute,在路由切换时,如果是Android系统,它将会使用Android系统默认的页面切换动画(从底向上);如果是iOS系统,它会使用iOS系统默认的页面切换动画(从右向左)。由于在前面的示例中还没有Cupertino组件的示例,下面我们实现一个简单的Cupertino组件风格的页面:

    1. //导入cupertino widget库
    2. import 'package:flutter/cupertino.dart';
    3. class CupertinoTestRoute extends StatelessWidget {
    4. @override
    5. Widget build(BuildContext context) {
    6. return CupertinoPageScaffold(
    7. navigationBar: CupertinoNavigationBar(
    8. middle: Text("Cupertino Demo"),
    9. ),
    10. child: Center(
    11. child: CupertinoButton(
    12. color: CupertinoColors.activeBlue,
    13. child: Text("Press"),
    14. onPressed: () {}
    15. ),
    16. ),
    17. );
    18. }
    19. }

    下面(图3-3)是在iPhoneX上页面效果截图:
    3.基础组件 - 图5

    关于示例

    本章后面章节的示例中会使用一些布局类组件,如ScaffoldRowColumn等,这些组件将在后面“布局类组件”一章中详细介绍,读者可以先不用关注。

    总结

    Flutter提供了丰富的组件,在实际的开发中你可以根据需要随意使用它们,而不必担心引入过多组件库会让你的应用安装包变大,这不是web开发,dart在编译时只会编译你使用了的代码。由于Material和Cupertino都是在基础组件库之上的,所以如果我们的应用中引入了这两者之一,则不需要再引入flutter/widgets.dart了,因为它们内部已经引入过了。

    3.2 状态管理

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

  • Widget管理自己的状态。

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

如何决定使用哪种管理方法?下面是官方给出的一些原则可以帮助你做决定:

  • 如果状态是用户数据,如复选框的选中状态、滑块的位置,则该状态最好由父Widget管理。
  • 如果状态是有关界面外观效果的,例如颜色、动画,那么状态最好由Widget本身来管理。
  • 如果某一个状态是不同Widget共享的则最好由它们共同的父Widget管理。

在Widget内部管理状态封装性会好一些,而在父Widget中管理会比较灵活。有些时候,如果不确定到底该怎么管理状态,那么推荐的首选是在父widget中管理(灵活会显得更重要一些)。
接下来,我们将通过创建三个简单示例TapboxA、TapboxB和TapboxC来说明管理状态的不同方式。 这些例子功能是相似的 ——创建一个盒子,当点击它时,盒子背景会在绿色与灰色之间切换。状态 _active确定颜色:绿色为true ,灰色为false,如图3-4所示。3.基础组件 - 图6
下面的例子将使用GestureDetector来识别点击事件,关于该GestureDetector的详细内容我们将在后面“事件处理”一章中介绍。

3.2.1 Widget管理自身状态

_TapboxAState 类:

  • 管理TapboxA的状态。
  • 定义_active:确定盒子的当前颜色的布尔值。
  • 定义_handleTap()函数,该函数在点击该盒子时更新_active,并调用setState()更新UI。
  • 实现widget的所有交互式行为。

    1. // TapboxA 管理自身状态.
    2. //------------------------- TapboxA ----------------------------------
    3. class TapboxA extends StatefulWidget {
    4. TapboxA({Key key}) : super(key: key);
    5. @override
    6. _TapboxAState createState() => new _TapboxAState();
    7. }
    8. class _TapboxAState extends State<TapboxA> {
    9. bool _active = false;
    10. void _handleTap() {
    11. setState(() {
    12. _active = !_active;
    13. });
    14. }
    15. Widget build(BuildContext context) {
    16. return new GestureDetector(
    17. onTap: _handleTap,
    18. child: new Container(
    19. child: new Center(
    20. child: new Text(
    21. _active ? 'Active' : 'Inactive',
    22. style: new TextStyle(fontSize: 32.0, color: Colors.white),
    23. ),
    24. ),
    25. width: 200.0,
    26. height: 200.0,
    27. decoration: new BoxDecoration(
    28. color: _active ? Colors.lightGreen[700] : Colors.grey[600],
    29. ),
    30. ),
    31. );
    32. }
    33. }

    3.2.2 父Widget管理子Widget的状态

    对于父Widget来说,管理状态并告诉其子Widget何时更新通常是比较好的方式。 例如,IconButton是一个图标按钮,但它是一个无状态的Widget,因为我们认为父Widget需要知道该按钮是否被点击来采取相应的处理。
    在以下示例中,TapboxB通过回调将其状态导出到其父组件,状态由父组件管理,因此它的父组件为StatefulWidget。但是由于TapboxB不管理任何状态,所以TapboxBStatelessWidget
    ParentWidgetState 类:

  • 为TapboxB 管理_active状态。

  • 实现_handleTapboxChanged(),当盒子被点击时调用的方法。
  • 当状态改变时,调用setState()更新UI。

TapboxB 类:

  • 继承StatelessWidget类,因为所有状态都由其父组件处理。
  • 当检测到点击时,它会通知父组件。

    1. // ParentWidget 为 TapboxB 管理状态.
    2. //------------------------ ParentWidget --------------------------------
    3. class ParentWidget extends StatefulWidget {
    4. @override
    5. _ParentWidgetState createState() => new _ParentWidgetState();
    6. }
    7. class _ParentWidgetState extends State<ParentWidget> {
    8. bool _active = false;
    9. void _handleTapboxChanged(bool newValue) {
    10. setState(() {
    11. _active = newValue;
    12. });
    13. }
    14. @override
    15. Widget build(BuildContext context) {
    16. return new Container(
    17. child: new TapboxB(
    18. active: _active,
    19. onChanged: _handleTapboxChanged,
    20. ),
    21. );
    22. }
    23. }
    24. //------------------------- TapboxB ----------------------------------
    25. class TapboxB extends StatelessWidget {
    26. TapboxB({Key key, this.active: false, @required this.onChanged})
    27. : super(key: key);
    28. final bool active;
    29. final ValueChanged<bool> onChanged;
    30. void _handleTap() {
    31. onChanged(!active);
    32. }
    33. Widget build(BuildContext context) {
    34. return new GestureDetector(
    35. onTap: _handleTap,
    36. child: new Container(
    37. child: new Center(
    38. child: new Text(
    39. active ? 'Active' : 'Inactive',
    40. style: new TextStyle(fontSize: 32.0, color: Colors.white),
    41. ),
    42. ),
    43. width: 200.0,
    44. height: 200.0,
    45. decoration: new BoxDecoration(
    46. color: active ? Colors.lightGreen[700] : Colors.grey[600],
    47. ),
    48. ),
    49. );
    50. }
    51. }

    3.2.3 混合状态管理

    对于一些组件来说,混合管理的方式会非常有用。在这种情况下,组件自身管理一些内部状态,而父组件管理一些其他外部状态。
    在下面TapboxC示例中,手指按下时,盒子的周围会出现一个深绿色的边框,抬起时,边框消失。点击完成后,盒子的颜色改变。 TapboxC将其_active状态导出到其父组件中,但在内部管理其_highlight状态。这个例子有两个状态对象_ParentWidgetState_TapboxCState
    _ParentWidgetStateC类:

  • 管理_active 状态。

  • 实现 _handleTapboxChanged() ,当盒子被点击时调用。
  • 当点击盒子并且_active状态改变时调用setState()更新UI。

_TapboxCState 对象:

  • 管理_highlight 状态。
  • GestureDetector监听所有tap事件。当用户点下时,它添加高亮(深绿色边框);当用户释放时,会移除高亮。
  • 当按下、抬起、或者取消点击时更新_highlight状态,调用setState()更新UI。
  • 当点击时,将状态的改变传递给父组件。
    1. //---------------------------- ParentWidget ----------------------------
    2. class ParentWidgetC extends StatefulWidget {
    3. @override
    4. _ParentWidgetCState createState() => new _ParentWidgetCState();
    5. }
    6. class _ParentWidgetCState extends State<ParentWidgetC> {
    7. bool _active = false;
    8. void _handleTapboxChanged(bool newValue) {
    9. setState(() {
    10. _active = newValue;
    11. });
    12. }
    13. @override
    14. Widget build(BuildContext context) {
    15. return new Container(
    16. child: new TapboxC(
    17. active: _active,
    18. onChanged: _handleTapboxChanged,
    19. ),
    20. );
    21. }
    22. }
    23. //----------------------------- TapboxC ------------------------------
    24. class TapboxC extends StatefulWidget {
    25. TapboxC({Key key, this.active: false, @required this.onChanged})
    26. : super(key: key);
    27. final bool active;
    28. final ValueChanged<bool> onChanged;
    29. @override
    30. _TapboxCState createState() => new _TapboxCState();
    31. }
    32. class _TapboxCState extends State<TapboxC> {
    33. bool _highlight = false;
    34. void _handleTapDown(TapDownDetails details) {
    35. setState(() {
    36. _highlight = true;
    37. });
    38. }
    39. void _handleTapUp(TapUpDetails details) {
    40. setState(() {
    41. _highlight = false;
    42. });
    43. }
    44. void _handleTapCancel() {
    45. setState(() {
    46. _highlight = false;
    47. });
    48. }
    49. void _handleTap() {
    50. widget.onChanged(!widget.active);
    51. }
    52. @override
    53. Widget build(BuildContext context) {
    54. // 在按下时添加绿色边框,当抬起时,取消高亮
    55. return new GestureDetector(
    56. onTapDown: _handleTapDown, // 处理按下事件
    57. onTapUp: _handleTapUp, // 处理抬起事件
    58. onTap: _handleTap,
    59. onTapCancel: _handleTapCancel,
    60. child: new Container(
    61. child: new Center(
    62. child: new Text(widget.active ? 'Active' : 'Inactive',
    63. style: new TextStyle(fontSize: 32.0, color: Colors.white)),
    64. ),
    65. width: 200.0,
    66. height: 200.0,
    67. decoration: new BoxDecoration(
    68. color: widget.active ? Colors.lightGreen[700] : Colors.grey[600],
    69. border: _highlight
    70. ? new Border.all(
    71. color: Colors.teal[700],
    72. width: 10.0,
    73. )
    74. : null,
    75. ),
    76. ),
    77. );
    78. }
    79. }
    另一种实现可能会将高亮状态导出到父组件,但同时保持_active状态为内部状态,但如果你要将该TapBox给其它人使用,可能没有什么意义。 开发人员只会关心该框是否处于Active状态,而不在乎高亮显示是如何管理的,所以应该让TapBox内部处理这些细节。

    3.2.4 全局状态管理

    当应用中需要一些跨组件(包括跨路由)的状态需要同步时,上面介绍的方法便很难胜任了。比如,我们有一个设置页,里面可以设置应用的语言,我们为了让设置实时生效,我们期望在语言状态发生改变时,APP中依赖应用语言的组件能够重新build一下,但这些依赖应用语言的组件和设置页并不在一起,所以这种情况用上面的方法很难管理。这时,正确的做法是通过一个全局状态管理器来处理这种相距较远的组件之间的通信。目前主要有两种办法:
  1. 实现一个全局的事件总线,将语言状态改变对应为一个事件,然后在APP中依赖应用语言的组件的initState 方法中订阅语言改变的事件。当用户在设置页切换语言后,我们发布语言改变事件,而订阅了此事件的组件就会收到通知,收到通知后调用setState(...)方法重新build一下自身即可。
  2. 使用一些专门用于状态管理的包,如Provider、Redux,读者可以在pub上查看其详细信息。

本书将在”功能型组件”一章中介绍Provider包的实现原理及用法,同时也将会在”事件处理与通知”一章中实现一个全局事件总线,读者有需要可以直接翻看。

3.3 文本及样式

3.3.1 Text

Text用于显示简单样式文本,它包含一些控制文本显示样式的一些属性,一个简单的例子如下:

  1. Text("Hello world",
  2. textAlign: TextAlign.left,
  3. );
  4. Text("Hello world! I'm Jack. "*4,
  5. maxLines: 1,
  6. overflow: TextOverflow.ellipsis,
  7. );
  8. Text("Hello world",
  9. textScaleFactor: 1.5,
  10. );

运行效果如图3-5所示:
3.基础组件 - 图7

  • textAlign:文本的对齐方式;可以选择左对齐、右对齐还是居中。注意,对齐的参考系是Text widget本身。本例中虽然是指定了居中对齐,但因为Text文本内容宽度不足一行,Text的宽度和文本内容长度相等,那么这时指定对齐方式是没有意义的,只有Text宽度大于文本内容长度时指定此属性才有意义。下面我们指定一个较长的字符串:

    1. Text("Hello world "*6, //字符串重复六次
    2. textAlign: TextAlign.center,
    3. );
  • 运行效果如图3-6所示:
    3.基础组件 - 图8

    字符串内容超过一行,Text宽度等于屏幕宽度,第二行文本便会居中显示。

  • maxLinesoverflow:指定文本显示的最大行数,默认情况下,文本是自动折行的,如果指定此参数,则文本最多不会超过指定的行。如果有多余的文本,可以通过overflow来指定截断方式,默认是直接截断,本例中指定的截断方式TextOverflow.ellipsis,它会将多余文本截断后以省略符“…”表示;TextOverflow的其它截断方式请参考SDK文档。

  • textScaleFactor:代表文本相对于当前字体大小的缩放因子,相对于去设置文本的样式style属性的fontSize,它是调整字体大小的一个快捷方式。该属性的默认值可以通过MediaQueryData.textScaleFactor获得,如果没有MediaQuery,那么会默认值将为1.0。

    3.3.2 TextStyle

    TextStyle用于指定文本显示的样式如颜色、字体、粗细、背景等。我们看一个示例:

    1. Text("Hello world",
    2. style: TextStyle(
    3. color: Colors.blue,
    4. fontSize: 18.0,
    5. height: 1.2,
    6. fontFamily: "Courier",
    7. background: new Paint()..color=Colors.yellow,
    8. decoration:TextDecoration.underline,
    9. decorationStyle: TextDecorationStyle.dashed
    10. ),
    11. );

    效果如图3-7所示:
    3.基础组件 - 图9
    此示例只展示了TextStyle的部分属性,它还有一些其它属性,属性名基本都是自解释的,在此不再赘述,读者可以查阅SDK文档。值得注意的是:

  • height:该属性用于指定行高,但它并不是一个绝对值,而是一个因子,具体的行高等于fontSize*height

  • fontFamily :由于不同平台默认支持的字体集不同,所以在手动指定字体时一定要先在不同平台测试一下。
  • fontSize:该属性和Text的textScaleFactor都用于控制字体大小。但是有两个主要区别:

    • fontSize可以精确指定字体大小,而textScaleFactor只能通过缩放比例来控制。
    • textScaleFactor主要是用于系统字体大小设置改变时对Flutter应用字体进行全局调整,而fontSize通常用于单个文本,字体大小不会跟随系统字体大小变化。

      3.3.3 TextSpan

      在上面的例子中,Text的所有文本内容只能按同一种样式,如果我们需要对一个Text内容的不同部分按照不同的样式显示,这时就可以使用TextSpan,它代表文本的一个“片段”。我们看看TextSpan的定义:
      1. const TextSpan({
      2. TextStyle style,
      3. Sting text,
      4. List<TextSpan> children,
      5. GestureRecognizer recognizer,
      6. });
      其中styletext属性代表该文本片段的样式和内容。 children是一个TextSpan的数组,也就是说TextSpan可以包括其他TextSpan。而recognizer用于对该文本片段上用于手势进行识别处理。下面我们看一个效果(图3-8),然后用TextSpan实现它。
      3.基础组件 - 图10
      源码:
      1. Text.rich(TextSpan(
      2. children: [
      3. TextSpan(
      4. text: "Home: "
      5. ),
      6. TextSpan(
      7. text: "https://flutterchina.club",
      8. style: TextStyle(
      9. color: Colors.blue
      10. ),
      11. recognizer: _tapRecognizer
      12. ),
      13. ]
      14. ))
  • 上面代码中,我们通过TextSpan实现了一个基础文本片段和一个链接片段,然后通过Text.rich 方法将TextSpan 添加到Text中,之所以可以这样做,是因为Text其实就是RichText的一个包装,而RichText是可以显示多种样式(富文本)的widget。

  • _tapRecognizer,它是点击链接后的一个处理器(代码已省略),关于手势识别的更多内容我们将在后面单独介绍。

    3.3.4 DefaultTextStyle

    在Widget树中,文本的样式默认是可以被继承的(子类文本类组件未指定具体样式时可以使用Widget树中父级设置的默认样式),因此,如果在Widget树的某一个节点处设置一个默认的文本样式,那么该节点的子树中所有文本都会默认使用这个样式,而DefaultTextStyle正是用于设置默认文本样式的。下面我们看一个例子:
    1. DefaultTextStyle(
    2. //1.设置文本默认样式
    3. style: TextStyle(
    4. color:Colors.red,
    5. fontSize: 20.0,
    6. ),
    7. textAlign: TextAlign.start,
    8. child: Column(
    9. crossAxisAlignment: CrossAxisAlignment.start,
    10. children: <Widget>[
    11. Text("hello world"),
    12. Text("I am Jack"),
    13. Text("I am Jack",
    14. style: TextStyle(
    15. inherit: false, //2.不继承默认样式
    16. color: Colors.grey
    17. ),
    18. ),
    19. ],
    20. ),
    21. );
    上面代码中,我们首先设置了一个默认的文本样式,即字体为20像素(逻辑像素)、颜色为红色。然后通过DefaultTextStyle 设置给了子树Column节点处,这样一来Column的所有子孙Text默认都会继承该样式,除非Text显示指定不继承样式,如代码中注释2。示例运行效果如图3-9:
    3.基础组件 - 图11

    3.3.5 字体

    可以在Flutter应用程序中使用不同的字体。例如,我们可能会使用设计人员创建的自定义字体,或者其它第三方的字体,如Google Fonts中的字体。本节将介绍如何为Flutter应用配置字体,并在渲染文本时使用它们。
    在Flutter中使用字体分两步完成。首先在pubspec.yaml中声明它们,以确保它们会打包到应用程序中。然后通过TextStyle属性使用字体。

    在asset中声明

    要将字体文件打包到应用中,和使用其它资源一样,要先在pubspec.yaml中声明它。然后将字体文件复制到在pubspec.yaml中指定的位置。如:
    1. flutter:
    2. fonts:
    3. - family: Raleway
    4. fonts:
    5. - asset: assets/fonts/Raleway-Regular.ttf
    6. - asset: assets/fonts/Raleway-Medium.ttf
    7. weight: 500
    8. - asset: assets/fonts/Raleway-SemiBold.ttf
    9. weight: 600
    10. - family: AbrilFatface
    11. fonts:
    12. - asset: assets/fonts/abrilfatface/AbrilFatface-Regular.ttf

    使用字体

    1. // 声明文本样式
    2. const textStyle = const TextStyle(
    3. fontFamily: 'Raleway',
    4. );
    5. // 使用文本样式
    6. var buttonText = const Text(
    7. "Use the font for this text",
    8. style: textStyle,
    9. );

    Package中的字体

    要使用Package中定义的字体,必须提供package参数。例如,假设上面的字体声明位于my_package包中。然后创建TextStyle的过程如下:
    1. const textStyle = const TextStyle(
    2. fontFamily: 'Raleway',
    3. package: 'my_package', //指定包名
    4. );
    如果在package包内部使用它自己定义的字体,也应该在创建文本样式时指定package参数,如上例所示。
    一个包也可以只提供字体文件而不需要在pubspec.yaml中声明。 这些文件应该存放在包的lib/文件夹中。字体文件不会自动绑定到应用程序中,应用程序可以在声明字体时有选择地使用这些字体。假设一个名为my_package的包中有一个字体文件:
    1. lib/fonts/Raleway-Medium.ttf
    然后,应用程序可以声明一个字体,如下面的示例所示:
    1. flutter:
    2. fonts:
    3. - family: Raleway
    4. fonts:
    5. - asset: assets/fonts/Raleway-Regular.ttf
    6. - asset: packages/my_package/fonts/Raleway-Medium.ttf
    7. weight: 500
    lib/是隐含的,所以它不应该包含在asset路径中。
    在这种情况下,由于应用程序本地定义了字体,所以在创建TextStyle时可以不指定package参数:
    1. const textStyle = const TextStyle(
    2. fontFamily: 'Raleway',
    3. );

    3.4 按钮

    3.4.1 Material组件库中的按钮

    Material 组件库中提供了多种按钮组件如RaisedButtonFlatButtonOutlineButton等,它们都是直接或间接对RawMaterialButton组件的包装定制,所以他们大多数属性都和RawMaterialButton一样。在介绍各个按钮时我们先介绍其默认外观,而按钮的外观大都可以通过属性来自定义,我们在后面统一介绍这些属性。另外,所有Material 库中的按钮都有如下相同点:
  1. 按下时都会有“水波动画”(又称“涟漪动画”,就是点击时按钮上会出现水波荡漾的动画)。
  2. 有一个onPressed属性来设置点击回调,当按钮按下时会执行该回调,如果不提供该回调则按钮会处于禁用状态,禁用状态不响应用户点击。

    RaisedButton

    RaisedButton 即”漂浮”按钮,它默认带有阴影和灰色背景。按下后,阴影会变大,如图3-10所示:
    3.基础组件 - 图12
    使用RaisedButton非常简单,如:

    1. RaisedButton(
    2. child: Text("normal"),
    3. onPressed: () {},
    4. );

    FlatButton

    FlatButton即扁平按钮,默认背景透明并不带阴影。按下后,会有背景色,如图3-11所示:
    3.基础组件 - 图13
    使用FlatButton也很简单,代码如下:

    1. FlatButton(
    2. child: Text("normal"),
    3. onPressed: () {},
    4. )

    OutlineButton

    OutlineButton默认有一个边框,不带阴影且背景透明。按下后,边框颜色会变亮、同时出现背景和阴影(较弱),如图3-12所示:
    3.基础组件 - 图14
    使用OutlineButton也很简单,代码如下:

    1. OutlineButton(
    2. child: Text("normal"),
    3. onPressed: () {},
    4. )

    IconButton

    IconButton是一个可点击的Icon,不包括文字,默认没有背景,点击后会出现背景,如图3-13所示:
    3.基础组件 - 图15
    代码如下:

    1. IconButton(
    2. icon: Icon(Icons.thumb_up),
    3. onPressed: () {},
    4. )

    带图标的按钮

    RaisedButtonFlatButtonOutlineButton都有一个icon 构造函数,通过它可以轻松创建带图标的按钮,如图3-14所示:
    3.基础组件 - 图16
    代码如下:

    1. RaisedButton.icon(
    2. icon: Icon(Icons.send),
    3. label: Text("发送"),
    4. onPressed: _onPressed,
    5. ),
    6. OutlineButton.icon(
    7. icon: Icon(Icons.add),
    8. label: Text("添加"),
    9. onPressed: _onPressed,
    10. ),
    11. FlatButton.icon(
    12. icon: Icon(Icons.info),
    13. label: Text("详情"),
    14. onPressed: _onPressed,
    15. ),

    3.4.2 自定义按钮外观

    按钮外观可以通过其属性来定义,不同按钮属性大同小异,我们以FlatButton为例,介绍一下常见的按钮属性,详细的信息可以查看API文档。

    1. const FlatButton({
    2. ...
    3. @required this.onPressed, //按钮点击回调
    4. this.textColor, //按钮文字颜色
    5. this.disabledTextColor, //按钮禁用时的文字颜色
    6. this.color, //按钮背景颜色
    7. this.disabledColor,//按钮禁用时的背景颜色
    8. this.highlightColor, //按钮按下时的背景颜色
    9. this.splashColor, //点击时,水波动画中水波的颜色
    10. this.colorBrightness,//按钮主题,默认是浅色主题
    11. this.padding, //按钮的填充
    12. this.shape, //外形
    13. @required this.child, //按钮的内容
    14. })

    其中大多数属性名都是自解释的,我们不赘述。下面我们通过一个示例来看看如何自定义按钮。

    示例

    定义一个背景蓝色,两边圆角的按钮。效果如图3-15所示:
    3.基础组件 - 图17
    代码如下:

    1. FlatButton(
    2. color: Colors.blue,
    3. highlightColor: Colors.blue[700],
    4. colorBrightness: Brightness.dark,
    5. splashColor: Colors.grey,
    6. child: Text("Submit"),
    7. shape:RoundedRectangleBorder(borderRadius: BorderRadius.circular(20.0)),
    8. onPressed: () {},
    9. )

    很简单吧,在上面的代码中,我们主要通过shape来指定其外形为一个圆角矩形。因为按钮背景是蓝色(深色),我们需要指定按钮主题colorBrightnessBrightness.dark,这是为了保证按钮文字颜色为浅色。
    Flutter 中没有提供去除背景的设置,假若我们需要去除背景,则可以通过将背景颜色设置为全透明来实现。对应上面的代码,便是将 color: Colors.blue 替换为 color: Color(0x000000)
    细心的读者可能会发现这个按钮没有阴影(点击之后也没有),这样会显得没有质感。其实这也很容易,将上面的FlatButton换成RaisedButton就行,其它代码不用改(这里 color 也不做更改),换了之后的效果如图3-16所示:
    3.基础组件 - 图18
    是不是有质感了!之所以会这样,是因为RaisedButton默认有配置阴影:

    1. const RaisedButton({
    2. ...
    3. this.elevation = 2.0, //正常状态下的阴影
    4. this.highlightElevation = 8.0,//按下时的阴影
    5. this.disabledElevation = 0.0,// 禁用时的阴影
    6. ...
    7. }

    值得注意的是,在Material 组件库中,我们会在很多组件中见到elevation相关的属性,它们都是用来控制阴影的,这是因为阴影在Material设计风格中是一种很重要的表现形式,以后在介绍其它组件时,便不再赘述。
    如果我们想实现一个背景渐变的圆角按钮,按钮有没有相应的属性呢?答案是否定的,但是,我们可以通过其它方式来实现,我们将在后面”自定义组件”一章中实现。

    3.5 图片及ICON

    3.5.1 图片

    Flutter中,我们可以通过Image组件来加载并显示图片,Image的数据源可以是asset、文件、内存以及网络。

    ImageProvider

    ImageProvider 是一个抽象类,主要定义了图片数据获取的接口load(),从不同的数据源获取图片需要实现不同的ImageProvider ,如AssetImage是实现了从Asset中加载图片的ImageProvider,而NetworkImage实现了从网络加载图片的ImageProvider。

    Image

    Image widget有一个必选的image参数,它对应一个ImageProvider。下面我们分别演示一下如何从asset和网络加载图片。

    从asset中加载图片

  3. 在工程根目录下创建一个images目录,并将图片avatar.png拷贝到该目录。

  4. pubspec.yaml中的flutter部分添加如下内容:

    1. assets:
    2. - images/avatar.png

    注意: 由于 yaml 文件对缩进严格,所以必须严格按照每一层两个空格的方式进行缩进,此处assets前面应有两个空格。

  5. 加载该图片

    1. Image(
    2. image: AssetImage("images/avatar.png"),
    3. width: 100.0
    4. );
  6. Image也提供了一个快捷的构造函数Image.asset用于从asset中加载、显示图片:

    1. Image.asset("images/avatar.png",
    2. width: 100.0,
    3. )

    从网络加载图片

    1. Image(
    2. image: NetworkImage(
    3. "https://avatars2.githubusercontent.com/u/20411648?s=460&v=4"),
    4. width: 100.0,
    5. )

    Image也提供了一个快捷的构造函数Image.network用于从网络加载、显示图片:

    1. Image.network(
    2. "https://avatars2.githubusercontent.com/u/20411648?s=460&v=4",
    3. width: 100.0,
    4. )

    运行上面两个示例,图片加载成功后如图3-17所示:
    3.基础组件 - 图19

    参数

    Image在显示图片时定义了一系列参数,通过这些参数我们可以控制图片的显示外观、大小、混合效果等。我们看一下Image的主要参数:

    1. const Image({
    2. ...
    3. this.width, //图片的宽
    4. this.height, //图片高度
    5. this.color, //图片的混合色值
    6. this.colorBlendMode, //混合模式
    7. this.fit,//缩放模式
    8. this.alignment = Alignment.center, //对齐方式
    9. this.repeat = ImageRepeat.noRepeat, //重复方式
    10. ...
    11. })
  • widthheight:用于设置图片的宽、高,当不指定宽高时,图片会根据当前父容器的限制,尽可能的显示其原始大小,如果只设置widthheight的其中一个,那么另一个属性默认会按比例缩放,但可以通过下面介绍的fit属性来指定适应规则。
  • fit:该属性用于在图片的显示空间和图片本身大小不同时指定图片的适应模式。适应模式是在BoxFit中定义,它是一个枚举类型,有如下值:
    • fill:会拉伸填充满显示空间,图片本身长宽比会发生变化,图片会变形。
    • cover:会按图片的长宽比放大后居中填满显示空间,图片不会变形,超出显示空间部分会被剪裁。
    • contain:这是图片的默认适应规则,图片会在保证图片本身长宽比不变的情况下缩放以适应当前显示空间,图片不会变形。
    • fitWidth:图片的宽度会缩放到显示空间的宽度,高度会按比例缩放,然后居中显示,图片不会变形,超出显示空间部分会被剪裁。
    • fitHeight:图片的高度会缩放到显示空间的高度,宽度会按比例缩放,然后居中显示,图片不会变形,超出显示空间部分会被剪裁。
    • none:图片没有适应策略,会在显示空间内显示图片,如果图片比显示空间大,则显示空间只会显示图片中间部分。
  • 一图胜万言! 我们对一个宽高相同的头像图片应用不同的fit值,效果如图3-18所示:
    3.基础组件 - 图20
  • colorcolorBlendMode:在图片绘制时可以对每一个像素进行颜色混合处理,color指定混合色,而colorBlendMode指定混合模式,下面是一个简单的示例:

    1. Image(
    2. image: AssetImage("images/avatar.png"),
    3. width: 100.0,
    4. color: Colors.blue,
    5. colorBlendMode: BlendMode.difference,
    6. );

    运行效果如图3-19所示(彩色):
    3.基础组件 - 图21

  • repeat:当图片本身大小小于显示空间时,指定图片的重复规则。简单示例如下:

    1. Image(
    2. image: AssetImage("images/avatar.png"),
    3. width: 100.0,
    4. height: 200.0,
    5. repeat: ImageRepeat.repeatY ,
    6. )
  • 运行后效果如图3-20所示:
    3.基础组件 - 图22

完整的示例代码如下:

  1. import 'package:flutter/material.dart';
  2. class ImageAndIconRoute extends StatelessWidget {
  3. @override
  4. Widget build(BuildContext context) {
  5. var img=AssetImage("imgs/avatar.png");
  6. return SingleChildScrollView(
  7. child: Column(
  8. children: <Image>[
  9. Image(
  10. image: img,
  11. height: 50.0,
  12. width: 100.0,
  13. fit: BoxFit.fill,
  14. ),
  15. Image(
  16. image: img,
  17. height: 50,
  18. width: 50.0,
  19. fit: BoxFit.contain,
  20. ),
  21. Image(
  22. image: img,
  23. width: 100.0,
  24. height: 50.0,
  25. fit: BoxFit.cover,
  26. ),
  27. Image(
  28. image: img,
  29. width: 100.0,
  30. height: 50.0,
  31. fit: BoxFit.fitWidth,
  32. ),
  33. Image(
  34. image: img,
  35. width: 100.0,
  36. height: 50.0,
  37. fit: BoxFit.fitHeight,
  38. ),
  39. Image(
  40. image: img,
  41. width: 100.0,
  42. height: 50.0,
  43. fit: BoxFit.scaleDown,
  44. ),
  45. Image(
  46. image: img,
  47. height: 50.0,
  48. width: 100.0,
  49. fit: BoxFit.none,
  50. ),
  51. Image(
  52. image: img,
  53. width: 100.0,
  54. color: Colors.blue,
  55. colorBlendMode: BlendMode.difference,
  56. fit: BoxFit.fill,
  57. ),
  58. Image(
  59. image: img,
  60. width: 100.0,
  61. height: 200.0,
  62. repeat: ImageRepeat.repeatY ,
  63. )
  64. ].map((e){
  65. return Row(
  66. children: <Widget>[
  67. Padding(
  68. padding: EdgeInsets.all(16.0),
  69. child: SizedBox(
  70. width: 100,
  71. child: e,
  72. ),
  73. ),
  74. Text(e.fit.toString())
  75. ],
  76. );
  77. }).toList()
  78. ),
  79. );
  80. }
  81. }

Image缓存

Flutter框架对加载过的图片是有缓存的(内存),默认最大缓存数量是1000,最大缓存空间为100M。关于Image的详细内容及原理我们将会在后面进阶部分深入介绍。

3.5.2 ICON

Flutter中,可以像Web开发一样使用iconfont,iconfont即“字体图标”,它是将图标做成字体文件,然后通过指定不同的字符而显示不同的图片。

在字体文件中,每一个字符都对应一个位码,而每一个位码对应一个显示字形,不同的字体就是指字形不同,即字符对应的字形是不同的。而在iconfont中,只是将位码对应的字形做成了图标,所以不同的字符最终就会渲染成不同的图标。

在Flutter开发中,iconfont和图片相比有如下优势:

  1. 体积小:可以减小安装包大小。
  2. 矢量的:iconfont都是矢量图标,放大不会影响其清晰度。
  3. 可以应用文本样式:可以像文本一样改变字体图标的颜色、大小对齐等。
  4. 可以通过TextSpan和文本混用。

    使用Material Design字体图标

    Flutter默认包含了一套Material Design的字体图标,在pubspec.yaml文件中的配置如下

    1. flutter:
    2. uses-material-design: true

    Material Design所有图标可以在其官网查看:https://material.io/tools/icons/
    我们看一个简单的例子:

    1. String icons = "";
    2. // accessible: &#xE914; or 0xE914 or E914
    3. icons += "\uE914";
    4. // error: &#xE000; or 0xE000 or E000
    5. icons += " \uE000";
    6. // fingerprint: &#xE90D; or 0xE90D or E90D
    7. icons += " \uE90D";
    8. Text(icons,
    9. style: TextStyle(
    10. fontFamily: "MaterialIcons",
    11. fontSize: 24.0,
    12. color: Colors.green
    13. ),
    14. );

    运行效果如图3-21所示:
    3.基础组件 - 图23
    通过这个示例可以看到,使用图标就像使用文本一样,但是这种方式需要我们提供每个图标的码点,这并对开发者不友好,所以,Flutter封装了IconDataIcon来专门显示字体图标,上面的例子也可以用如下方式实现:

    1. Row(
    2. mainAxisAlignment: MainAxisAlignment.center,
    3. children: <Widget>[
    4. Icon(Icons.accessible,color: Colors.green,),
    5. Icon(Icons.error,color: Colors.green,),
    6. Icon(Icons.fingerprint,color: Colors.green,),
    7. ],
    8. )

    Icons类中包含了所有Material Design图标的IconData静态变量定义。

    使用自定义字体图标

    我们也可以使用自定义字体图标。iconfont.cn上有很多字体图标素材,我们可以选择自己需要的图标打包下载后,会生成一些不同格式的字体文件,在Flutter中,我们使用ttf格式即可。
    假设我们项目中需要使用一个书籍图标和微信图标,我们打包下载后导入:

  5. 导入字体图标文件;这一步和导入字体文件相同,假设我们的字体图标文件保存在项目根目录下,路径为”fonts/iconfont.ttf”:

    1. fonts:
    2. - family: myIcon #指定一个字体名
    3. fonts:
    4. - asset: fonts/iconfont.ttf
  6. 为了使用方便,我们定义一个MyIcons类,功能和Icons类一样:将字体文件中的所有图标都定义成静态变量:

    1. class MyIcons{
    2. // book 图标
    3. static const IconData book = const IconData(
    4. 0xe614,
    5. fontFamily: 'myIcon',
    6. matchTextDirection: true
    7. );
    8. // 微信图标
    9. static const IconData wechat = const IconData(
    10. 0xec7d,
    11. fontFamily: 'myIcon',
    12. matchTextDirection: true
    13. );
    14. }
  7. 使用

    1. Row(
    2. mainAxisAlignment: MainAxisAlignment.center,
    3. children: <Widget>[
    4. Icon(MyIcons.book,color: Colors.purple,),
    5. Icon(MyIcons.wechat,color: Colors.green,),
    6. ],
    7. )
  8. 运行后效果如图3-22所示:
    3.基础组件 - 图24

    3.6 单选开关和复选框

    Material 组件库中提供了Material风格的单选开关Switch和复选框Checkbox,虽然它们都是继承自StatefulWidget,但它们本身不会保存当前选中状态,选中状态都是由父组件来管理的。当SwitchCheckbox被点击时,会触发它们的onChanged回调,我们可以在此回调中处理选中状态改变逻辑。下面看一个简单的例子:

    1. class SwitchAndCheckBoxTestRoute extends StatefulWidget {
    2. @override
    3. _SwitchAndCheckBoxTestRouteState createState() => new _SwitchAndCheckBoxTestRouteState();
    4. }
    5. class _SwitchAndCheckBoxTestRouteState extends State<SwitchAndCheckBoxTestRoute> {
    6. bool _switchSelected=true; //维护单选开关状态
    7. bool _checkboxSelected=true;//维护复选框状态
    8. @override
    9. Widget build(BuildContext context) {
    10. return Column(
    11. children: <Widget>[
    12. Switch(
    13. value: _switchSelected,//当前状态
    14. onChanged:(value){
    15. //重新构建页面
    16. setState(() {
    17. _switchSelected=value;
    18. });
    19. },
    20. ),
    21. Checkbox(
    22. value: _checkboxSelected,
    23. activeColor: Colors.red, //选中时的颜色
    24. onChanged:(value){
    25. setState(() {
    26. _checkboxSelected=value;
    27. });
    28. } ,
    29. )
    30. ],
    31. );
    32. }
    33. }

    上面代码中,由于需要维护SwitchCheckbox的选中状态,所以SwitchAndCheckBoxTestRoute继承自StatefulWidget 。在其build方法中分别构建了一个SwitchCheckbox,初始状态都为选中状态,当用户点击时,会将状态置反,然后回调用setState()通知Flutter framework重新构建UI。
    3.基础组件 - 图25

    属性及外观

    SwitchCheckbox属性比较简单,读者可以查看API文档,它们都有一个activeColor属性,用于设置激活态的颜色。至于大小,到目前为止,Checkbox的大小是固定的,无法自定义,而Switch只能定义宽度,高度也是固定的。值得一提的是Checkbox有一个属性tristate ,表示是否为三态,其默认值为false ,这时Checkbox有两种状态即“选中”和“不选中”,对应的value值为truefalse 。如果tristate值为true时,value的值会增加一个状态null,读者可以自行了解。

    总结

    通过SwitchCheckbox我们可以看到,虽然它们本身是与状态(是否选中)关联的,但它们却不是自己来维护状态,而是需要父组件来管理状态,然后当用户点击时,再通过事件通知给父组件,这样是合理的,因为SwitchCheckbox是否选中本就和用户数据关联,而这些用户数据也不可能是它们的私有状态。我们在自定义组件时也应该思考一下哪种状态的管理方式最为合理。

    3.7 输入框及表单

    Material组件库中提供了输入框组件TextField和表单组件Form。下面我们分别介绍一下。

    3.7.1 TextField

    TextField用于文本输入,它提供了很多属性,我们先简单介绍一下主要属性的作用,然后通过几个示例来演示一下关键属性的用法。

    1. const TextField({
    2. ...
    3. TextEditingController controller,
    4. FocusNode focusNode,
    5. InputDecoration decoration = const InputDecoration(),
    6. TextInputType keyboardType,
    7. TextInputAction textInputAction,
    8. TextStyle style,
    9. TextAlign textAlign = TextAlign.start,
    10. bool autofocus = false,
    11. bool obscureText = false,
    12. int maxLines = 1,
    13. int maxLength,
    14. bool maxLengthEnforced = true,
    15. ValueChanged<String> onChanged,
    16. VoidCallback onEditingComplete,
    17. ValueChanged<String> onSubmitted,
    18. List<TextInputFormatter> inputFormatters,
    19. bool enabled,
    20. this.cursorWidth = 2.0,
    21. this.cursorRadius,
    22. this.cursorColor,
    23. ...
    24. })
  • controller:编辑框的控制器,通过它可以设置/获取编辑框的内容、选择编辑内容、监听编辑文本改变事件。大多数情况下我们都需要显式提供一个controller来与文本框交互。如果没有提供controller,则TextField内部会自动创建一个。
  • focusNode:用于控制TextField是否占有当前键盘的输入焦点。它是我们和键盘交互的一个句柄(handle)。
  • InputDecoration:用于控制TextField的外观显示,如提示文本、背景颜色、边框等。
  • keyboardType:用于设置该输入框默认的键盘输入类型,取值如下:
    | TextInputType枚举值 | 含义 | | —- | —- | | text | 文本输入键盘 | | multiline | 多行文本,需和maxLines配合使用(设为null或大于1) | | number | 数字;会弹出数字键盘 | | phone | 优化后的电话号码输入键盘;会弹出数字键盘并显示“* #” | | datetime | 优化后的日期输入键盘;Android上会显示“: -” | | emailAddress | 优化后的电子邮件地址;会显示“@ .” | | url | 优化后的url输入键盘; 会显示“/ .” |

  • textInputAction:键盘动作按钮图标(即回车键位图标),它是一个枚举值,有多个可选值,全部的取值列表读者可以查看API文档,下面是当值为TextInputAction.search时,原生Android系统下键盘样式如图3-24所示:
    3.基础组件 - 图26

  • style:正在编辑的文本样式。
  • textAlign: 输入框内编辑文本在水平方向的对齐方式。
  • autofocus: 是否自动获取焦点。
  • obscureText:是否隐藏正在编辑的文本,如用于输入密码的场景等,文本内容会用“•”替换。
  • maxLines:输入框的最大行数,默认为1;如果为null,则无行数限制。
  • maxLengthmaxLengthEnforcedmaxLength代表输入框文本的最大长度,设置后输入框右下角会显示输入的文本计数。maxLengthEnforced决定当输入文本长度超过maxLength时是否阻止输入,为true时会阻止输入,为false时不会阻止输入但输入框会变红。
  • onChange:输入框内容改变时的回调函数;注:内容改变事件也可以通过controller来监听。
  • onEditingCompleteonSubmitted:这两个回调都是在输入框输入完成时触发,比如按了键盘的完成键(对号图标)或搜索键(🔍图标)。不同的是两个回调签名不同,onSubmitted回调是ValueChanged<String>类型,它接收当前输入内容做为参数,而onEditingComplete不接收参数。
  • inputFormatters:用于指定输入格式;当用户输入内容改变时,会根据指定的格式来校验。
  • enable:如果为false,则输入框会被禁用,禁用状态不接收输入和事件,同时显示禁用态样式(在其decoration中定义)。
  • cursorWidthcursorRadiuscursorColor:这三个属性是用于自定义输入框光标宽度、圆角和颜色的。

    示例:登录输入框

    布局
    1. Column(
    2. children: <Widget>[
    3. TextField(
    4. autofocus: true,
    5. decoration: InputDecoration(
    6. labelText: "用户名",
    7. hintText: "用户名或邮箱",
    8. prefixIcon: Icon(Icons.person)
    9. ),
    10. ),
    11. TextField(
    12. decoration: InputDecoration(
    13. labelText: "密码",
    14. hintText: "您的登录密码",
    15. prefixIcon: Icon(Icons.lock)
    16. ),
    17. obscureText: true,
    18. ),
    19. ],
    20. );
    运行后,效果如图3-25所示:
    3.基础组件 - 图27
    获取输入内容
    获取输入内容有两种方式:
  1. 定义两个变量,用于保存用户名和密码,然后在onChange触发时,各自保存一下输入内容。
  2. 通过controller直接获取。

第一种方式比较简单,不在举例,我们来重点看一下第二种方式,我们以用户名输入框举例:
定义一个controller

  1. //定义一个controller
  2. TextEditingController _unameController = TextEditingController();

然后设置输入框controller:

  1. TextField(
  2. autofocus: true,
  3. controller: _unameController, //设置controller
  4. ...
  5. )

通过controller获取输入框内容

  1. print(_unameController.text)

监听文本变化

监听文本变化也有两种方式:

  1. 设置onChange回调,如:

    1. TextField(
    2. autofocus: true,
    3. onChanged: (v) {
    4. print("onChange: $v");
    5. }
    6. )
  2. 通过controller监听,如:

    1. @override
    2. void initState() {
    3. //监听输入改变
    4. _unameController.addListener((){
    5. print(_unameController.text);
    6. });
    7. }

    两种方式相比,onChanged是专门用于监听文本变化,而controller的功能却多一些,除了能监听文本变化外,它还可以设置默认值、选择文本,下面我们看一个例子:
    创建一个controller:

    1. TextEditingController _selectionController = TextEditingController();

    设置默认值,并从第三个字符开始选中后面的字符

    1. _selectionController.text="hello world!";
    2. _selectionController.selection=TextSelection(
    3. baseOffset: 2,
    4. extentOffset: _selectionController.text.length
    5. );

    设置controller:

    1. TextField(
    2. controller: _selectionController,
    3. )

    运行效果如图3-26所示:
    3.基础组件 - 图28

    控制焦点

    焦点可以通过FocusNodeFocusScopeNode来控制,默认情况下,焦点由FocusScope来管理,它代表焦点控制范围,可以在这个范围内可以通过FocusScopeNode在输入框之间移动焦点、设置默认焦点等。我们可以通过FocusScope.of(context) 来获取Widget树中默认的FocusScopeNode。下面看一个示例,在此示例中创建两个TextField,第一个自动获取焦点,然后创建两个按钮:

  • 点击第一个按钮可以将焦点从第一个TextField挪到第二个TextField
  • 点击第二个按钮可以关闭键盘。

我们要实现的效果如图3-27所示:
3.基础组件 - 图29
代码如下:

  1. class FocusTestRoute extends StatefulWidget {
  2. @override
  3. _FocusTestRouteState createState() => new _FocusTestRouteState();
  4. }
  5. class _FocusTestRouteState extends State<FocusTestRoute> {
  6. FocusNode focusNode1 = new FocusNode();
  7. FocusNode focusNode2 = new FocusNode();
  8. FocusScopeNode focusScopeNode;
  9. @override
  10. Widget build(BuildContext context) {
  11. return Padding(
  12. padding: EdgeInsets.all(16.0),
  13. child: Column(
  14. children: <Widget>[
  15. TextField(
  16. autofocus: true,
  17. focusNode: focusNode1,//关联focusNode1
  18. decoration: InputDecoration(
  19. labelText: "input1"
  20. ),
  21. ),
  22. TextField(
  23. focusNode: focusNode2,//关联focusNode2
  24. decoration: InputDecoration(
  25. labelText: "input2"
  26. ),
  27. ),
  28. Builder(builder: (ctx) {
  29. return Column(
  30. children: <Widget>[
  31. RaisedButton(
  32. child: Text("移动焦点"),
  33. onPressed: () {
  34. //将焦点从第一个TextField移到第二个TextField
  35. // 这是一种写法 FocusScope.of(context).requestFocus(focusNode2);
  36. // 这是第二种写法
  37. if(null == focusScopeNode){
  38. focusScopeNode = FocusScope.of(context);
  39. }
  40. focusScopeNode.requestFocus(focusNode2);
  41. },
  42. ),
  43. RaisedButton(
  44. child: Text("隐藏键盘"),
  45. onPressed: () {
  46. // 当所有编辑框都失去焦点时键盘就会收起
  47. focusNode1.unfocus();
  48. focusNode2.unfocus();
  49. },
  50. ),
  51. ],
  52. );
  53. },
  54. ),
  55. ],
  56. ),
  57. );
  58. }
  59. }

FocusNodeFocusScopeNode还有一些其它的方法,详情可以查看API文档。

监听焦点状态改变事件

FocusNode继承自ChangeNotifier,通过FocusNode可以监听焦点的改变事件,如:

  1. ...
  2. // 创建 focusNode
  3. FocusNode focusNode = new FocusNode();
  4. ...
  5. // focusNode绑定输入框
  6. TextField(focusNode: focusNode);
  7. ...
  8. // 监听焦点变化
  9. focusNode.addListener((){
  10. print(focusNode.hasFocus);
  11. });

获得焦点时focusNode.hasFocus值为true,失去焦点时为false

自定义样式

虽然我们可以通过decoration属性来定义输入框样式,下面以自定义输入框下划线颜色为例来介绍一下:

  1. TextField(
  2. decoration: InputDecoration(
  3. labelText: "请输入用户名",
  4. prefixIcon: Icon(Icons.person),
  5. // 未获得焦点下划线设为灰色
  6. enabledBorder: UnderlineInputBorder(
  7. borderSide: BorderSide(color: Colors.grey),
  8. ),
  9. //获得焦点下划线设为蓝色
  10. focusedBorder: UnderlineInputBorder(
  11. borderSide: BorderSide(color: Colors.blue),
  12. ),
  13. ),
  14. ),

上面代码我们直接通过InputDecoration的enabledBorder和focusedBorder来分别设置了输入框在未获取焦点和获得焦点后的下划线颜色。另外,我们也可以通过主题来自定义输入框的样式,下面我们探索一下如何在不使用enabledBorder和focusedBorder的情况下来自定义下滑线颜色。
由于TextField在绘制下划线时使用的颜色是主题色里面的hintColor,但提示文本颜色也是用的hintColor, 如果我们直接修改hintColor,那么下划线和提示文本的颜色都会变。值得高兴的是decoration中可以设置hintStyle,它可以覆盖hintColor,并且主题中可以通过inputDecorationTheme来设置输入框默认的decoration。所以我们可以通过主题来自定义,代码如下:

  1. Theme(
  2. data: Theme.of(context).copyWith(
  3. hintColor: Colors.grey[200], //定义下划线颜色
  4. inputDecorationTheme: InputDecorationTheme(
  5. labelStyle: TextStyle(color: Colors.grey),//定义label字体样式
  6. hintStyle: TextStyle(color: Colors.grey, fontSize: 14.0)//定义提示文本样式
  7. )
  8. ),
  9. child: Column(
  10. children: <Widget>[
  11. TextField(
  12. decoration: InputDecoration(
  13. labelText: "用户名",
  14. hintText: "用户名或邮箱",
  15. prefixIcon: Icon(Icons.person)
  16. ),
  17. ),
  18. TextField(
  19. decoration: InputDecoration(
  20. prefixIcon: Icon(Icons.lock),
  21. labelText: "密码",
  22. hintText: "您的登录密码",
  23. hintStyle: TextStyle(color: Colors.grey, fontSize: 13.0)
  24. ),
  25. obscureText: true,
  26. )
  27. ],
  28. )
  29. )

运行效果如图3-28所示:
3.基础组件 - 图30
我们成功的自定义了下划线颜色和提问文字样式,细心的读者可能已经发现,通过这种方式自定义后,输入框在获取焦点时,labelText不会高亮显示了,正如上图中的”用户名”本应该显示蓝色,但现在却显示为灰色,并且我们还是无法定义下划线宽度。另一种灵活的方式是直接隐藏掉TextField本身的下划线,然后通过Container去嵌套定义样式,如:

  1. Container(
  2. child: TextField(
  3. keyboardType: TextInputType.emailAddress,
  4. decoration: InputDecoration(
  5. labelText: "Email",
  6. hintText: "电子邮件地址",
  7. prefixIcon: Icon(Icons.email),
  8. border: InputBorder.none //隐藏下划线
  9. )
  10. ),
  11. decoration: BoxDecoration(
  12. // 下滑线浅灰色,宽度1像素
  13. border: Border(bottom: BorderSide(color: Colors.grey[200], width: 1.0))
  14. ),
  15. )

运行效果:
3.基础组件 - 图31
通过这种组件组合的方式,也可以定义背景圆角等。一般来说,优先通过decoration来自定义样式,如果decoration实现不了,再用widget组合的方式。

思考题:在这个示例中,下划线颜色是固定的,所以获得焦点后颜色仍然为灰色,如何实现点击后下滑线也变色呢?

3.7.2 表单Form

实际业务中,在正式向服务器提交数据前,都会对各个输入框数据进行合法性校验,但是对每一个TextField都分别进行校验将会是一件很麻烦的事。还有,如果用户想清除一组TextField的内容,除了一个一个清除有没有什么更好的办法呢?为此,Flutter提供了一个Form 组件,它可以对输入框进行分组,然后进行一些统一操作,如输入内容校验、输入框重置以及输入内容保存。

Form

Form继承自StatefulWidget对象,它对应的状态类为FormState。我们先看看Form类的定义:

  1. Form({
  2. @required Widget child,
  3. bool autovalidate = false,
  4. WillPopCallback onWillPop,
  5. VoidCallback onChanged,
  6. })
  • autovalidate:是否自动校验输入内容;当为true时,每一个子FormField内容发生变化时都会自动校验合法性,并直接显示错误信息。否则,需要通过调用FormState.validate()来手动校验。
  • onWillPop:决定Form所在的路由是否可以直接返回(如点击返回按钮),该回调返回一个Future对象,如果Future的最终结果是false,则当前路由不会返回;如果为true,则会返回到上一个路由。此属性通常用于拦截返回按钮。
  • onChangedForm的任意一个子FormField内容发生变化时会触发此回调。

    FormField

    Form的子孙元素必须是FormField类型,FormField是一个抽象类,定义几个属性,FormState内部通过它们来完成操作,FormField部分定义如下:

    1. const FormField({
    2. ...
    3. FormFieldSetter<T> onSaved, //保存回调
    4. FormFieldValidator<T> validator, //验证回调
    5. T initialValue, //初始值
    6. bool autovalidate = false, //是否自动校验。
    7. })

    为了方便使用,Flutter提供了一个TextFormField组件,它继承自FormField类,也是TextField的一个包装类,所以除了FormField定义的属性之外,它还包括TextField的属性。

    FormState

    FormStateFormState类,可以通过Form.of()GlobalKey获得。我们可以通过它来对Form的子孙FormField进行统一操作。我们看看其常用的三个方法:

  • FormState.validate():调用此方法后,会调用Form子孙FormField的validate回调,如果有一个校验失败,则返回false,所有校验失败项都会返回用户返回的错误提示。

  • FormState.save():调用此方法后,会调用Form子孙FormFieldsave回调,用于保存表单内容
  • FormState.reset():调用此方法后,会将子孙FormField的内容清空。

    示例

    我们修改一下上面用户登录的示例,在提交之前校验:
  1. 用户名不能为空,如果为空则提示“用户名不能为空”。
  2. 密码不能小于6位,如果小于6为则提示“密码不能少于6位”。

完整代码:

  1. class FormTestRoute extends StatefulWidget {
  2. @override
  3. _FormTestRouteState createState() => new _FormTestRouteState();
  4. }
  5. class _FormTestRouteState extends State<FormTestRoute> {
  6. TextEditingController _unameController = new TextEditingController();
  7. TextEditingController _pwdController = new TextEditingController();
  8. GlobalKey _formKey= new GlobalKey<FormState>();
  9. @override
  10. Widget build(BuildContext context) {
  11. return Scaffold(
  12. appBar: AppBar(
  13. title:Text("Form Test"),
  14. ),
  15. body: Padding(
  16. padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0),
  17. child: Form(
  18. key: _formKey, //设置globalKey,用于后面获取FormState
  19. autovalidate: true, //开启自动校验
  20. child: Column(
  21. children: <Widget>[
  22. TextFormField(
  23. autofocus: true,
  24. controller: _unameController,
  25. decoration: InputDecoration(
  26. labelText: "用户名",
  27. hintText: "用户名或邮箱",
  28. icon: Icon(Icons.person)
  29. ),
  30. // 校验用户名
  31. validator: (v) {
  32. return v
  33. .trim()
  34. .length > 0 ? null : "用户名不能为空";
  35. }
  36. ),
  37. TextFormField(
  38. controller: _pwdController,
  39. decoration: InputDecoration(
  40. labelText: "密码",
  41. hintText: "您的登录密码",
  42. icon: Icon(Icons.lock)
  43. ),
  44. obscureText: true,
  45. //校验密码
  46. validator: (v) {
  47. return v
  48. .trim()
  49. .length > 5 ? null : "密码不能少于6位";
  50. }
  51. ),
  52. // 登录按钮
  53. Padding(
  54. padding: const EdgeInsets.only(top: 28.0),
  55. child: Row(
  56. children: <Widget>[
  57. Expanded(
  58. child: RaisedButton(
  59. padding: EdgeInsets.all(15.0),
  60. child: Text("登录"),
  61. color: Theme
  62. .of(context)
  63. .primaryColor,
  64. textColor: Colors.white,
  65. onPressed: () {
  66. //在这里不能通过此方式获取FormState,context不对
  67. //print(Form.of(context));
  68. // 通过_formKey.currentState 获取FormState后,
  69. // 调用validate()方法校验用户名密码是否合法,校验
  70. // 通过后再提交数据。
  71. if((_formKey.currentState as FormState).validate()){
  72. //验证通过提交数据
  73. }
  74. },
  75. ),
  76. ),
  77. ],
  78. ),
  79. )
  80. ],
  81. ),
  82. ),
  83. ),
  84. );
  85. }
  86. }

运行后效果如图3-29所示:
3.基础组件 - 图32
注意,登录按钮的onPressed方法中不能通过Form.of(context)来获取,原因是,此处的contextFormTestRoute的context,而Form.of(context)是根据所指定context向根去查找,而FormState是在FormTestRoute的子树中,所以不行。正确的做法是通过Builder来构建登录按钮,Builder会将widget节点的context作为回调参数:

  1. Expanded(
  2. // 通过Builder来获取RaisedButton所在widget树的真正context(Element)
  3. child:Builder(builder: (context){
  4. return RaisedButton(
  5. ...
  6. onPressed: () {
  7. //由于本widget也是Form的子代widget,所以可以通过下面方式获取FormState
  8. if(Form.of(context).validate()){
  9. //验证通过提交数据
  10. }
  11. },
  12. );
  13. })
  14. )

其实context正是操作Widget所对应的Element的一个接口,由于Widget树对应的Element都是不同的,所以context也都是不同的,有关context的更多内容会在后面高级部分详细讨论。Flutter中有很多“of(context)”这种方法,读者在使用时一定要注意context是否正确。

3.8 进度指示器

Material 组件库中提供了两种进度指示器:LinearProgressIndicatorCircularProgressIndicator,它们都可以同时用于精确的进度指示和模糊的进度指示。精确进度通常用于任务进度可以计算和预估的情况,比如文件下载;而模糊进度则用户任务进度无法准确获得的情况,如下拉刷新,数据提交等。

LinearProgressIndicator

LinearProgressIndicator是一个线性、条状的进度条,定义如下:

  1. LinearProgressIndicator({
  2. double value,
  3. Color backgroundColor,
  4. Animation<Color> valueColor,
  5. ...
  6. })
  • valuevalue表示当前的进度,取值范围为[0,1];如果valuenull时则指示器会执行一个循环动画(模糊进度);当value不为null时,指示器为一个具体进度的进度条。
  • backgroundColor:指示器的背景色。
  • valueColor: 指示器的进度条颜色;值得注意的是,该值类型是Animation<Color>,这允许我们对进度条的颜色也可以指定动画。如果我们不需要对进度条颜色执行动画,换言之,我们想对进度条应用一种固定的颜色,此时我们可以通过AlwaysStoppedAnimation来指定。

    示例

    1. // 模糊进度条(会执行一个动画)
    2. LinearProgressIndicator(
    3. backgroundColor: Colors.grey[200],
    4. valueColor: AlwaysStoppedAnimation(Colors.blue),
    5. ),
    6. //进度条显示50%
    7. LinearProgressIndicator(
    8. backgroundColor: Colors.grey[200],
    9. valueColor: AlwaysStoppedAnimation(Colors.blue),
    10. value: .5,
    11. )
    运行效果如图3-30所示:
    3.基础组件 - 图33
    第一个进度条在执行循环动画:蓝色条一直在移动,而第二个进度条是静止的,停在50%的位置。

    CircularProgressIndicator

    CircularProgressIndicator是一个圆形进度条,定义如下:
    1. CircularProgressIndicator({
    2. double value,
    3. Color backgroundColor,
    4. Animation<Color> valueColor,
    5. this.strokeWidth = 4.0,
    6. ...
    7. })
    前三个参数和LinearProgressIndicator相同,不再赘述。strokeWidth 表示圆形进度条的粗细。示例如下:
    1. // 模糊进度条(会执行一个旋转动画)
    2. CircularProgressIndicator(
    3. backgroundColor: Colors.grey[200],
    4. valueColor: AlwaysStoppedAnimation(Colors.blue),
    5. ),
    6. //进度条显示50%,会显示一个半圆
    7. CircularProgressIndicator(
    8. backgroundColor: Colors.grey[200],
    9. valueColor: AlwaysStoppedAnimation(Colors.blue),
    10. value: .5,
    11. ),
    运行效果如图3-31所示:
    3.基础组件 - 图34
    第一个进度条会执行旋转动画,而第二个进度条是静止的,它停在50%的位置。

    自定义尺寸

    我们可以发现LinearProgressIndicatorCircularProgressIndicator,并没有提供设置圆形进度条尺寸的参数;如果我们希望LinearProgressIndicator的线细一些,或者希望CircularProgressIndicator的圆大一些该怎么做?
    其实LinearProgressIndicatorCircularProgressIndicator都是取父容器的尺寸作为绘制的边界的。知道了这点,我们便可以通过尺寸限制类Widget,如ConstrainedBoxSizedBox (我们将在后面容器类组件一章中介绍)来指定尺寸,如:
    1. // 线性进度条高度指定为3
    2. SizedBox(
    3. height: 3,
    4. child: LinearProgressIndicator(
    5. backgroundColor: Colors.grey[200],
    6. valueColor: AlwaysStoppedAnimation(Colors.blue),
    7. value: .5,
    8. ),
    9. ),
    10. // 圆形进度条直径指定为100
    11. SizedBox(
    12. height: 100,
    13. width: 100,
    14. child: CircularProgressIndicator(
    15. backgroundColor: Colors.grey[200],
    16. valueColor: AlwaysStoppedAnimation(Colors.blue),
    17. value: .7,
    18. ),
    19. ),
    运行效果如图3-32所示:
    3.基础组件 - 图35
    注意,如果CircularProgressIndicator显示空间的宽高不同,则会显示为椭圆。如:
    1. // 宽高不等
    2. SizedBox(
    3. height: 100,
    4. width: 130,
    5. child: CircularProgressIndicator(
    6. backgroundColor: Colors.grey[200],
    7. valueColor: AlwaysStoppedAnimation(Colors.blue),
    8. value: .7,
    9. ),
    10. ),
    运行效果如图3-33所示:
    3.基础组件 - 图36

    进度色动画

    前面说过可以通过valueColor对进度条颜色做动画,关于动画我们将在后面专门的章节详细介绍,这里先给出一个例子,读者在了解了Flutter动画一章后再回过头来看。
    我们实现一个进度条在3秒内从灰色变成蓝色的动画:
    1. import 'package:flutter/material.dart';
    2. class ProgressRoute extends StatefulWidget {
    3. @override
    4. _ProgressRouteState createState() => _ProgressRouteState();
    5. }
    6. class _ProgressRouteState extends State<ProgressRoute>
    7. with SingleTickerProviderStateMixin {
    8. AnimationController _animationController;
    9. @override
    10. void initState() {
    11. //动画执行时间3秒
    12. _animationController =
    13. new AnimationController(vsync: this, duration: Duration(seconds: 3));
    14. _animationController.forward();
    15. _animationController.addListener(() => setState(() => {}));
    16. super.initState();
    17. }
    18. @override
    19. void dispose() {
    20. _animationController.dispose();
    21. super.dispose();
    22. }
    23. @override
    24. Widget build(BuildContext context) {
    25. return SingleChildScrollView(
    26. child: Column(
    27. children: <Widget>[
    28. Padding(
    29. padding: EdgeInsets.all(16),
    30. child: LinearProgressIndicator(
    31. backgroundColor: Colors.grey[200],
    32. valueColor: ColorTween(begin: Colors.grey, end: Colors.blue)
    33. .animate(_animationController), // 从灰色变成蓝色
    34. value: _animationController.value,
    35. ),
    36. );
    37. ],
    38. ),
    39. );
    40. }
    41. }

    自定义进度指示器样式

    定制进度指示器风格样式,可以通过CustomPainter Widget 来自定义绘制逻辑,实际上LinearProgressIndicatorCircularProgressIndicator也正是通过CustomPainter来实现外观绘制的。关于CustomPainter,我们将在后面“自定义Widget”一章中详细介绍。

    flutter_spinkit 包提供了多种风格的模糊进度指示器,读者若是感兴趣,可以参考。