Material组件库提供了丰富多样的组件,本节介绍一些常用的组件,其余的读者可以自行查看文档或Flutter Gallery中Material组件部分的示例。

Flutter Gallery是Flutter官方提供的Flutter Demo,源码位于flutter源码中的examples目录下,笔者强烈建议用户将Flutter Gallery示例跑起来,它是一个很全面的Flutter示例应用,是非常好的参考Demo,也是笔者学习Flutter的第一手资料。

Scaffold

一个完整的路由页可能会包含导航栏抽屉菜单(Drawer)以及底部Tab导航菜单等。如果每个路由页面都需要开发者自己手动去实现这些,这会是一件非常麻烦且无聊的事。幸运的是,Flutter Material组件库提供了一些现成的组件来减少我们的开发任务。Scaffold是一个路由页的骨架,我们使用它可以很容易地拼装出一个完整的页面。

我们实现一个页面,它包含:

  1. 一个导航栏
  2. 导航栏右边有一个分享按钮
  3. 有一个抽屉菜单
  4. 有一个底部导航
  5. 右下角有一个悬浮的动作按钮

最终效果如图5-18、图5-19所示:
Scaffold、TabBar、底部导航 - 图1 Scaffold、TabBar、底部导航 - 图2
实现代码如下:

  1. import 'package:flutter/material.dart';
  2. // import 'dart:math' as math;
  3. void main() => runApp(new MyApp());
  4. class MyApp extends StatelessWidget {
  5. @override
  6. Widget build(BuildContext context) {
  7. return MaterialApp(
  8. debugShowCheckedModeBanner: false,
  9. title: '装饰容器DecoratedBox',
  10. theme: ThemeData(
  11. primarySwatch: Colors.blue,
  12. ),
  13. home: ScaffoldRoute());
  14. }
  15. }
  16. class ScaffoldRoute extends StatefulWidget {
  17. @override
  18. _ScaffoldRouteState createState() => _ScaffoldRouteState();
  19. }
  20. class _ScaffoldRouteState extends State<ScaffoldRoute> {
  21. int _selectedIndex = 1;
  22. @override
  23. Widget build(BuildContext context) {
  24. return Scaffold(
  25. appBar: AppBar(
  26. //导航栏
  27. title: Text("App Name"),
  28. actions: <Widget>[
  29. //导航栏右侧菜单
  30. IconButton(icon: Icon(Icons.share), onPressed: () {}),
  31. ],
  32. ),
  33. drawer: new MyDrawer(), //抽屉
  34. bottomNavigationBar: BottomNavigationBar(
  35. // 底部导航
  36. items: <BottomNavigationBarItem>[
  37. BottomNavigationBarItem(icon: Icon(Icons.home), title: Text('Home')),
  38. BottomNavigationBarItem(
  39. icon: Icon(Icons.business), title: Text('Business')),
  40. BottomNavigationBarItem(
  41. icon: Icon(Icons.school), title: Text('School')),
  42. ],
  43. currentIndex: _selectedIndex,
  44. fixedColor: Colors.blue,
  45. onTap: _onItemTapped,
  46. ),
  47. floatingActionButton: FloatingActionButton(
  48. //悬浮按钮
  49. child: Icon(Icons.add),
  50. onPressed: _onAdd),
  51. );
  52. }
  53. void _onItemTapped(int index) {
  54. setState(() {
  55. _selectedIndex = index;
  56. });
  57. }
  58. void _onAdd() {}
  59. }
  60. class MyDrawer extends StatelessWidget {
  61. const MyDrawer({
  62. Key key,
  63. }) : super(key: key);
  64. @override
  65. Widget build(BuildContext context) {
  66. return Drawer(
  67. child: MediaQuery.removePadding(
  68. context: context,
  69. //移除抽屉菜单顶部默认留白
  70. removeTop: true,
  71. child: Column(
  72. crossAxisAlignment: CrossAxisAlignment.start,
  73. children: <Widget>[
  74. Padding(
  75. padding: const EdgeInsets.only(top: 38.0),
  76. child: Row(
  77. children: <Widget>[
  78. Padding(
  79. padding: const EdgeInsets.symmetric(horizontal: 16.0),
  80. child: ClipOval(
  81. child: Image.asset(
  82. "imgs/avatar.png",
  83. width: 80,
  84. ),
  85. ),
  86. ),
  87. Text(
  88. "Wendux",
  89. style: TextStyle(fontWeight: FontWeight.bold),
  90. )
  91. ],
  92. ),
  93. ),
  94. Expanded(
  95. child: ListView(
  96. children: <Widget>[
  97. ListTile(
  98. leading: const Icon(Icons.add),
  99. title: const Text('Add account'),
  100. ),
  101. ListTile(
  102. leading: const Icon(Icons.settings),
  103. title: const Text('Manage accounts'),
  104. ),
  105. ],
  106. ),
  107. ),
  108. ],
  109. ),
  110. ),
  111. );
  112. }
  113. }

上面代码中我们用到了如下组件:

组件名称 解释
AppBar 一个导航栏骨架
MyDrawer 抽屉菜单
BottomNavigationBar 底部导航栏
FloatingActionButton 漂浮按钮

AppBar

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

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

如果给Scaffold添加了抽屉菜单,默认情况下Scaffold会自动将AppBar的leading设置为菜单按钮(如上面截图所示),点击它便可打开抽屉菜单。如果我们想自定义菜单图标,可以手动来设置leading,如:

  1. Scaffold(
  2. appBar: AppBar(
  3. title: Text("App Name"),
  4. leading: Builder(builder: (context) {
  5. return IconButton(
  6. icon: Icon(Icons.dashboard, color: Colors.white), //自定义图标
  7. onPressed: () {
  8. // 打开抽屉菜单
  9. Scaffold.of(context).openDrawer();
  10. },
  11. );
  12. }),
  13. ...
  14. )

代码运行效果如图5-20所示:
Scaffold、TabBar、底部导航 - 图3
可以看到左侧菜单已经替换成功。
代码中打开抽屉菜单的方法在ScaffoldState中,通过Scaffold.of(context)可以获取父级最近的Scaffold组件的State对象
**

AppBar和自制AppBar做比较

1596253868782-ff74ac33-a8ca-448f-9147-8fa7e65503f5_meitu_1.jpg

leading 左上角的控件,一般放一个icon,位置如上图
title 标题,位置如上图
actions 一系列的组件,位置如上图
flexibleSpace 此小组件堆叠在工具栏和标签栏后面。它的高度与应用栏的整体高度相同
bottom 位置如上图
elevation 阴影Z轴
backgroundColor 背景颜色
brightness 亮度
iconTheme 图标样式(用于应用栏图标的颜色,不透明度和大小。通常,这与backgroundColorbrightnesstextTheme一起设置。)
textTheme 字体样式
centerTitle title是否显示在中间
automaticallyImplyLeading 如果为true且leading为null,则自动尝试推断出主要小部件应该是什么。如果为false且leading为null,则为title提供前导空格。如果leading小部件不为null,则此参数无效。
toolbarOpacity 应用栏的工具栏部分是多么不透明。
bottomOpacity 应用栏底部的不透明程度。
primary true 此应用栏是否显示在屏幕顶部。
titleSpacing 横轴上标题内容 周围的间距。即使没有前导内容或操作,也会应用此间距。如果希望 title占用所有可用空间,请将此值设置为0.0
  1. import 'package:flutter/material.dart';
  2. void main() => runApp(new MyApp());
  3. class MyApp extends StatelessWidget {
  4. @override
  5. Widget build(BuildContext context) {
  6. return MaterialApp(
  7. debugShowCheckedModeBanner: true,
  8. title: '主题测试',
  9. theme: ThemeData(
  10. primaryColor: Colors.red,
  11. textTheme: TextTheme(
  12. display4: TextStyle(color: Colors.pink, fontSize: 24.0))),
  13. home: Scaffold(
  14. // appBar: AppBar(title: Text("主题测试")),
  15. body: ThemeTestRoute(),
  16. ));
  17. }
  18. }
  19. class ThemeTestRoute extends StatefulWidget {
  20. @override
  21. _ThemeTestRouteState createState() => new _ThemeTestRouteState();
  22. }
  23. class _ThemeTestRouteState extends State<ThemeTestRoute> {
  24. Color _themeColor = Colors.teal; //当前路由主题色
  25. @override
  26. Widget build(BuildContext context) {
  27. ThemeData themeData = Theme.of(context);
  28. return Theme(
  29. data: ThemeData(
  30. primaryColor: _themeColor, //用于导航栏、FloatingActionButton的背景色等
  31. iconTheme: IconThemeData(color: _themeColor) //用于Icon颜色
  32. ),
  33. child: Scaffold(
  34. appBar: AppBar(title: Text("主题测试")),
  35. body: Column(
  36. mainAxisAlignment: MainAxisAlignment.center,
  37. children: <Widget>[
  38. //第一行Icon使用主题中的iconTheme
  39. Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
  40. Icon(Icons.favorite),
  41. Icon(Icons.airport_shuttle),
  42. Text(" 颜色跟随主题")
  43. ]),
  44. //为第二行Icon自定义颜色(固定为黑色)
  45. Theme(
  46. data: themeData.copyWith(
  47. iconTheme: themeData.iconTheme.copyWith(color: Colors.black),
  48. ),
  49. child: Row(
  50. mainAxisAlignment: MainAxisAlignment.center,
  51. children: <Widget>[
  52. Icon(Icons.favorite),
  53. Icon(Icons.airport_shuttle),
  54. Text(
  55. " 颜色固定黑色",
  56. style: themeData.textTheme.display4,
  57. )
  58. ]),
  59. ),
  60. Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
  61. Icon(Icons.favorite),
  62. Icon(Icons.airport_shuttle),
  63. Text(" 颜色固定黑色")
  64. ])
  65. ],
  66. ),
  67. floatingActionButton: FloatingActionButton(
  68. onPressed: () => //切换主题
  69. setState(() => _themeColor =
  70. _themeColor == Colors.teal ? Colors.blue : Colors.teal),
  71. child: Icon(Icons.palette),
  72. backgroundColor: Theme.of(context).primaryColor),
  73. ),
  74. );
  75. }
  76. }

TabBar

下面我们通过“bottom”属性来添加一个导航栏底部Tab按钮组,将要实现的效果如图5-21所示:
Scaffold、TabBar、底部导航 - 图5
Material组件库中提供了一个TabBar组件,它可以快速生成Tab菜单,下面是上图对应的源码:

  1. class ScaffoldRoute extends StatefulWidget {
  2. @override
  3. _ScaffoldRouteState createState() => _ScaffoldRouteState();
  4. }
  5. class _ScaffoldRouteState extends State<ScaffoldRoute>
  6. with SingleTickerProviderStateMixin {
  7. TabController _tabController; //需要定义一个Controller
  8. List<Map<String, String>> tabs = [
  9. {'tabName': '新闻','description':'这是新闻频道'}
  10. ]
  11. ..add({'tabName': '历史','description':'这是历史频道'})
  12. ..add({'tabName': '主播','description':'这是主播频道'})
  13. ..add({'tabName': "博客",'description':'这是博客频道'})
  14. ..add({'tabName': "头条",'description':'这是头条频道'})
  15. ..add({'tabName': "体育",'description':'这是体育频道'});
  16. @override
  17. void initState() {
  18. super.initState();
  19. // 创建Controller
  20. _tabController = TabController(length: tabs.length, vsync: this);
  21. }
  22. @override
  23. Widget build(BuildContext context) {
  24. return Scaffold(
  25. appBar: AppBar(
  26. leading: IconButton(icon: Icon(Icons.receipt), onPressed: () {}),
  27. actions: <Widget>[
  28. //导航栏右侧菜单
  29. IconButton(icon: Icon(Icons.share), onPressed: () {}),
  30. ],
  31. title: Text('标题'),
  32. bottom: TabBar(
  33. //生成Tab菜单
  34. controller: _tabController,
  35. tabs: tabs.map((item) => Tab(text: item['tabName'])).toList()),
  36. ),
  37. body: TabBarView(
  38. controller: _tabController,
  39. children: tabs.map((item) => Text(item['description'])).toList()),
  40. // children: <Widget>[
  41. // Text('1111'),
  42. // Text('2222'),
  43. // Text('3333')
  44. // ],
  45. );
  46. }
  47. }

上面代码首先创建了一个TabController ,它是用于控制/监听Tab菜单切换的
接下来通过TabBar生成了一个底部菜单栏,TabBar的tabs属性接受一个Widget数组,表示每一个Tab子菜单,我们可以自定义,也可以像示例中一样直接使用TabBar 组件,它是Material组件库提供的Material风格的TabBar菜单。
TabBar组件有三个可选参数,除了可以指定文字外,还可以指定Tab菜单图标,或者直接自定义组件样式。Tab组件定义如下:

  1. Tab({
  2. Key key,
  3. this.text, // 菜单文本
  4. this.icon, // 菜单图标
  5. this.child, // 自定义组件样式
  6. })

开发者可以根据实际需求来定制

TabBarView

通过TabBar我们只能生成一个静态的菜单,真正的Tab页还没有实现。由于Tab菜单和Tab页的切换需要同步,我们需要通过TabController去监听Tab菜单的切换去切换Tab页,代码如:

  1. _tabController.addListener((){
  2. switch(_tabController.index){
  3. case 1: ...;
  4. case 2: ... ;
  5. }
  6. });

如果我们Tab页可以滑动切换的话,还需要在滑动过程中更新TabBar指示器的偏移!显然,要手动处理这些是很麻烦的,为此,Material库提供了一个TabBarView组件,通过它不仅可以轻松的实现Tab页,而且可以非常容易的配合TabBar来实现同步切换和滑动状态同步,示例如下:

  1. Scaffold(
  2. appBar: AppBar(
  3. ... //省略无关代码
  4. bottom: TabBar(
  5. controller: _tabController,
  6. tabs: tabs.map((e) => Tab(text: e)).toList()),
  7. ),
  8. drawer: new MyDrawer(),
  9. body: TabBarView(
  10. controller: _tabController,
  11. children: tabs.map((e) { //创建3个Tab页
  12. return Container(
  13. alignment: Alignment.center,
  14. child: Text(e, textScaleFactor: 5),
  15. );
  16. }).toList(),
  17. ),
  18. ... // 省略无关代码
  19. )

运行后效果如图所示:
Scaffold、TabBar、底部导航 - 图6
现在,无论是点击导航栏Tab菜单还是在页面上左右滑动,Tab页面都会切换,并且Tab菜单的状态和Tab页面始终保持同步!那它们是如何实现同步的呢?

  • 上例中TabBar和TabBarView的controller是同一个!正是如此,TabBar和TabBarView正是通过同一个controller来实现菜单切换和滑动状态同步的
  • 有关TabController的详细信息,我们不在本书做过多介绍,使用时读者直接查看SDK即可。

另外,Material组件库也提供了一个PageView 组件,它和TabBarView功能相似,读者可以自行了解一下。

抽屉菜单Drawer

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

  1. class MyDrawer extends StatelessWidget {
  2. const MyDrawer({
  3. Key key,
  4. }) : super(key: key);
  5. @override
  6. Widget build(BuildContext context) {
  7. return Drawer(
  8. child: MediaQuery.removePadding(
  9. context: context,
  10. //移除抽屉菜单顶部默认留白
  11. removeTop: true,
  12. child: Column(
  13. crossAxisAlignment: CrossAxisAlignment.start,
  14. children: <Widget>[
  15. Padding(
  16. padding: const EdgeInsets.only(top: 38.0),
  17. child: Row(
  18. children: <Widget>[
  19. Padding(
  20. padding: const EdgeInsets.symmetric(horizontal: 16.0),
  21. child: ClipOval(
  22. child: Image.asset(
  23. "imgs/avatar.png",
  24. width: 80,
  25. ),
  26. ),
  27. ),
  28. Text(
  29. "Wendux",
  30. style: TextStyle(fontWeight: FontWeight.bold),
  31. )
  32. ],
  33. ),
  34. ),
  35. Expanded(
  36. child: ListView(
  37. children: <Widget>[
  38. ListTile(
  39. leading: const Icon(Icons.add),
  40. title: const Text('Add account'),
  41. ),
  42. ListTile(
  43. leading: const Icon(Icons.settings),
  44. title: const Text('Manage accounts'),
  45. ),
  46. ],
  47. ),
  48. ),
  49. ],
  50. ),
  51. ),
  52. );
  53. }
  54. }

抽屉菜单通常将Drawer组件作为根节点,它实现了Material风格的菜单面板,MediaQuery.removePadding可以移除Drawer默认的一些留白(比如Drawer默认顶部会留和手机状态栏等高的留白),读者可以尝试传递不同的参数来看看实际效果。抽屉菜单页由顶部和底部组成,顶部由用户头像和昵称组成,底部是一个菜单列表,用ListView实现,关于ListView我们将在后面“可滚动组件”一节详细介绍。

FloatingActionButton

FloatingActionButton是Material设计规范中的一种特殊Button,通常悬浮在页面的某一个位置作为某种常用动作的快捷入口,如本节示例中页面右下角的”➕”号按钮。

  • 我们可以通过Scaffold的floatingActionButton属性来设置一个FloatingActionButton,
  • 同时通过floatingActionButtonLocation属性来指定其在页面中悬浮的位置,这个比较简单,不再赘述

底部Tab导航栏

我们可以通过Scaffold的bottomNavigationBar属性来设置底部导航,如本节开始示例所示,我们通过Material组件库提供的BottomNavigationBarBottomNavigationBarItem两种组件来实现Material风格的底部导航栏。可以看到上面的实现代码非常简单,所以不再赘述

但是如果我们想实现如图所示效果的底部导航栏应该怎么做呢?
Scaffold、TabBar、底部导航 - 图7
Material组件库中提供了一个BottomAppBar 组件,它可以和FloatingActionButton配合实现这种“打洞”效果,源码如下:

  1. bottomNavigationBar: BottomAppBar(
  2. color: Colors.white,
  3. shape: CircularNotchedRectangle(), // 底部导航栏打一个圆形的洞
  4. child: Row(
  5. children: [
  6. IconButton(icon: Icon(Icons.home)),
  7. SizedBox(), //中间位置空出
  8. IconButton(icon: Icon(Icons.business)),
  9. ],
  10. mainAxisAlignment: MainAxisAlignment.spaceAround, //均分底部导航栏横向空间
  11. ),
  12. )

可以看到,上面代码中没有控制打洞位置的属性,实际上,打洞的位置取决于FloatingActionButton的位置,上面FloatingActionButton的位置为:

  1. floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,

所以打洞位置在底部导航栏的正中间。

  • BottomAppBar的shape属性决定洞的外形,CircularNotchedRectangle实现了一个圆形的外
  • 我们也可以自定义外形,比如,Flutter Gallery示例中就有一个“钻石”形状的示例,读者感兴趣可以自行查看。

整合代码

  1. import 'package:flutter/material.dart';
  2. // import 'dart:math' as math;
  3. void main() => runApp(new MyApp());
  4. class MyApp extends StatelessWidget {
  5. @override
  6. Widget build(BuildContext context) {
  7. return MaterialApp(
  8. debugShowCheckedModeBanner: false,
  9. title: '装饰容器DecoratedBox',
  10. theme: ThemeData(
  11. primarySwatch: Colors.blue,
  12. ),
  13. home: ScaffoldRoute());
  14. }
  15. }
  16. class ScaffoldRoute extends StatefulWidget {
  17. @override
  18. _ScaffoldRouteState createState() => _ScaffoldRouteState();
  19. }
  20. class _ScaffoldRouteState extends State<ScaffoldRoute> {
  21. @override
  22. Widget build(BuildContext context) {
  23. return Scaffold(
  24. appBar: AppBar(
  25. //导航栏
  26. title: Text("App Name"),
  27. actions: <Widget>[
  28. //导航栏右侧菜单
  29. IconButton(icon: Icon(Icons.share), onPressed: () {}),
  30. ],
  31. ),
  32. bottomNavigationBar: BottomAppBar(
  33. color: Colors.white,
  34. shape: CircularNotchedRectangle(), // 底部导航栏打一个圆形的洞
  35. child: Row(
  36. children: [
  37. IconButton(
  38. icon: Icon(Icons.home),
  39. onPressed: () {},
  40. ),
  41. SizedBox(), //中间位置空出
  42. IconButton(icon: Icon(Icons.business), onPressed: () {}),
  43. ],
  44. mainAxisAlignment: MainAxisAlignment.spaceAround, //均分底部导航栏横向空间
  45. ),
  46. ),
  47. floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
  48. floatingActionButton: FloatingActionButton(
  49. //悬浮按钮
  50. child: Icon(Icons.add),
  51. onPressed: () {}),
  52. );
  53. }
  54. }