flutter笔记———组件

115 阅读27分钟

基础组件

Text

DefaultTextStyle(
  //1.设置文本默认样式  
  style: TextStyle(
    color:Colors.red,
    fontSize: 20.0,
  ),
  textAlign: TextAlign.start,
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: <Widget>[
      Text("hello world"),
      Text("I am Jack"),
      Text("I am Jack",
        style: TextStyle(
          inherit: false, //2.不继承默认样式
          color: Colors.grey
        ),
      ),
    ],
  ),
);

按钮

Material 组件库中提供了多种按钮组件如ElevatedButtonTextButtonOutlineButton等,它们都是直接或间接对RawMaterialButton组件的包装定制

ElevatedButton.icon(
  icon: Icon(Icons.send),
  label: Text("发送"),
  onPressed: _onPressed,
)

图片及ICON

Flutter 中,我们可以通过Image组件来加载并显示图片,Image的数据源可以是asset、文件、内存以及网络,ImageProvider 是一个抽象类,主要定义了图片数据获取的接口load(),从不同的数据源获取图片需要实现不同的ImageProvider ,如AssetImage是实现了从Asset中加载图片的 ImageProvider,而NetworkImage 实现了从网络加载图片的 ImageProvider,Image widget 有一个必选的image参数,它对应一个 ImageProvider。下面我们分别演示一下如何从 asset 和网络加载图片

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

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

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

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

Material Design所有图标可以在其官网查看:material.io/tools/icons…

String icons = "";
// accessible: 0xe03e
icons += "\uE03e";
// error:  0xe237
icons += " \uE237";
// fingerprint: 0xe287
icons += " \uE287";

Text(
  icons,
  style: TextStyle(
    fontFamily: "MaterialIcons",
    fontSize: 24.0,
    color: Colors.green,
  ),
);

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

Row(
  mainAxisAlignment: MainAxisAlignment.center,
  children: <Widget>[
    Icon(Icons.accessible,color: Colors.green),
    Icon(Icons.error,color: Colors.green),
    Icon(Icons.fingerprint,color: Colors.green),
  ],
)

单选开关和复选框

Material 组件库中提供了 Material 风格的单选开关Switch和复选框Checkbox,虽然它们都是继承自StatefulWidget,但它们本身不会保存当前选中状态,选中状态都是由父组件来管理的

class SwitchAndCheckBoxTestRoute extends StatefulWidget {
  @override
  _SwitchAndCheckBoxTestRouteState createState() => _SwitchAndCheckBoxTestRouteState();
}

class _SwitchAndCheckBoxTestRouteState extends State<SwitchAndCheckBoxTestRoute> {
  bool _switchSelected=true; //维护单选开关状态
  bool _checkboxSelected=true;//维护复选框状态
  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        Switch(
          value: _switchSelected,//当前状态
          onChanged:(value){
            //重新构建页面  
            setState(() {
              _switchSelected=value;
            });
          },
        ),
        Checkbox(
          value: _checkboxSelected,
          activeColor: Colors.red, //选中时的颜色
          onChanged:(value){
            setState(() {
              _checkboxSelected=value;
            });
          } ,
        )
      ],
    );
  }
}

SwitchCheckbox属性比较简单,读者可以查看API文档,它们都有一个activeColor属性,用于设置激活态的颜色。至于大小,到目前为止,Checkbox的大小是固定的,无法自定义,而Switch只能定义宽度,高度也是固定的。

输入框及表单

Material 组件库中提供了输入框组件TextField和表单组件Form

Theme(
  data: Theme.of(context).copyWith(
      hintColor: Colors.grey[200], //定义下划线颜色
      inputDecorationTheme: InputDecorationTheme(
          labelStyle: TextStyle(color: Colors.grey),//定义label字体样式
          hintStyle: TextStyle(color: Colors.grey, fontSize: 14.0)//定义提示文本样式
      )
  ),
  child: Column(
    children: <Widget>[
      TextField(
        decoration: InputDecoration(
            labelText: "用户名",
            hintText: "用户名或邮箱",
            prefixIcon: Icon(Icons.person)
        ),
      ),
      TextField(
        decoration: InputDecoration(
            prefixIcon: Icon(Icons.lock),
            labelText: "密码",
            hintText: "您的登录密码",
            hintStyle: TextStyle(color: Colors.grey, fontSize: 13.0)
        ),
        obscureText: true,
      )
    ],
  )
)
import 'package:flutter/material.dart';

class FormTestRoute extends StatefulWidget {
  @override
  _FormTestRouteState createState() => _FormTestRouteState();
}

class _FormTestRouteState extends State<FormTestRoute> {
  TextEditingController _unameController = TextEditingController();
  TextEditingController _pwdController = TextEditingController();
  GlobalKey _formKey = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey, //设置globalKey,用于后面获取FormState
      autovalidateMode: AutovalidateMode.onUserInteraction,
      child: Column(
        children: <Widget>[
          TextFormField(
            autofocus: true,
            controller: _unameController,
            decoration: InputDecoration(
              labelText: "用户名",
              hintText: "用户名或邮箱",
              icon: Icon(Icons.person),
            ),
            // 校验用户名
            validator: (v) {
              return v!.trim().isNotEmpty ? null : "用户名不能为空";
            },
          ),
          TextFormField(
            controller: _pwdController,
            decoration: InputDecoration(
              labelText: "密码",
              hintText: "您的登录密码",
              icon: Icon(Icons.lock),
            ),
            obscureText: true,
            //校验密码
            validator: (v) {
              return v!.trim().length > 5 ? null : "密码不能少于6位";
            },
          ),
          // 登录按钮
          Padding(
            padding: const EdgeInsets.only(top: 28.0),
            child: Row(
              children: <Widget>[
                Expanded(
                  child: ElevatedButton(
                    child: Padding(
                      padding: const EdgeInsets.all(16.0),
                      child: Text("登录"),
                    ),
                    onPressed: () {
                      // 通过_formKey.currentState 获取FormState后,
                      // 调用validate()方法校验用户名密码是否合法,校验
                      // 通过后再提交数据。
                      if ((_formKey.currentState as FormState).validate()) {
                        //验证通过提交数据
                      }
                    },
                  ),
                ),
              ],
            ),
          )
        ],
      ),
    );
  }
}

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

Expanded(
 // 通过Builder来获取ElevatedButton所在widget树的真正context(Element) 
  child:Builder(builder: (context){
    return ElevatedButton(
      ...
      onPressed: () {
        //由于本widget也是Form的子代widget,所以可以通过下面方式获取FormState  
        if(Form.of(context).validate()){
          //验证通过提交数据
        }
      },
    );
  })
)

进度指示器

Material 组件库中提供了两种进度指示器:LinearProgressIndicatorCircularProgressIndicatorLinearProgressIndicatorCircularProgressIndicator都是取父容器的尺寸作为绘制的边界的。知道了这点,我们便可以通过尺寸限制类Widget,如ConstrainedBoxSizedBox (我们将在后面容器类组件一章中介绍)来指定尺寸

// 线性进度条高度指定为3
SizedBox(
  height: 3,
  child: LinearProgressIndicator(
    backgroundColor: Colors.grey[200],
    valueColor: AlwaysStoppedAnimation(Colors.blue),
    value: .5,
  ),
),
// 圆形进度条直径指定为100
SizedBox(
  height: 100,
  width: 100,
  child: CircularProgressIndicator(
    backgroundColor: Colors.grey[200],
    valueColor: AlwaysStoppedAnimation(Colors.blue),
    value: .7,
  ),
),

布局类组件简介

Flutter 中有两种布局模型:

  • 基于 RenderBox 的盒模型布局。
  • 基于 Sliver ( RenderSliver ) 按需加载列表布局。

两种布局方式在细节上略有差异,但大体流程相同,布局流程如下:

  1. 上层组件向下层组件传递约束(constraints)条件。
  2. 下层组件确定自己的大小,然后告诉上层组件。注意下层组件的大小必须符合父组件的约束。
  3. 上层组件确定下层组件相对于自身的偏移和确定自身的大小(大多数情况下会根据子组件的大小来确定自身的大小)。

BoxConstraints 是盒模型布局过程中父渲染对象传递给子渲染对象的约束信息,包含最大宽高信息,子组件大小需要在约束的范围内,BoxConstraints 默认的构造函数如下:

const BoxConstraints({
  this.minWidth = 0.0, //最小宽度
  this.maxWidth = double.infinity, //最大宽度
  this.minHeight = 0.0, //最小高度
  this.maxHeight = double.infinity //最大高度
})

ConstrainedBox用于对子组件添加额外的约束

ConstrainedBox(
  constraints: BoxConstraints(
    minWidth: double.infinity, //宽度尽可能大
    minHeight: 50.0 //最小高度为50像素
  ),
  child: Container(
    height: 5.0, 
    child: redBox ,
  ),
)

SizedBox用于给子元素指定固定的宽高,如:

SizedBox(
  width: 80.0,
  height: 80.0,
  child: redBox
)

实际上SizedBox只是ConstrainedBox的一个定制,上面代码等价于:

ConstrainedBox(
  constraints: BoxConstraints.tightFor(width: 80.0,height: 80.0),
  child: redBox, 
)
ConstrainedBox(
  constraints: BoxConstraints(minWidth: 60.0, minHeight: 100.0),  //父
  child: UnconstrainedBox( //“去除”父级限制
    child: ConstrainedBox(
      constraints: BoxConstraints(minWidth: 90.0, minHeight: 20.0),//子
      child: redBox,
    ),
  )
)

上面代码中,如果没有中间的UnconstrainedBox,那么根据上面所述的多重限制规则,那么最终将显示一个90×100的红色框。但是由于UnconstrainedBox “去除”了父ConstrainedBox的限制,则最终会按照子ConstrainedBox的限制来绘制redBox,即90×20 如图4-5所示:

但是,读者请注意,UnconstrainedBox对父组件限制的“去除”并非是真正的去除:上面例子中虽然红色区域大小是90×20,但上方仍然有80的空白空间。也就是说父限制的minHeight(100.0)仍然是生效的,只不过它不影响最终子元素redBox的大小,但仍然还是占有相应的空间,可以认为此时的父ConstrainedBox是作用于子UnconstrainedBox上,而redBox只受子ConstrainedBox限制,这一点请读者务必注意。

那么有什么方法可以彻底去除父ConstrainedBox的限制吗?答案是否定的!请牢记,任何时候子组件都必须遵守其父组件的约束,所以在此提示读者,在定义一个通用的组件时,如果要对子组件指定约束,那么一定要注意,因为一旦指定约束条件,子组件自身就不能违反约束。

在实际开发中,当我们发现已经使用 SizedBox 或 ConstrainedBox给子元素指定了固定宽高,但是仍然没有效果时,几乎可以断定:已经有父组件指定了约束!举个例子,如 Material 组件库中的AppBar(导航栏)的右侧菜单中,我们使用SizedBox指定了 loading 按钮的大小,代码如下:

 AppBar(
   title: Text(title),
   actions: <Widget>[
     SizedBox(
       width: 20, 
       height: 20,
       child: CircularProgressIndicator(
         strokeWidth: 3,
         valueColor: AlwaysStoppedAnimation(Colors.white70),
       ),
     )
   ],
)

上面代码运行后,效果如图4-6所示:

图4-6

我们会发现右侧loading按钮大小并没有发生变化!这正是因为AppBar中已经指定了actions按钮的约束条件,所以我们要自定义loading按钮大小,就必须通过UnconstrainedBox来 “去除” 父元素的限制,代码如下:

AppBar(
  title: Text(title),
  actions: <Widget>[
    UnconstrainedBox(
      child: SizedBox(
        width: 20,
        height: 20,
        child: CircularProgressIndicator(
          strokeWidth: 3,
          valueColor: AlwaysStoppedAnimation(Colors.white70),
        ),
      ),
    )
  ],
)

线性布局(Row和Column)

所谓线性布局,即指沿水平或垂直方向排列子组件。Flutter 中通过RowColumn来实现线性布局,类似于Android 中的LinearLayout控件。RowColumn都继承自Flex

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

Container(
  color: Colors.green,
  child: Padding(
    padding: const EdgeInsets.all(16.0),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      mainAxisSize: MainAxisSize.max, //有效,外层Colum高度为整个屏幕
      children: <Widget>[
        Container(
          color: Colors.red,
          child: Column(
            mainAxisSize: MainAxisSize.max,//无效,内层Colum高度为实际高度  
            children: <Widget>[
              Text("hello world "),
              Text("I am Jack "),
            ],
          ),
        )
      ],
    ),
  ),
);

运行效果如图4-11所示:

图4-11

弹性布局(Flex)

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

class FlexLayoutTestRoute extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        //Flex的两个子widget按1:2来占据水平空间  
        Flex(
          direction: Axis.horizontal,
          children: <Widget>[
            Expanded(
              flex: 1,
              child: Container(
                height: 30.0,
                color: Colors.red,
              ),
            ),
            Expanded(
              flex: 2,
              child: Container(
                height: 30.0,
                color: Colors.green,
              ),
            ),
          ],
        ),
        Padding(
          padding: const EdgeInsets.only(top: 20.0),
          child: SizedBox(
            height: 100.0,
            //Flex的三个子widget,在垂直方向按2:1:1来占用100像素的空间  
            child: Flex(
              direction: Axis.vertical,
              children: <Widget>[
                Expanded(
                  flex: 2,
                  child: Container(
                    height: 30.0,
                    color: Colors.red,
                  ),
                ),
                Spacer(
                  flex: 1,
                ),
                Expanded(
                  flex: 1,
                  child: Container(
                    height: 30.0,
                    color: Colors.green,
                  ),
                ),
              ],
            ),
          ),
        ),
      ],
    );
  }
}

流式布局(Wrap、Flow)

我们把超出屏幕显示范围会自动折行的布局称为流式布局。Flutter中通过WrapFlow来支持流式布局

Wrap(
   spacing: 8.0, // 主轴(水平)方向间距
   runSpacing: 4.0, // 纵轴(垂直)方向间距
   alignment: WrapAlignment.center, //沿主轴方向居中
   children: <Widget>[
     Chip(
       avatar: CircleAvatar(backgroundColor: Colors.blue, child: Text('A')),
       label: Text('Hamilton'),
     ),
     Chip(
       avatar: CircleAvatar(backgroundColor: Colors.blue, child: Text('M')),
       label: Text('Lafayette'),
     ),
     Chip(
       avatar: CircleAvatar(backgroundColor: Colors.blue, child: Text('H')),
       label: Text('Mulligan'),
     ),
     Chip(
       avatar: CircleAvatar(backgroundColor: Colors.blue, child: Text('J')),
       label: Text('Laurens'),
     ),
   ],
)

层叠布局(Stack、Positioned)

层叠布局和 Web 中的绝对定位、Android 中的 Frame 布局是相似的,子组件可以根据距父容器四个角的位置来确定自身的位置。层叠布局允许子组件按照代码中声明的顺序堆叠起来。Flutter中使用StackPositioned这两个组件来配合实现绝对定位。Stack允许子组件堆叠,而Positioned用于根据Stack的四个角来确定子组件的位置。

//通过ConstrainedBox来确保Stack占满屏幕
ConstrainedBox(
  constraints: BoxConstraints.expand(),
  child: Stack(
    alignment:Alignment.center , //指定未定位或部分定位widget的对齐方式
    children: <Widget>[
      Container(
        child: Text("Hello world",style: TextStyle(color: Colors.white)),
        color: Colors.red,
      ),
      Positioned(
        left: 18.0,
        child: Text("I am Jack"),
      ),
      Positioned(
        top: 18.0,
        child: Text("Your friend"),
      )        
    ],
  ),
);

对齐与相对定位(Align)

Container(
  height: 120.0,
  width: 120.0,
  color: Colors.blue.shade50,
  child: Align(
    alignment: Alignment.topRight,
    child: FlutterLogo(
      size: 60,
    ),
  ),
)

Center继承自Align,它比Align只少了一个alignment 参数;由于Align的构造函数中alignment 值为Alignment.center,所以,我们可以认为Center组件其实是对齐方式确定(Alignment.center)了的Align

LayoutBuilder

通过 LayoutBuilder,我们可以在布局过程中拿到父组件传递的约束信息,然后我们可以根据约束信息动态的构建不同的布局。

比如我们实现一个响应式的 Column 组件 ResponsiveColumn,它的功能是当当前可用的宽度小于 200 时,将子组件显示为一列,否则显示为两列

容器类Widget

容器类Widget和布局类Widget都作用于其子Widget,不同的是:

  • 布局类Widget一般都需要接收一个widget数组(children),他们直接或间接继承自(或包含)MultiChildRenderObjectWidget ;而容器类Widget一般只需要接收一个子Widget(child),他们直接或间接继承自(或包含)SingleChildRenderObjectWidget。
  • 布局类Widget是按照一定的排列方式来对其子Widget进行排列;而容器类Widget一般只是包装其子Widget,对其添加一些修饰(补白或背景色等)、变换(旋转或剪裁等)、或限制(大小等)。

Padding

Padding可以给其子节点添加填充(留白),和边距效果类似。

Padding(
            // 分别指定四个方向的补白
            padding: EdgeInsets.fromLTRB(20, 0, 20, 20),
            child: Text("Your friend"),
          )

DecoratedBox

DecoratedBox可以在其子组件绘制前(或后)绘制一些装饰(Decoration),如背景、边框、渐变等。我们通常会直接使用BoxDecoration类,它是一个Decoration的子类,实现了常用的装饰元素的绘制。

DecoratedBox(
   decoration: BoxDecoration(
     gradient: LinearGradient(colors:[Colors.red,Colors.orange.shade700]), //背景渐变
     borderRadius: BorderRadius.circular(3.0), //3像素圆角
     boxShadow: [ //阴影
       BoxShadow(
         color:Colors.black54,
         offset: Offset(2.0,2.0),
         blurRadius: 4.0
       )
     ]
   ),
  child: Padding(
    padding: EdgeInsets.symmetric(horizontal: 80.0, vertical: 18.0),
    child: Text("Login", style: TextStyle(color: Colors.white),),
  )
)

变换(Transform)

Transform可以在其子组件绘制时对其应用一些矩阵变换来实现一些特效。Matrix4是一个4D矩阵,通过它我们可以实现各种矩阵操作,下面是一个例子:

Container(
  color: Colors.black,
  child: Transform(
    alignment: Alignment.topRight, //相对于坐标系原点的对齐方式
    transform: Matrix4.skewY(0.3), //沿Y轴倾斜0.3弧度
    child: Container(
      padding: const EdgeInsets.all(8.0),
      color: Colors.deepOrange,
      child: const Text('Apartment for rent!'),
    ),
  ),
)

容器组件(Container)

Container是一个组合类容器,它本身不对应具体的RenderObject,它是DecoratedBoxConstrainedBox、TransformPaddingAlign等组件组合的一个多功能容器,

Container(
  margin: EdgeInsets.only(top: 50.0, left: 120.0),
  constraints: BoxConstraints.tightFor(width: 200.0, height: 150.0),//卡片大小
  decoration: BoxDecoration(  //背景装饰
    gradient: RadialGradient( //背景径向渐变
      colors: [Colors.red, Colors.orange],
      center: Alignment.topLeft,
      radius: .98,
    ),
    boxShadow: [
      //卡片阴影
      BoxShadow(
        color: Colors.black54,
        offset: Offset(2.0, 2.0),
        blurRadius: 4.0,
      )
    ],
  ),
  transform: Matrix4.rotationZ(.2),//卡片倾斜变换
  alignment: Alignment.center, //卡片内文字居中
  child: Text(
    //卡片文字
    "5.20", style: TextStyle(color: Colors.white, fontSize: 40.0),
  ),
 )

剪裁类组件

Flutter中提供了一些剪裁组件,用于对组件进行剪裁。

剪裁Widget默认行为
ClipOval子组件为正方形时剪裁成内贴圆形;为矩形时,剪裁成内贴椭圆
ClipRRect将子组件剪裁为圆角矩形
ClipRect默认剪裁掉子组件布局空间之外的绘制内容(溢出部分剪裁)
ClipPath按照自定义的路径剪裁

空间适配(FittedBox)

适配原理

  1. FittedBox 在布局子组件时会忽略其父组件传递的约束,可以允许子组件无限大,即FittedBox 传递给子组件的约束为(0<=width<=double.infinity, 0<= height <=double.infinity)。
  2. FittedBox 对子组件布局结束后就可以获得子组件真实的大小。
  3. FittedBox 知道子组件的真实大小也知道他父组件的约束,那么FittedBox 就可以通过指定的适配方式(BoxFit 枚举中指定),让起子组件在 FittedBox 父组件的约束范围内按照指定的方式显示。
Widget build(BuildContext context) {
  return Center(
    child: Column(
      children: [
        wContainer(BoxFit.none),
        Text('Wendux'),
        wContainer(BoxFit.contain),
        Text('Flutter中国'),
      ],
    ),
  );
}

Widget wContainer(BoxFit boxFit) {
  return Container(
    width: 50,
    height: 50,
    color: Colors.red,
    child: FittedBox(
      fit: boxFit,
      // 子容器超过父容器大小
      child: Container(width: 60, height: 70, color: Colors.blue),
    ),
  );
}

因为父Container要比子Container 小,所以当没有指定任何适配方式时,子组件会按照其真实大小进行绘制,所以第一个蓝色区域会超出父组件的空间,因而看不到红色区域。第二个我们指定了适配方式为 BoxFit.contain,含义是按照子组件的比例缩放,尽可能多的占据父组件空间,因为子组件的长宽并不相同,所以按照比例缩放适配父组件后,父组件能显示一部分。

要注意一点,在未指定适配方式时,虽然 FittedBox 子组件的大小超过了 FittedBox 父 Container 的空间,但FittedBox 自身还是要遵守其父组件传递的约束,所以最终 FittedBox 的本身的大小是 50×50,这也是为什么蓝色会和下面文本重叠的原因,因为在布局空间内,父Container只占50×50的大小,接下来文本会紧挨着Container进行布局,而此时Container 中有子组件的大小超过了自己,所以最终的效果就是绘制范围超出了Container,但布局位置是正常的,所以就重叠了。如果我们不想让蓝色超出父组件布局范围,那么可以可以使用 ClipRect 对超出的部分剪裁掉即可:

页面骨架(Scaffold)

Scaffold 是一个路由页的骨架,我们使用它可以很容易地拼装出一个完整的页面

class ScaffoldRoute extends StatefulWidget {
  @override
  _ScaffoldRouteState createState() => _ScaffoldRouteState();
}

class _ScaffoldRouteState extends State<ScaffoldRoute> {
  int _selectedIndex = 1;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar( //导航栏
        title: Text("App Name"), 
        actions: <Widget>[ //导航栏右侧菜单
          IconButton(icon: Icon(Icons.share), onPressed: () {}),
        ],
      ),
      drawer: MyDrawer(), //抽屉
      bottomNavigationBar: BottomNavigationBar( // 底部导航
        items: <BottomNavigationBarItem>[
          BottomNavigationBarItem(icon: Icon(Icons.home), title: Text('Home')),
          BottomNavigationBarItem(icon: Icon(Icons.business), title: Text('Business')),
          BottomNavigationBarItem(icon: Icon(Icons.school), title: Text('School')),
        ],
        currentIndex: _selectedIndex,
        fixedColor: Colors.blue,
        onTap: _onItemTapped,
      ),
      floatingActionButton: FloatingActionButton( //悬浮按钮
          child: Icon(Icons.add),
          onPressed:_onAdd
      ),
    );
  }
  void _onItemTapped(int index) {
    setState(() {
      _selectedIndex = index;
    });
  }
  void _onAdd(){
  }
}

AppBar

AppBar是一个Material风格的导航栏,通过它可以设置导航栏标题、导航栏菜单、导航栏底部的Tab标题等。下面我们看看AppBar的定义:

AppBar({
  Key? key,
  this.leading, //导航栏最左侧Widget,常见为抽屉菜单按钮或返回按钮。
  this.automaticallyImplyLeading = true, //如果leading为null,是否自动实现默认的leading按钮
  this.title,// 页面标题
  this.actions, // 导航栏右侧菜单
  this.bottom, // 导航栏底部菜单,通常为Tab按钮组
  this.elevation = 4.0, // 导航栏阴影
  this.centerTitle, //标题是否居中 
  this.backgroundColor,
  ...   //其他属性见源码注释
})

抽屉菜单Drawer

ScaffolddrawerendDrawer属性可以分别接受一个Widget来作为页面的左、右抽屉菜单。如果开发者提供了抽屉菜单,那么当用户手指从屏幕左(或右)侧向里滑动时便可打开抽屉菜单。本节开始部分的示例中实现了一个左抽屉菜单MyDrawer,它的源码如下:

class MyDrawer extends StatelessWidget {
  const MyDrawer({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Drawer(
      child: MediaQuery.removePadding(
        context: context,
        //移除抽屉菜单顶部默认留白
        removeTop: true,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Padding(
              padding: const EdgeInsets.only(top: 38.0),
              child: Row(
                children: <Widget>[
                  Padding(
                    padding: const EdgeInsets.symmetric(horizontal: 16.0),
                    child: ClipOval(
                      child: Image.asset(
                        "imgs/avatar.png",
                        width: 80,
                      ),
                    ),
                  ),
                  Text(
                    "Wendux",
                    style: TextStyle(fontWeight: FontWeight.bold),
                  )
                ],
              ),
            ),
            Expanded(
              child: ListView(
                children: <Widget>[
                  ListTile(
                    leading: const Icon(Icons.add),
                    title: const Text('Add account'),
                  ),
                  ListTile(
                    leading: const Icon(Icons.settings),
                    title: const Text('Manage accounts'),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

可滚动组件

通常可滚动组件的子组件可能会非常多、占用的总高度也会非常大;如果要一次性将子组件全部构建出将会非常昂贵!为此,Flutter中提出一个Sliver(中文为“薄片”的意思)概念,Sliver 可以包含一个或多个子组件。Sliver 的主要作用是配合:加载子组件并确定每一个子组件的布局和绘制信息,如果 Sliver 可以包含多个子组件时,通常会实现按需加载模型。

只有当 Sliver 出现在视口中时才会去构建它,这种模型也称为“基于Sliver的列表按需加载模型”。可滚动组件中有很多都支持基于Sliver的按需加载模型,如ListViewGridView,但是也有不支持该模型的,如SingleChildScrollView

约定:后面如果我们说一个组件是Sliver 则表示它是基于Sliver布局的组件,同理,说一个组件是 RenderBox,则代表它是基于盒模型布局的组件,并不是说它就是 RenderBox 类的实例。

Flutter 中的可滚动主要由三个角色组成:Scrollable、Viewport 和 Sliver:

  • Scrollable :用于处理滑动手势,确定滑动偏移,滑动偏移变化时构建 Viewport 。
  • Viewport:显示的视窗,即列表的可视区域;
  • Sliver:视窗里显示的元素。

具体布局过程:

  1. Scrollable 监听到用户滑动行为后,根据最新的滑动偏移构建 Viewport 。
  2. Viewport 将当前视口信息和配置信息通过 SliverConstraints 传递给 Sliver。
  3. Sliver 中对子组件(RenderBox)按需进行构建和布局,然后确认自身的位置、绘制等信息,保存在 geometry 中(一个 SliverGeometry 类型的对象)。

Scrollable

用于处理滑动手势,确定滑动偏移,滑动偏移变化时构建 Viewport,我们看一下其关键的属性:

Scrollable({
  ...
  this.axisDirection = AxisDirection.down,
  this.controller,
  this.physics,
  required this.viewportBuilder, //后面介绍
})

Viewport

Viewport 比较简单,用于渲染当前视口中需要显示 Sliver。

Viewport({
  Key? key,
  this.axisDirection = AxisDirection.down,
  this.crossAxisDirection,
  this.anchor = 0.0,
  required ViewportOffset offset, // 用户的滚动偏移
  // 类型为Key,表示从什么地方开始绘制,默认是第一个元素
  this.center,
  this.cacheExtent, // 预渲染区域
  //该参数用于配合解释cacheExtent的含义,也可以为主轴长度的乘数
  this.cacheExtentStyle = CacheExtentStyle.pixel, 
  this.clipBehavior = Clip.hardEdge,
  List<Widget> slivers = const <Widget>[], // 需要显示的 Sliver 列表
})

Sliver

Sliver 主要作用是对子组件进行构建和布局,比如 ListView 的 Sliver 需要实现子组件(列表项)按需加载功能,只有当列表项进入预渲染区域时才会去对它进行构建和布局、渲染。

Sliver 对应的渲染对象类型是 RenderSliver,RenderSliver 和 RenderBox 的相同点是都继承自 RenderObject 类,不同点是在布局的时候约束信息不同。RenderBox 在布局时父组件传递给它的约束信息对应的是 BoxConstraints,只包含最大宽高的约束;而 RenderSliver 在布局时父组件(列表)传递给它的约束是对应的是 SliverConstraints。关于 Sliver 的布局协议,我们将在本章最后一节中介绍。

Scrollbar

Scrollbar是一个Material风格的滚动指示器(滚动条),如果要给可滚动组件添加滚动条,只需将Scrollbar作为可滚动组件的任意一个父级组件即可,如:

Scrollbar(
  child: SingleChildScrollView(
    ...
  ),
);

SingleChildScrollView

通常SingleChildScrollView只应在期望的内容不会超过屏幕太多时使用,这是因为SingleChildScrollView不支持基于 Sliver 的延迟加载模型,所以如果预计视口可能包含超出屏幕尺寸太多的内容时,那么使用SingleChildScrollView将会非常昂贵(性能差),此时应该使用一些支持Sliver延迟加载的可滚动组件,如ListView

ListView

ListView({
  ...  
  //可滚动widget公共参数
  Axis scrollDirection = Axis.vertical,
  bool reverse = false,
  ScrollController? controller,
  bool? primary,
  ScrollPhysics? physics,
  EdgeInsetsGeometry? padding,
  
  //ListView各个构造函数的共同参数  
  double? itemExtent,
  Widget? prototypeItem, //列表项原型,后面解释
  bool shrinkWrap = false,
  bool addAutomaticKeepAlives = true,
  bool addRepaintBoundaries = true,
  double? cacheExtent, // 预渲染区域长度
    
  //子widget列表
  List<Widget> children = const <Widget>[],
})

上面参数分为两组:第一组是可滚动组件的公共参数,本章第一节中已经介绍过,不再赘述;第二组是ListView各个构造函数(ListView有多个构造函数)的共同参数,我们重点来看看这些参数,:

  • itemExtent:该参数如果不为null,则会强制children的“长度”为itemExtent的值;这里的“长度”是指滚动方向上子组件的长度,也就是说如果滚动方向是垂直方向,则itemExtent代表子组件的高度;如果滚动方向为水平方向,则itemExtent就代表子组件的宽度。在ListView中,指定itemExtent比让子组件自己决定自身长度会有更好的性能,这是因为指定itemExtent后,滚动系统可以提前知道列表的长度,而无需每次构建子组件时都去再计算一下,尤其是在滚动位置频繁变化时(滚动系统需要频繁去计算列表高度)。
  • prototypeItem:如果我们知道列表中的所有列表项长度都相同但不知道具体是多少,这时我们可以指定一个列表项,该列表项被称为 prototypeItem(列表项原型)。指定 prototypeItem 后,可滚动组件会在 layout 时计算一次它延主轴方向的长度,这样也就预先知道了所有列表项的延主轴方向的长度,所以和指定 itemExtent 一样,指定 prototypeItem 会有更好的性能。注意,itemExtent 和prototypeItem 互斥,不能同时指定它们。
  • shrinkWrap:该属性表示是否根据子组件的总长度来设置ListView的长度,默认值为false 。默认情况下,ListView会在滚动方向尽可能多的占用空间。当ListView在一个无边界(滚动方向上)的容器中时,shrinkWrap必须为true
  • addAutomaticKeepAlives:该属性我们将在介绍 PageView 组件时详细解释。
  • addRepaintBoundaries:该属性表示是否将列表项(子组件)包裹在RepaintBoundary组件中。RepaintBoundary 读者可以先简单理解为它是一个”绘制边界“,将列表项包裹在RepaintBoundary中可以避免列表项不必要的重绘,但是当列表项重绘的开销非常小(如一个颜色块,或者一个较短的文本)时,不添加RepaintBoundary反而会更高效(具体原因会在本书后面 Flutter 绘制原理相关章节中介绍)。如果列表项自身来维护是否需要添加绘制边界组件,则此参数应该指定为 false。

注意:上面这些参数并非ListView特有,在本章后面介绍的其他可滚动组件也可能会拥有这些参数,它们的含义是相同的。

默认构造函数有一个children参数,它接受一个Widget列表(List)。这种方式适合只有少量的子组件数量已知且比较少的情况,反之则应该使用ListView.builder 按需动态构建列表项。

注意:虽然这种方式将所有children一次性传递给 ListView,但子组件)仍然是在需要时才会加载(build(如有)、布局、绘制),也就是说通过默认构造函数构建的 ListView 也是基于 Sliver 的列表懒加载模型。

ListView.builder

ListView.builder适合列表项比较多或者列表项不确定的情况,下面看一下ListView.builder的核心参数列表:

ListView.builder({
  // ListView公共参数已省略  
  ...
  required IndexedWidgetBuilder itemBuilder,
  int itemCount,
  ...
})
  • itemBuilder:它是列表项的构建器,类型为IndexedWidgetBuilder,返回值为一个widget。当列表滚动到具体的index位置时,会调用该构建器构建列表项。
  • itemCount:列表项的数量,如果为null,则为无限列表。

ListView.separated

ListView.separated可以在生成的列表项之间添加一个分割组件,它比ListView.builder多了一个separatorBuilder参数,该参数是一个分割组件生成器

滚动监听及控制

ScrollController

ScrollController构造函数如下:

ScrollController({
  double initialScrollOffset = 0.0, //初始滚动位置
  this.keepScrollOffset = true,//是否保存滚动位置
  ...
})

ScrollController间接继承自Listenable,我们可以根据ScrollController来监听滚动事件,如:

controller.addListener(()=>print(controller.offset))

滚动位置恢复

PageStorage是一个用于保存页面(路由)相关数据的组件,它并不会影响子树的UI外观,其实,PageStorage是一个功能型组件,它拥有一个存储桶(bucket),子树中的Widget可以通过指定不同的PageStorageKey来存储各自的数据或状态。

每次滚动结束,可滚动组件都会将滚动位置offset存储到PageStorage中,当可滚动组件重新创建时再恢复。如果ScrollController.keepScrollOffsetfalse,则滚动位置将不会被存储,可滚动组件重新创建时会使用ScrollController.initialScrollOffsetScrollController.keepScrollOffsettrue时,可滚动组件在第一次创建时,会滚动到initialScrollOffset处,因为这时还没有存储过滚动位置。在接下来的滚动中就会存储、恢复滚动位置,而initialScrollOffset会被忽略。

当一个路由中包含多个可滚动组件时,如果你发现在进行一些跳转或切换操作后,滚动位置不能正确恢复,这时你可以通过显式指定PageStorageKey来分别跟踪不同的可滚动组件的位置,如:

ListView(key: PageStorageKey(1), ... );
...
ListView(key: PageStorageKey(2), ... );

滚动监听

滚动通知

Flutter Widget树中子Widget可以通过发送通知(Notification)与父(包括祖先)Widget通信。父级组件可以通过NotificationListener组件来监听自己关注的通知,这种通信方式类似于Web开发中浏览器的事件冒泡,我们在Flutter中沿用“冒泡”这个术语,关于通知冒泡我们将在后面“事件处理与通知”一章中详细介绍。

可滚动组件在滚动时会发送ScrollNotification类型的通知,ScrollBar正是通过监听滚动通知来实现的。通过NotificationListener监听滚动事件和通过ScrollController有两个主要的不同:

  1. 通过NotificationListener可以在从可滚动组件到widget树根之间任意位置都能监听。而ScrollController只能和具体的可滚动组件关联后才可以。
  2. 收到滚动事件后获得的信息不同;NotificationListener在收到滚动事件时,通知中会携带当前滚动位置和ViewPort的一些信息,而ScrollController只能获取当前滚动位置。

实例

下面,我们监听ListView的滚动通知,然后显示当前滚动进度百分比:

import 'package:flutter/material.dart';

class ScrollNotificationTestRoute extends StatefulWidget {
  @override
  _ScrollNotificationTestRouteState createState() =>
      _ScrollNotificationTestRouteState();
}

class _ScrollNotificationTestRouteState
    extends State<ScrollNotificationTestRoute> {
  String _progress = "0%"; //保存进度百分比

  @override
  Widget build(BuildContext context) {
    return Scrollbar(
      //进度条
      // 监听滚动通知
      child: NotificationListener<ScrollNotification>(
        onNotification: (ScrollNotification notification) {
          double progress = notification.metrics.pixels /
              notification.metrics.maxScrollExtent;
          //重新构建
          setState(() {
            _progress = "${(progress * 100).toInt()}%";
          });
          print("BottomEdge: ${notification.metrics.extentAfter == 0}");
          return false;
          //return true; //放开此行注释后,进度条将失效
        },
        child: Stack(
          alignment: Alignment.center,
          children: <Widget>[
            ListView.builder(
              itemCount: 100,
              itemExtent: 50.0,
              itemBuilder: (context, index) => ListTile(title: Text("$index")),
            ),
            CircleAvatar(
              //显示进度百分比
              radius: 30.0,
              child: Text(_progress),
              backgroundColor: Colors.black54,
            )
          ],
        ),
      ),
    );
  }
}

GridView

SliverGridDelegate是一个抽象类,定义了GridView Layout相关接口,子类需要通过实现它们来实现具体的布局算法。Flutter中提供了两个SliverGridDelegate的子类SliverGridDelegateWithFixedCrossAxisCountSliverGridDelegateWithMaxCrossAxisExtent

SliverGridDelegateWithFixedCrossAxisCount

该子类实现了一个横轴为固定数量子元素的layout算法,其构造函数为:

SliverGridDelegateWithFixedCrossAxisCount({
  @required double crossAxisCount, 
  double mainAxisSpacing = 0.0,
  double crossAxisSpacing = 0.0,
  double childAspectRatio = 1.0,
})
  • crossAxisCount:横轴子元素的数量。此属性值确定后子元素在横轴的长度就确定了,即ViewPort横轴长度除以crossAxisCount的商。
  • mainAxisSpacing:主轴方向的间距。
  • crossAxisSpacing:横轴方向子元素的间距。
  • childAspectRatio:子元素在横轴长度和主轴长度的比例。由于crossAxisCount指定后,子元素横轴长度就确定了,然后通过此参数值就可以确定子元素在主轴的长度。
class InfiniteGridView extends StatefulWidget {
  @override
  _InfiniteGridViewState createState() => _InfiniteGridViewState();
}

class _InfiniteGridViewState extends State<InfiniteGridView> {
  List<IconData> _icons = []; //保存Icon数据

  @override
  void initState() {
    super.initState();
    // 初始化数据
    _retrieveIcons();
  }

  @override
  Widget build(BuildContext context) {
    return GridView.builder(
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 3, //每行三列
        childAspectRatio: 1.0, //显示区域宽高相等
      ),
      itemCount: _icons.length,
      itemBuilder: (context, index) {
        //如果显示到最后一个并且Icon总数小于200时继续获取数据
        if (index == _icons.length - 1 && _icons.length < 200) {
          _retrieveIcons();
        }
        return Icon(_icons[index]);
      },
    );
  }

  //模拟异步获取数据
  void _retrieveIcons() {
    Future.delayed(Duration(milliseconds: 200)).then((e) {
      setState(() {
        _icons.addAll([
          Icons.ac_unit,
          Icons.airport_shuttle,
          Icons.all_inclusive,
          Icons.beach_access,
          Icons.cake,
          Icons.free_breakfast,
        ]);
      });
    });
  }
}

PageView

如果要实现页面切换和 Tab 布局,我们可以使用 PageView 组件。需要注意,PageView 是一个非常重要的组件,因为在移动端开发中很常用,比如大多数 App 都包含 Tab 换页效果、图片轮动以及抖音上下滑页切换视频功能等等,这些都可以通过 PageView 轻松实现

ageView({
  Key? key,
  this.scrollDirection = Axis.horizontal, // 滑动方向
  this.reverse = false,
  PageController? controller,
  this.physics,
  List<Widget> children = const <Widget>[],
  this.onPageChanged,
  
  //每次滑动是否强制切换整个页面,如果为false,则会根据实际的滑动距离显示页面
  this.pageSnapping = true,
  //主要是配合辅助功能用的,后面解释
  this.allowImplicitScrolling = false,
  //后面解释
  this.padEnds = true,
})

AutomaticKeepAlive 的组件的主要作用是将列表项的根 RenderObject 的 keepAlive 按需自动标记 为 true 或 false。为了方便叙述,我们可以认为根 RenderObject 对应的组件就是列表项的根 Widget,代表整个列表项组件,同时我们将列表组件的 Viewport区域 + cacheExtent(预渲染区域)称为加载区域

  1. 当 keepAlive 标记为 false 时,如果列表项滑出加载区域时,列表组件将会被销毁。
  2. 当 keepAlive 标记为 true 时,当列表项滑出加载区域后,Viewport 会将列表组件缓存起来;当列表项进入加载区域时,Viewport 从先从缓存中查找是否已经缓存,如果有则直接复用,如果没有则重新创建列表项。

那么 AutomaticKeepAlive 什么时候会将列表项的 keepAlive 标记为 true 或 false 呢?答案是开发者说了算!Flutter 中实现了一套类似 C/S 的机制,AutomaticKeepAlive 就类似一个 Server,它的子组件可以是 Client,这样子组件想改变是否需要缓存的状态时就向 AutomaticKeepAlive 发一个通知消息(KeepAliveNotification),AutomaticKeepAlive 收到消息后会去更改 keepAlive 的状态,如果有必要同时做一些资源清理的工作(比如 keepAlive 从 true 变为 false 时,要释放缓存)。

我们基于上一节 PageView 示例,实现页面缓存,根据上面的描述实现思路就很简单了:让Page 页变成一个 AutomaticKeepAlive Client 即可。为了便于开发者实现,Flutter 提供了一个 AutomaticKeepAliveClientMixin ,我们只需要让 PageState 混入这个 mixin,且同时添加一些必要操作即可:

class _PageState extends State<Page> with AutomaticKeepAliveClientMixin {

  @override
  Widget build(BuildContext context) {
    super.build(context); // 必须调用
    return Center(child: Text("${widget.text}", textScaleFactor: 5));
  }

  @override
  bool get wantKeepAlive => true; // 是否需要缓存
}

TabBarView

TabBarView 是 Material 组件库中提供了 Tab 布局组件,通常和 TabBar 配合使用。

TabBarView 封装了 PageView,它的构造方法很简单

 TabBarView({
  Key? key,
  required this.children, // tab 页
  this.controller, // TabController
  this.physics,
  this.dragStartBehavior = DragStartBehavior.start,
}) 

TabBar 为 TabBarView 的导航标题

CustomScrollView

Flutter 提供了一个 CustomScrollView 组件来帮助我们创建一个公共的 Scrollable 和 Viewport ,然后它的 slivers 参数接受一个 Sliver 数组,这样我们就可以使用CustomScrollView 方面的实现我们期望的功能了:

Widget buildTwoSliverList() {
  // SliverFixedExtentList 是一个 Sliver,它可以生成高度相同的列表项。
  // 再次提醒,如果列表项高度相同,我们应该优先使用SliverFixedExtentList 
  // 和 SliverPrototypeExtentList,如果不同,使用 SliverList.
  var listView = SliverFixedExtentList(
    itemExtent: 56, //列表项高度固定
    delegate: SliverChildBuilderDelegate(
      (_, index) => ListTile(title: Text('$index')),
      childCount: 10,
    ),
  );
  // 使用
  return CustomScrollView(
    slivers: [
      listView,
      listView,
    ],
  );
}

SliverToBoxAdapter

在实际布局中,我们通常需要往 CustomScrollView 中添加一些自定义的组件,而这些组件并非都有 Sliver 版本,为此 Flutter 提供了一个 SliverToBoxAdapter 组件,它是一个适配器:可以将 RenderBox 适配为 Sliver。比如我们想在列表顶部添加一个可以横向滑动的 PageView,可以使用 SliverToBoxAdapter 来配置:

CustomScrollView(
  slivers: [
    SliverToBoxAdapter(
      child: SizedBox(
        height: 300,
        child: PageView(
          children: [Text("1"), Text("2")],
        ),
      ),
    ),
    buildSliverFixedList(),
  ],
);

注意,上面的代码是可以正常运行的,但是如果将 PageView 换成一个滑动方向和 CustomScrollView 一致的 ListView 则不会正常工作!原因是:CustomScrollView 组合 Sliver 的原理是为所有子 Sliver 提供一个共享的 Scrollable,然后统一处理指定滑动方向的滑动事件,如果 Sliver 中引入了其他的 Scrollable,则滑动事件便会冲突。上例中 PageView 之所以能正常工作,是因为 PageView 的 Scrollable 只处理水平方向的滑动,而 CustomScrollView 是处理垂直方向的,两者并未冲突,所以不会有问题,但是换一个也是垂直方向的 ListView 时则不能正常工作,最终的效果是,在ListView内滑动时只会对ListView 起作用,原因是滑动事件被 ListView 的 Scrollable 优先消费,CustomScrollView 的 Scrollable 便接收不到滑动事件了。

Flutter 中手势的冲突时,默认的策略是子元素生效,这个我们将在后面事件处理相关章节介绍。

所以我们可以得出一个结论:如果 CustomScrollView 有孩子也是一个完整的可滚动组件且它们的滑动方向一致,则 CustomScrollView 不能正常工作。要解决这个问题,可以使用 NestedScrollView

NestedScrollView

  1. NestedScrollView 整体就是一个 CustomScrollView (实际上是 CustomScrollView 的一个子类)
  2. header 和 body 都是 CustomScrollView 的子 Sliver ,注意,虽然 body 是一个 RenderBox,但是它会被包装为 Sliver 。
  3. CustomScrollView 将其所有子 Sliver 在逻辑上分为 header 和 body 两部分:header 是前面部分、body 是后面部分。
  4. 当 body 是一个可滚动组件时, 它和 CustomScrollView 分别有一个 Scrollable ,由于 body 在 CustomScrollView 的内部,所以称其为内部可滚动组件,称 CustomScrollView 为外部可滚动组件;同时 因为 header 部分是 Sliver,所以没有独立的 Scrollable,滑动时是受 CustomScrollView 的 Scrollable 控制,所以为了区分,可以称 header 为外部可滚动组件(Flutter 文档中是这么约定的)。
  5. NestedScrollView 核心功能就是通过一个协调器来协调外部可滚动组件和内部可滚动组件的滚动,以使滑动效果连贯统一,协调器的实现原理就是分别给内外可滚动组件分别设置一个 controller,然后通过这两个controller 来协调控制它们的滚动。

导航返回拦截(WillPopScope)

import 'package:flutter/material.dart';

class WillPopScopeTestRoute extends StatefulWidget {
  @override
  WillPopScopeTestRouteState createState() {
    return WillPopScopeTestRouteState();
  }
}

class WillPopScopeTestRouteState extends State<WillPopScopeTestRoute> {
  DateTime? _lastPressedAt; //上次点击时间

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
      onWillPop: () async {
        if (_lastPressedAt == null ||
            DateTime.now().difference(_lastPressedAt!) > Duration(seconds: 1)) {
          //两次点击间隔超过1秒则重新计时
          _lastPressedAt = DateTime.now();
          return false;
        }
        return true;
      },
      child: Container(
        alignment: Alignment.center,
        child: Text("1秒内连续按两次返回键退出"),
      ),
    );
  }
}

InheritedWidget

InheritedWidget是 Flutter 中非常重要的一个功能型组件,它提供了一种在 widget 树中从上到下共享数据的方式,比如我们在应用的根 widget 中通过InheritedWidget共享了一个数据,那么我们便可以在任意子widget 中来获取该共享的数据!这个特性在一些需要在整个 widget 树中共享数据的场景中非常方便!如Flutter SDK中正是通过 InheritedWidget 来共享应用主题(Theme)和 Locale (当前语言环境)信息的 实际上,我们只想更新子树中依赖了ShareDataWidget的组件,而现在只要调用_InheritedWidgetTestRouteStatesetState()方法,所有子节点都会被重新build,这很没必要,那么有什么办法可以避免呢?答案是缓存!一个简单的做法就是通过封装一个StatefulWidget,将子Widget树缓存起来,具体做法下一节我们将通过实现一个Provider Widget 来演示如何缓存,以及如何利用InheritedWidget 来实现 Flutter 全局状态共享

颜色和主题

Color(0xffdc380d); //如果颜色固定可以直接使用整数值
//颜色是一个字符串变量
var c = "dc380d";
Color(int.parse(c,radix:16)|0xFF000000) //通过位运算符将Alpha设置为FF
Color(int.parse(c,radix:16)).withAlpha(255)  //通过方法将Alpha设置为FF

ThemeData用于保存是Material 组件库的主题数据,Material组件需要遵守相应的设计规范,而这些规范可自定义部分都定义在ThemeData中了,所以我们可以通过ThemeData来自定义应用主题。在子组件中,我们可以通过Theme.of方法来获取当前的ThemeData

按需rebuild(ValueListenableBuilder)

class ValueListenableRoute extends StatefulWidget {
  const ValueListenableRoute({Key? key}) : super(key: key);

  @override
  State<ValueListenableRoute> createState() => _ValueListenableState();
}

class _ValueListenableState extends State<ValueListenableRoute> {
  // 定义一个ValueNotifier,当数字变化时会通知 ValueListenableBuilder
  final ValueNotifier<int> _counter = ValueNotifier<int>(0);
  static const double textScaleFactor = 1.5;

  @override
  Widget build(BuildContext context) {
    // 添加 + 按钮不会触发整个 ValueListenableRoute 组件的 build
    print('build');
    return Scaffold(
      appBar: AppBar(title: Text('ValueListenableBuilder 测试')),
      body: Center(
        child: ValueListenableBuilder<int>(
          builder: (BuildContext context, int value, Widget? child) {
            // builder 方法只会在 _counter 变化时被调用
            return Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                child!,
                Text('$value 次',textScaleFactor: textScaleFactor),
              ],
            );
          },
          valueListenable: _counter,
          // 当子组件不依赖变化的数据,且子组件收件开销比较大时,指定 child 属性来缓存子组件非常有用
          child: const Text('点击了 ', textScaleFactor: textScaleFactor),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.add),
        // 点击后值 +1,触发 ValueListenableBuilder 重新构建
        onPressed: () => _counter.value += 1,
      ),
    );
  }
}

异步UI更新(FutureBuilder、StreamBuilder)

Widget build(BuildContext context) {
  return Center(
    child: FutureBuilder<String>(
      future: mockNetworkData(),
      builder: (BuildContext context, AsyncSnapshot snapshot) {
        // 请求已结束
        if (snapshot.connectionState == ConnectionState.done) {
          if (snapshot.hasError) {
            // 请求失败,显示错误
            return Text("Error: ${snapshot.error}");
          } else {
            // 请求成功,显示数据
            return Text("Contents: ${snapshot.data}");
          }
        } else {
          // 请求未结束,显示loading
          return CircularProgressIndicator();
        }
      },
    ),
  );
}

使用对话框

实际上AlertDialogSimpleDialog都使用了Dialog类。由于AlertDialogSimpleDialog中使用了IntrinsicWidth来尝试通过子组件的实际尺寸来调整自身尺寸,这就导致他们的子组件不能是延迟加载模型的组件(如ListViewGridView 、 CustomScrollView等)

Future<void> showListDialog() async {
  int? index = await showDialog<int>(
    context: context,
    builder: (BuildContext context) {
      var child = Column(
        children: <Widget>[
          ListTile(title: Text("请选择")),
          Expanded(
              child: ListView.builder(
            itemCount: 30,
            itemBuilder: (BuildContext context, int index) {
              return ListTile(
                title: Text("$index"),
                onTap: () => Navigator.of(context).pop(index),
              );
            },
          )),
        ],
      );
      //使用AlertDialog会报错
      //return AlertDialog(content: child);
      return Dialog(child: child);
    },
  );
  if (index != null) {
    print("点击了:$index");
  }
}

对话框状态管理

Row(
  children: <Widget>[
    Text("同时删除子目录?"),
    //使用StatefulBuilder来构建StatefulWidget上下文
    StatefulBuilder(
      builder: (context, _setState) {
        return Checkbox(
          value: _withTree, //默认不选中
          onChanged: (bool value) {
            //_setState方法实际就是该StatefulWidget的setState方法,
            //调用后builder方法会重新被调用
            _setState(() {
              //更新选中状态
              _withTree = !_withTree;
            });
          },
        );
      },
    ),
  ],
),

是否还有更简单的解决方案呢?要确认这个问题,我们就得先搞清楚UI是怎么更新的,我们知道在调用setState方法后StatefulWidget就会重新build,那setState方法做了什么呢?我们能不能从中找到方法?顺着这个思路,我们就得看一下setState的核心源码:

void setState(VoidCallback fn) {
  ... //省略无关代码
  _element.markNeedsBuild();
}

1
2
3
4

可以发现,setState中调用了ElementmarkNeedsBuild()方法,我们前面说过,Flutter是一个响应式框架,要更新UI只需改变状态后通知框架页面需要重构即可,而ElementmarkNeedsBuild()方法正是来实现这个功能的!markNeedsBuild()方法会将当前的Element对象标记为“dirty”(脏的),在每一个Frame,Flutter都会重新构建被标记为“dirty”Element对象。既然如此,我们有没有办法获取到对话框内部UI的Element对象,然后将其标示为为“dirty”呢?答案是肯定的!我们可以通过Context来得到Element对象,至于ElementContext的关系我们将会在后面“Flutter核心原理”一章中再深入介绍,现在只需要简单的认为:在组件树中,context实际上就是Element对象的引用。知道这个后,那么解决的方案就呼之欲出了,我们可以通过如下方式来让复选框可以更新:

Future<bool?> showDeleteConfirmDialog4() {
  bool _withTree = false;
  return showDialog<bool>(
    context: context,
    builder: (context) {
      return AlertDialog(
        title: Text("提示"),
        content: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            Text("您确定要删除当前文件吗?"),
            Row(
              children: <Widget>[
                Text("同时删除子目录?"),
                Checkbox( // 依然使用Checkbox组件
                  value: _withTree,
                  onChanged: (bool value) {
                    // 此时context为对话框UI的根Element,我们 
                    // 直接将对话框UI对应的Element标记为dirty
                    (context as Element).markNeedsBuild();
                    _withTree = !_withTree;
                  },
                ),
              ],
            ),
          ],
        ),

当然上面的代码并不是最优,因为我们只需要更新复选框的状态,而此时的context我们用的是对话框的根context,所以会导致整个对话框UI组件全部rebuild,因此最好的做法是将context的“范围”缩小,也就是说只将Checkbox的Element标记为dirty,优化后的代码为:

... //省略无关代码
Row(
  children: <Widget>[
    Text("同时删除子目录?"),
    // 通过Builder来获得构建Checkbox的`context`,
    // 这是一种常用的缩小`context`范围的方式
    Builder(
      builder: (BuildContext context) {
        return Checkbox(
          value: _withTree,
          onChanged: (bool value) {
            (context as Element).markNeedsBuild();
            _withTree = !_withTree;
          },
        );
      },
    ),
  ],
),

底部菜单列表

showModalBottomSheet方法可以弹出一个Material风格的底部菜单列表模态对话框,示例如下:

// 弹出底部菜单列表模态对话框
Future<int?> _showModalBottomSheet() {
  return showModalBottomSheet<int>(
    context: context,
    builder: (BuildContext context) {
      return ListView.builder(
        itemCount: 30,
        itemBuilder: (BuildContext context, int index) {
          return ListTile(
            title: Text("$index"),
            onTap: () => Navigator.of(context).pop(index),
          );
        },
      );
    },
  );
}

Loading框

其实Loading框可以直接通过showDialog+AlertDialog来自定义:

showLoadingDialog() {
  showDialog(
    context: context,
    barrierDismissible: false, //点击遮罩不关闭对话框
    builder: (context) {
      return AlertDialog(
        content: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            CircularProgressIndicator(),
            Padding(
              padding: const EdgeInsets.only(top: 26.0),
              child: Text("正在加载,请稍后..."),
            )
          ],
        ),
      );
    },
  );
}

Loading框

UnconstrainedBox(
  constrainedAxis: Axis.vertical,
  child: SizedBox(
    width: 280,
    child: AlertDialog(
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          CircularProgressIndicator(value: .8,),
          Padding(
            padding: const EdgeInsets.only(top: 26.0),
            child: Text("正在加载,请稍后..."),
          )
        ],
      ),
    ),
  ),
);

日历选择器

Future<DateTime?> _showDatePicker1() {
  var date = DateTime.now();
  return showDatePicker(
    context: context,
    initialDate: date,
    firstDate: date,
    lastDate: date.add( //未来30天可选
      Duration(days: 30),
    ),
  );
}

参考:《Flutter实战·第二版》