编写任何一个正式的移动应用,第一步都需要考虑如何组织项目代码(目录结构),以及各个页面之间的跳转和返回(导航)。JW Flutter Demo(JWFD) 应用也是如此,本次我们就来完成这一部分的开发工作。

本文属于 JW Flutter Demo 文章系列,相关代码已在 GitHub 开源 JW Flutter Demo。如果想全面深入学习 Flutter,欢迎选购笔者制作的视频课程 Flutter 移动应用开发实战

目录结构

JWFD 应用的目录结构如下:

.
├── LICENSE
├── README.md
├── android
├── build
├── flutterdemo.iml
├── ios
├── lib
│   ├── components
│   │   ├── components.dart
│   │   ├── counter.dart
│   │   ├── drawer.dart
│   │   └── tab_bar.dart
│   ├── main.dart
│   └── pages
│       ├── back.dart
│       ├── home.dart
│       ├── list_view.dart
│       ├── pages.dart
│       ├── tab_bar
│       └── tar_bar.dart
├── pubspec.lock
├── pubspec.yaml
└── test
    └── widget_test.dart

其中一级目录都是使用 flutter 工具创建项目时自动创建的,包括 Android 原生工程目录 android,iOS 原生工程目录 ios,以及应用代码目录 lib

lib 目录下除了 main.dart 文件是必须的,其它子目录结构开发人员可以自行决定。我们这里暂时只分了两个子目录,一个是 components,存放在各个页面中共用的一些组件,另一个是 pages,存放所有的页面。虽然页面本质上也是组件,但页面能够被导航,所以最好把存放路径区分开来。随着后面引入的第三方库和增加的功能越来越多,可以按需增加其它子目录,比如 Redux 相关的 actionsreducers 等目录。

pubspec.yaml 文件里存放的是项目依赖的第三方库,如果使用 IDE,只要该文件内容有改动,就会自动触发依赖库更新。也可在命令行执行 flutter packages get 来手动触发。

Drawer 导航

移动应用的导航方式一般有两种,一种是 Drawer 导航,适合一级页面较多的情况,还有一种是 TabBar 导航,适合一级页面较少的情况(不超过 5 个)。考虑到 JWFD 的示例以后会越来越多,这些示例都需要入口,所以适合采用 Drawer 导航方式。

运行效果

先来看一下运行效果:

编写代码

开发 Drawer 组件

新建 components/drawer.dart 文件,添加如下代码:

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

import '../pages/pages.dart';

class JWDrawer extends StatefulWidget {
  @override
  JWDrawerState createState() => JWDrawerState();
}

class JWDrawerState extends State<JWDrawer> {
  static var _packageInfo = PackageInfo();
  static var _isHome = true;
  static final _panels = [
    {
      'title': 'Navigation',
      'isExpanded': false,
      'items': [
        {
          'title': 'TabBar',
          'isSelected': false,
          'pageBuilder': (BuildContext context) => TabBarPage(),
        },
      ],
    },
    {
      'title': 'Scroll',
      'isExpanded': false,
      'items': [
        {
          'title': 'ListView',
          'isSelected': false,
          'pageBuilder': (BuildContext context) => ListViewPage(),
        },
      ],
    },
  ];

  @override
  void initState() {
    super.initState();

    if (_packageInfo == null) {
      PackageInfo.fromPlatform().then((packageInfo) {
        setState(() {
          _packageInfo = packageInfo;
        });
      });
    }
  }

  void _resetPanels() {
    _panels.forEach((panel) {
      panel['isExpanded'] = false;
      (panel['items'] as List<Map<String, Object>>).forEach((item) {
        item['isSelected'] = false;
      });
    });
  }

  void _goHome() {
    _isHome = true;

    _resetPanels();

    Navigator.of(context)
      ..pop()
      ..pushReplacement(MaterialPageRoute(
        builder: (context) => HomePage(),
      ));
  }

  void _onExpand(index, isExpanded) {
    _resetPanels();

    _panels[index]['isExpanded'] = !isExpanded;

    setState(() {});
  }

  void _onSelected(Map<String, Object> panel, Map<String, Object> item) {
    _isHome = false;

    _resetPanels();

    panel['isExpanded'] = true;
    item['isSelected'] = true;

    Navigator.of(context)
      ..pop()
      ..pushReplacement(MaterialPageRoute(
        builder: item['pageBuilder'],
      ));
  }

  @override
  Widget build(BuildContext context) {
    return Drawer(
      child: Column(
        children: <Widget>[
          DrawerHeader(
            decoration: BoxDecoration(
              color: Colors.blueGrey,
            ),
            child: Center(
              child: Text(
                'JW Flutter Demo',
                style: TextStyle(color: Colors.white, fontSize: 20),
              ),
            ),
          ),
          ListTile(
            onTap: _goHome,
            title: Text(
              'Home',
              style: TextStyle(fontSize: 16),
            ),
            selected: _isHome,
            dense: true,
          ),
          ExpansionPanelList(
            expansionCallback: _onExpand,
            children: _panels
                .asMap()
                .map<int, ExpansionPanel>(
                  (index, panel) => MapEntry(
                        index,
                        ExpansionPanel(
                          headerBuilder: (context, isExpanded) => ListTile(
                                onTap: () =>
                                    _onExpand(index, panel['isExpanded']),
                                title: Text(
                                  panel['title'],
                                  style: TextStyle(fontSize: 16),
                                ),
                                selected: isExpanded,
                                dense: true,
                              ),
                          body: Container(
                            padding: EdgeInsets.only(left: 15),
                            child: Column(
                              children: (panel['items']
                                      as List<Map<String, Object>>)
                                  .map<Widget>((item) => ListTile(
                                        onTap: () => _onSelected(panel, item),
                                        title: Text(item['title']),
                                        selected: item['isSelected'],
                                        dense: true,
                                        enabled: item['pageBuilder'] != null,
                                      ))
                                  .toList(),
                            ),
                          ),
                          isExpanded: panel['isExpanded'],
                        ),
                      ),
                )
                .values
                .toList(),
          ),
          Divider(),
          AboutListTile(
            applicationName: _packageInfo.appName,
            applicationLegalese: 'Copyright © Jagger Wang',
            applicationVersion: _packageInfo.version,
          ),
        ],
      ),
    );
  }
}

里面利用了 Flutter 内置的 Drawer 组件来完成 Drawer 的展开和收起,我们只需要完成 Drawer 里的内容生成,主要是导航菜单。

  1. 为了防止跟 Flutter 内置的 Drawer 组件命名冲突,我们自己的 Drawer 组件命名为了 JWDrawer
  2. 因为每个页面都是重新构建自己的 Drawer 组件,为了在切换页面之后能够记住之前的状态(当前展开的 Panel 和选中的菜单项),导航菜单数据设置为了 static 类属性。
  3. 使用 DrawerHeader 组件来生成头部的内容,一般这个区域用来展示登录用户信息。
  4. 导航菜单里的条目都使用了 ListTile 组件来生成,ListTile 用于列表中的条目生成。支持标题、子标题、前缀、后缀等内容区块,以及选择、禁用等操作,使用它可以节省很多工作。
  5. 考虑到以后菜单条目会很多,因此设计成了两级菜单并且可折叠的方式。折叠效果使用了 Flutter 内置的 ExpansionPanelList 组件,每一个一级菜单对应一个 ExpansionPanel
  6. 底部使用了 AboutListTile 组件来展示应用版本和版权信息,其中应用名称和版本使用了第三方的库 package_info 库来从 pubspec.yaml 文件中读取。
  7. Drawer 本身也是个页面,可通过 Navigator.push 打开,Navigator.pop 返回。
  8. 一般导航菜单里的各个页面是相互独立的,进入一个新的页面不需要返回之前页面,所以我们使用了 Navigator.pushReplacement,而不是 Navigator.push 来打开页面,这样可以防止切换许多次后导航堆栈里的页面越来越多。

关于各个 Flutter 内置组件的详细说明,可以参考 官方文档

在页面中使用 Drawer 组件

使用 Drawer 组件很简单,Scaffold 组件直接支持 Drawer 导航,只需要给其 drawer 属性赋值为我们自己的 JWDrawer 组件实例即可。页面左上角就会出现 Drawer 图标,点击图标会展开 Drawer,在展开情况下点击页面右侧空白区域可收起 Drawer。

import 'package:flutter/material.dart';

import '../components/components.dart';

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Home'),
      ),
      drawer: JWDrawer(),
      body: Center(
        child: Text(
          'JW Flutter Demo',
          style: Theme.of(context).textTheme.display1,
        ),
      ),
    );
  }
}

更多资料

  1. JW Flutter Demo 文章系列
  2. JW Flutter Demo 开源项目
  3. JW 课程 - Flutter 移动应用开发实战
  4. 围观 APP