Skip to content

列表页面

构建带有 BottomNavigationBar 的页面

从 "登录" 页面进入主页面后,主页面的下方有 5 个按钮,分别对应 5 个不同的页面。
因此在这里,我们首先需要构建 1 个入口页面,以及由入口页面引出的 5 个页面。
先在 lib/pages 目录下将这 6 个页面构建出来,给这 6 个页面分别按如下这样命名:

  • todo_entry.dart: 作为前置页面,承载所有的页面
  • todo_list.dart: 展示包含所有待办事项的列表的页面
  • calendar.dart: 展示待办事项日历的页面
  • edit_todo.dart: 待办事项的编辑页面。
  • reporter.dart: 所有待办事项的报告展示页面
  • about.dart: 简单的设置页面,我们可以从这个页面退出登录

我们先在 todo_list.dart、calendar.dart、reporter.dart、about.dart 这几个页面中填充一些非常简单的内容 -- 让它们仅在页面的中间展示页面名称,下面仅仅展示出 "关于" 页面(about.dart) 的代码,其他几个页面也是类似的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// pages/about.dart
import 'package:flutter/material.dart';

class AboutPage extends StatelessWidget {
  const AboutPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('关于'),
      ),
      body: Center(
        child: Text(
          this.runtimeType.toString(),
        ),
      ),
    );
  }
}

然后不要忘了在 main.dart 和 route_url.dart 文件中注册我们的 TodoEntryPage:

1
2
3
4
// route_url.dart
const LOGIN_PAGE_URL = '/login';
const REGISTER_PAGE_URL = '/register';
const TODO_ENTRY_PAGE_URL = '/';
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// main.dart
final Map<String, WidgetBuilder> routes = {
  LOGIN_PAGE_URL: (context) => LoginPage(),
  REGISTER_PAGE_URL: (context) => RegisterPage(),
  TODO_ENTRY_PAGE_URL: (context) => TodoEntryPage(),
};

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      ...
      home: routes[TODO_ENTRY_PAGE_URL]!(context),
      routes: {
        LOGIN_PAGE_URL: (context) => LoginPage(),
        REGISTER_PAGE_URL: (context) => RegisterPage(),
      },
      ...
    )
  }
}

创建 BottomNavigationBar

现在我们来看看 todo_entry.dart 文件中的内容,和其他几个页面的内容类似:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import 'package:flutter/material.dart';

class TodoEntryPage extends StatelessWidget {
  const TodoEntryPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('todo_entry'),
      ),
      body: Center(
        child: Text(
          this.runtimeType.toString(),
        ),
      ),
    );
  }
}

可能大家曾在多个地方见到过 Scaffold 这个 Widget,其主要作用是方便我们构建可能在每个页面都重复出现的一些页面布局内容

例如,几乎每个页面都可以分为导航栏(AppBar) 和导航栏下的主体(body)两部分,如果没有 Scaffold,就需要我们自己去布局这两部分。
除此之外,我们还可以利用 Scaffold 实现页面底部的 tab 按钮、安卓中的抽屉布局等常用的页面布局方式。
要注意的是,Scaffold 是 material 库的一部分,因此只能帮助我们实现一些在安卓上常见的页面结构,如果需要实现 iOS 上的页面结构,则可以使用 CupertinoPageScaffold 或者 CupertinoTabScaffold

接下来我们需要实现页面底部的 tab 按钮,使用到的 Widget 是 BottomNavigationBar,在使用过程中,需要关心它的如下几个属性

  • Items: 表示按钮栏(TabBar) 中每个按钮的具体设置,这里我们传递给它的参数都是 BottomNaviagtionBarItem 类型的,注意这里并不是一个 Widget,而是一个普通的配置对象
  • onTap: 当某个按钮被单击后的回调函数,我们需要在这里修改当前选中的页面索引
  • currentIndex: 当前被选中的页面的索引
  • type: 底部的 TabBar 的类型

关于 BottomNavigationBar 的类型

BottomNavigationBar 有两种不同的类型,分别是 fixed 和 shifting。
如果我们不显式指定是哪种类型,那么当 Items 属性中数组元素的个数少于 4 时,BottomNavigationBar 会自动被设置为 fixed 类型,反之会被设置为 shifting 类型。
被设置为 fixed 类型时,所有的 item 不管是否被单击,位置都是固定的。
而被设置为 shifting 类型时,被单击的 item 会偏移一些位置

我们再来看看需要给 Items 属性传入的参数的类型 -- BottomNavigationBarItem。
对于 BottomNavigationBarItem 来说,我们需要关心它的以下 3 个参数

  • activeIcon: 当 item 被单击时的图标
  • icon: 正常情况下的 icon
  • title: icon 下方的标题

activeIcon 和 icon 接收任意类型的 Widget,不过我们一般会在这里传入 material 库中的 Icons Widget 或者利用 ImageIcon Widget 从图片中创建我们的 icon。
在我们给出的 demo 中的 assets/images 目录下,已经准备好了对应的图片资源,我们可以直接使用

首先我们将我们预设的颜色统一放置在 lib/config/colors.dart 中:

1
2
const Color activeTabIconColor = Color(0xff50D2C2);
const Color inactiveTabIconColor = Colors.black;

接下来在 TodoEntryPage 中新增一个创建 BottomNavigationBarItem 对象的方法 _buildBottomNavigationBarItem:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// todo_entry.dart
class TodoEntryPage extends StatelessWidget {
  const TodoEntryPage({super.key});

  BottomNavigationBarItem _buildBottomNavigationBarItem(
    String imagePath, {
    double size = 0,
    bool singleImage = false,
  }) {
    if (singleImage) {
      return BottomNavigationBarItem(
        icon: Image(
          width: size,
          height: size,
          image: AssetImage(imagePath),
        ),
        label: '',
      );
    }
    ImageIcon activeIcon = ImageIcon(
      AssetImage(imagePath),
      color: activeTabIconColor,
    );
    ImageIcon inactiveImageIcon = ImageIcon(
      AssetImage(imagePath),
      color: inactiveTabIconColor,
    );
    return BottomNavigationBarItem(
      activeIcon: activeIcon,
      icon: inactiveImageIcon,
      label: '',
    );
  }
  ...
}

在创建的这个私有方法中,我们会根据传入的图片的地址创建对应的 BottomNavigationBarItem。
需要注意的是,虽然在创建 BottomNavigationBarItem。
需要注意的是,虽然在创建 BottomNavigationBarItem 的时候,我们可以不设置 label,但是在 BottomNavigationBar 中会有对应的 assert 检查 BottomNavigationBarItem 是否存在 label,如果不存在就会抛出异常,因此这里我们还是需要给 BottomNavigationBarItem 传入一个空白的 label

接下来,我们暂时先创建一个空白的 _onTabChange 方法:

1
2
3
4
5
6
// todo_entry.dart
class TodoEntryPage extends StatelessWidget {
  ...
  _onTabChange(int index) {}
  ...
}

然后在 Scaffold 中添加我们的 BottomNavigationBar:

1
2
3
4
5
6
7
8
9
flutter:
  assets:
    - assets/images/mark.png
    - assets/images/default_avatar.png
    - assets/images/lists.png
    - assets/images/calendar.png
    - assets/images/add.png
    - assets/images/report.png
    - assets/images/about.png
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// todo_entry.dart
class TodoEntryPage extends StatelessWidget {
  ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      bottomNavigationBar: BottomNavigationBar(
        onTap: _onTabChange,
        currentIndex: 0,
        type: BottomNavigationBarType.fixed,
        items: <BottomNavigationBarItem>[
          _buildBottomNavigationBarItem('assets/images/lists.png'),
          _buildBottomNavigationBarItem('assets/images/calendar.png'),
          _buildBottomNavigationBarItem(
            'assets/images/add.png',
            size: 50,
            singleImage: true,
          ),
          _buildBottomNavigationBarItem('assets/images/report.png'),
          _buildBottomNavigationBarItem('assets/images/about.png'),
        ],
      ),
      ...s
    );
  }
}

可以看到如图所示的效果图

使用 StatefulWidget 完成页面转换

现在,BottomNavigationBar 的样子已经有了,但是并不能在单击 BottomNavigationBar 中的按钮后切换页面。
现在来看看如何让 BottomNavigationBar 真正地 "动" 起来,其实很简单,大体的实现思路分以下几步。

(1) 当 BottomNavigationBar 的 onTap 方法被调用时,需要修改传入的 currentIndex 的值
(2) 同时需要修改 Scaffold 中 body 里的 Widget
(3) 针对中间比较特殊的用作添加待办事项的按钮,我们需要做到当这个按钮被单击的时候,不修改 currentIndex 的值,而是跳转到一个新的页面

首先,将 TodoEntryPage 转换为 StatefulWidget 类型:

接下来,需要在 _TodoEntryPageState 中增加一个新的变量 currentIndex, 并在状态初始化方法 initState 中对其进行初始化:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// todo_entry.dart
class _TodoEntryPageState extends State<TodoEntryPage> {
  int currentIndex = 0;

  @override
  void initState() {
    super.initState();
    currentIndex = 0;
  }
  ...
}

然后,我们需要大幅度修改 _TodoEntryPageState 的 build 方法,让它能够根据 currentIndex 展示不同的 AppBar 和 body 中的 Widget:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// todo_entry.dart
class _TodoEntryPageState extends State<TodoEntryPage> {
  int currentIndex = 0;
  List<Widget> pages = <Widget>[
    TodoListPage(),
    CalendarPage(),
    Container(),
    ReporterPage(),
    AboutPage(),
  ];

  @override
  void initState() {
    super.initState();
    currentIndex = 0;
    pages = <Widget>[
      TodoListPage(),
      CalendarPage(),
      Container(),
      ReporterPage(),
      AboutPage(),
    ];
  }
  ...
  _onTabChange(int index) {}

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      bottomNavigationBar: BottomNavigationBar(
        onTap: _onTabChange,
        currentIndex: currentIndex,
        ...
      ),
      body: pages[currentIndex],
    );
  }
}

完成以上工作后,还有最为重要的一个工作,就是在 _onTabChange 中响应按钮的单击事件,这里我们使用 setState 方法将 currentIndex 修改并重新触发 build 方法

1
2
3
4
5
  _onTabChange(int index) {
    setState(() {
      currentIndex = index;
    });
  }

保存之后,我们会发现已经可以在几个页面之间跳转了

用正确的方式构建 body

到目前为止,貌似一些都很完美,我们已经将这个页面的雏形构建出来了。
但是在这个地方,需要注意一个细节:
我们的 body 中的内容,是在每次执行 build 方法的时候构建的。
这种方式会导致每个页面都会在单击的时候重新生成,页面中的状态无法得到保留,同时效率也很低下。

在这里我们可以对 "列表" 页面做一个小修改,来进一步展示这个问题:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// todo_list.dart
class TodoListPage extends StatelessWidget {
  const TodoListPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('清单'),
      ),
      body: Center(
        child: TextField(),
      ),
    );
  }
}

在 "列表" 页面中,我们把原本展示的 Text 组件替换为 TextFiled 组件,并在文本框中输入一些文字,然后切换到别的页面,再切回来,会发现刚在文本框中输入的文字不见了

要解决这个问题,就要想办法让一开始创建出来的 TodoListPage 在 setState 方法被触发后还能继续存在。
好在 Flutter 提供了一个 Widget 帮助我们完成这个事情,这个 Widget 就是 IndexedStack,只需要对代码做如下的小修改即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// todo_entry.dart
class _TodoEntryPageState extends State<TodoEntryPage> {
  ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      ..
      body: IndexedStack(
        children: pages,
        index: currentIndex,
      ),
    );
  }
}

在第一次创建 IndexedStack 后,它会帮我们持有所有页面的 Element,同时会根据 index 来决定真正展示哪一个 Element,
因此在触发 setState 方法后,Element 树的结构并没有变化,从而让我们输入的内容保留下来

我们能发现此时切换页面后,输入的内容得到了保留

使用 ListView 构建页面

现在我们专注于构建待办事项应用中最重要的一个页面: "列表" 页面。
在这个页面中,我们会学习如何使用 ListView, 这几乎是所有应用都需要使用的 Widget

准备数据

在上手代码之前,先来看一下我们所要构建的待办事项列表中,每一个待办事项都有哪些内容,具体内容如下表所示:

字段 类型 作用
id String 作为待办事项的唯一标识,用于区分不同的待办事项
title String 待办事项的标题
description String 待办事项的详细内容
startTime TimeOfDay 开始的时间
endTime TimeOfDay 结束的时间
isStar bool 是否为标星待办事项
isFinished bool 待办事项是否完成
priority int 待办事项的优先级,该项值越小代表优先级越高
date DateTime 待办事项的开始日期

我们先在 lib/model 目录下创建一个 todo.dart 文件,用来存放我们的 Todo 类:

添加 uuid 依赖

1
2
3
dependencies:
  ...
  uuid: ^3.0.6
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// lib/model/todo.dart
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';

class Todo {
  String id = "";
  String titile = "";
  String descriptionn = "";
  DateTime date = DateTime.now();
  TimeOfDay startTime = TimeOfDay(hour: 0, minute: 0);
  TimeOfDay endTime = TimeOfDay(hour: 0, minute: 0);
  bool isFinished = false;
  bool isStar = false;

  Todo(
    String title,
    String description,
    DateTime date,
    TimeOfDay startTime,
    TimeOfDay endTime,
    bool isFinished,
    bool isStar,
  ) {
    this.id = generateNewId();
    this.titile = title;
    this.descriptionn = description;
    this.date = date;
    this.endTime = endTime;
    this.startTime = startTime;
    this.isFinished = isFinished;
    this.isStar = isStar;
  }

  static Uuid _uuid = Uuid();

  static String generateNewId() => _uuid.v1();
}

除了一些待办事项的基本信息,我们还需要优先级这样相对较为复杂的字段.
我们希望优先级本身是一个可以记录多个信息的枚举值,但很可惜的是,Dart 语言中的枚举值并不支持增加更多类型的信息,因此这里我们只能采取静态类的方法来实现迂回:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';

class Todo {
  ...
  Priority priority = Priority.Unspecific;

  Todo(
    String title,
    String description,
    DateTime date,
    TimeOfDay startTime,
    TimeOfDay endTime,
    bool isFinished,
    bool isStar,
    Priority priority,
  ) {
    ...
    this.priority = priority;
  }
  ...
}

class Priority {
  /// 优先级对应的数值,如 0
  int value = 0;

  /// 优先级对应的文字描述,如非常重要
  String description = "";

  /// 优先级对应的颜色,如红色
  Color color = Colors.white;

  Priority(this.value, this.description, this.color);

  /// 重写 == 运算符
  /// 如果两个 Priority 对象的 value 和一个整型值相等,则它们相等
  @override
  bool operator ==(other) =>
      other is Priority && other.value == value || other == value;

  /// 若重写 == 运算符,必须同时重写 hashCode 方法
  @override
  int get hashCode => value;

  /// 判断当前的 Priority 对象是否比另一个 Priority 对象更加重要
  /// 这里的逻辑就是,谁的 value 值更小,谁的优先级就更高
  bool isHigher(Priority other) => other != null && other.value > value;

  // factory Priority(int priority) =>
  //     values.firstWhere((e) => e.value == priority, orElse: () => Low);

  static Priority High = Priority(0, '高优先级', Color(0xFFE53B3B));
  static Priority Medium = Priority(1, '中优先级', Color(0xFFFF9400));
  static Priority Low = Priority(2, '低优先级', Color(0xFF14D4F4));
  static Priority Unspecific = Priority(3, '无优先级', Color(0xFF50D2C2));

  static List<Priority> values = [
    High,
    Medium,
    Low,
    Unspecific,
  ];
}

在实际的待办事项对比过程中,会在很多地方对待办事项的开始日期进行对比,因此我们预先写好一些对开始日期的扩展:

1
2
3
4
5
6
7
8
9
// lib/extension/date_time.dart
extension DateTimeUtils on DateTime {
  DateTime get dayTime => DateTime(year, month, day);

  bool isSameDay(DateTime other) =>
      year == other.year && month == other.month && day == other.day;

  bool isSameYear(DateTime other) => year == other.year;
}

除此之外,为了使我们能够在拥有实际数据之前方便地创建出很多待办事项的模拟数据,我们可以使用一个叫做 mock_data 的包来帮我们生成大量模拟数据:

1
2
3
dependencies:
  ...
  mock_data: ^2.0.0
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// lib/utils/generate_todo.dart
import 'package:flutter/material.dart';
import 'package:mock_data/mock_data.dart';
import 'package:todo/model/todo.dart';

List<Todo> generateTodos(int length) {
  List<Priority> priorities = [
    Priority.Unspecific,
    Priority.Medium,
    Priority.Medium,
    Priority.High,
  ];
  return List.generate(length, (i) {
    DateTime date = mockDate(DateTime(2019, 1, 1));
    DateTime startTime = date.add(Duration(hours: mockInteger(1, 9)));
    DateTime endTime = startTime.add(Duration(hours: mockInteger(1, 9)));
    return Todo(
      '${mockName()} - ${mockString()}',
      mockString(30),
      date,
      TimeOfDay.fromDateTime(startTime),
      TimeOfDay.fromDateTime(endTime),
      mockBool(),
      mockBool(),
      priorities[mockInteger(0, 3)],
    );
  });
}

bool mockBool() => mockInteger(0, 1) > 0;

数据已经准备好了,接下来我们看看如何修改 "列表" 页面,将这些待办事项展示出来

用 ListView 展示待办事项

下面会直接采用 ListView 组件的 builder 构造方法来构建 ListView:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// lib/pages/todo_list.dart
import 'package:flutter/material.dart';
import 'package:todo/model/todo.dart';
import 'package:todo/utils/generate_todo.dart';

class TodoListPage extends StatefulWidget {
  const TodoListPage({super.key});

  @override
  State<TodoListPage> createState() => _TodoListPageState();
}

class _TodoListPageState extends State<TodoListPage> {
  List<Todo> todoList = generateTodos(100);

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('清单'),
      ),
      body: ListView.builder(
        itemCount: todoList.length,
        itemBuilder: (context, index) {
          return TodoItem(todo: todoList[index]);
        },
      ),
    );
  }
}

class TodoItem extends StatelessWidget {
  final Todo? todo;

  const TodoItem({super.key, this.todo});

  @override
  Widget build(BuildContext context) {
    return Container();
  }
}

这里比较复杂的部分在于如何构建 ListView 中的每一项。
我们首先把每一项抽象为一个单独的 StatelessWidget:

首先我们可以用 Container 的边框颜色表示待办事项的优先级,同时使用 opacity 区分待办事项是否完成,搭建出一条待办事项的布局雏形:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// lib/pages/todo_list.dart
class TodoItem extends StatelessWidget {
  final Todo? todo;

  const TodoItem({super.key, this.todo});

  @override
  Widget build(BuildContext context) {
    return Opacity(
      opacity: todo!.isFinished ? 0.3 : 1.0,
      child: Container(
        decoration: BoxDecoration(
          color: Colors.white,
          border: Border(
            left: BorderSide(
              width: 2,
              color: todo!.priority.color,
            ),
          ),
        ),
        margin: const EdgeInsets.all(10.0),
        padding: const EdgeInsets.fromLTRB(10.0, 20.0, 10.0, 20.0),
        height: 110,
      ),
    );
  }
}

接下来,我们来构建一条待办事项中的第一行内容:

1
2
3
4
5
6
  assets:
    ...
    - assets/images/rect_selected.png
    - assets/images/rect.png
    - assets/images/star.png
    - assets/images/star_normal.png
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
class TodoItem extends StatelessWidget {
  final Todo? todo;

  const TodoItem({super.key, this.todo});

  @override
  Widget build(BuildContext context) {
    return Opacity(
      opacity: todo!.isFinished ? 0.3 : 1.0,
      child: Container(
        decoration: BoxDecoration(
          color: Colors.white,
          border: Border(
            left: BorderSide(
              width: 2,
              color: todo!.priority.color,
            ),
          ),
        ),
        margin: const EdgeInsets.all(10.0),
        padding: const EdgeInsets.fromLTRB(10.0, 20.0, 10.0, 20.0),
        height: 110,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Row(
                  children: [
                    Image.asset(
                      todo!.isFinished
                          ? 'assets/images/rect_selected.png'
                          : 'assets/images/rect.png',
                      width: 25,
                      height: 25,
                    ),
                    Padding(
                      padding: const EdgeInsets.fromLTRB(10, 0, 10, 0),
                      child: Text(todo!.titile),
                    ),
                  ],
                ),
                Container(
                  child: Image.asset(
                    todo!.isStar
                        ? 'assets/images/star.png'
                        : 'assets/images/star_normal.png',
                  ),
                  width: 25,
                  height: 25,
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

对于第二行内容的构建,我们可以如法炮制:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// lib/model/todo.dart
class Todo {
  ...

  String get timeString {
    String dateString = date.compareTo(DateTime.now()) == 0
        ? 'today'
        : '${date.year}/${date.month}/${date.day}';
    if (startTime == null || endTime == null) {
      return dateString;
    }
    return '$dateString ${startTime.hour}:${startTime.minute} - ${endTime.hour}:${endTime.minute}';
  }
}
1
2
3
  assets:
    ...
    - assets/images/group.png
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class TodoItem extends StatelessWidget {
  final Todo? todo;

  const TodoItem({super.key, this.todo});

  @override
  Widget build(BuildContext context) {
    return Opacity(
      opacity: todo!.isFinished ? 0.3 : 1.0,
      child: Container(
        decoration: BoxDecoration(
          color: Colors.white,
          border: Border(
            left: BorderSide(
              width: 2,
              color: todo!.priority.color,
            ),
          ),
        ),
        margin: const EdgeInsets.all(10.0),
        padding: const EdgeInsets.fromLTRB(10.0, 20.0, 10.0, 20.0),
        height: 110,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            ...
            Row(
              children: [
                Image.asset(
                  'assets/images/group.png',
                  width: 25,
                  height: 25,
                ),
                Padding(
                  padding: const EdgeInsets.fromLTRB(10, 0, 0, 0),
                  child: Text(todo!.timeString),
                ),
              ],
            )
          ],
        ),
      ),
    );
  }
}

保存代码,可以看到如果所示的效果图:

为 ListView 增加简单的事件交互

我们需要完成以下几个事件:

  • 点击左边的选择框,表示待办事项已完成
  • 点击右边星星,表示给待办事项加标记
  • 点击待办事项后,跳转到 "编辑 TODO" 页面或者 "查看 TODO" 页面
  • 长按待办事项,弹出 Dialog 询问用户是否确认删除

为待办事项添加事件回调

首先需要为待办事项增加一些事件回调:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// todo_list.dart
typedef TodoEventCallback = Function(Todo todo);

class TodoItem extends StatelessWidget {
  final Todo? todo;
  final TodoEventCallback? onStar;
  final TodoEventCallback? onFinished;
  final TodoEventCallback? onTap;
  final TodoEventCallback? onLongPress;

  const TodoItem(
      {super.key,
      this.todo,
      this.onStar,
      this.onFinished,
      this.onTap,
      this.onLongPress});
}

然后需要在必要的地方用 GestureDetector 将点击事件喝事件回调关联起来:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
class TodoItem extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Opacity(
      opacity: todo!.isFinished ? 0.3 : 1.0,
      child: Container(
        decoration: BoxDecoration(
          color: Colors.white,
          border: Border(
            left: BorderSide(
              width: 2,
              color: todo!.priority.color,
            ),
          ),
        ),
        margin: const EdgeInsets.all(10.0),
        padding: const EdgeInsets.fromLTRB(10.0, 20.0, 10.0, 20.0),
        height: 110,
        child: GestureDetector(
          onTap: () => onTap!(todo!),
          onLongPress: () => onLongPress!(todo!),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  Row(
                    children: [
                      GestureDetector(
                        onTap: () => onFinished!(todo!),
                        child: Image.asset(
                          todo!.isFinished
                              ? 'assets/images/rect_selected.png'
                              : 'assets/images/rect.png',
                          width: 25,
                          height: 25,
                        ),
                      ),
                      Padding(
                        padding: const EdgeInsets.fromLTRB(10, 0, 10, 0),
                        child: Text(todo!.titile),
                      ),
                    ],
                  ),
                  Container(
                    child: GestureDetector(
                      onTap: () => onStar!(todo!),
                      child: Image.asset(
                        todo!.isStar
                            ? 'assets/images/star.png'
                            : 'assets/images/star_normal.png',
                      ),
                    ),
                    width: 25,
                    height: 25,
                  ),
                ],
              ),
              Row(
                ...
              )
            ],
          ),
        ),
      ),
    );
  }
}

添加事件回调的具体逻辑

接下来,我们在 ListView 中的 itemBuilder 里填充具体的事件处理逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class _TodoListPageState extends State<TodoListPage> {
  List<Todo> todoList = generateTodos(100);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('清单'),
      ),
      body: ListView.builder(
        itemCount: todoList.length,
        itemBuilder: (context, index) {
          return TodoItem(
            todo: todoList[index],
            onFinished: (Todo todo) {
              setState(() {
                todo.isFinished = !todo.isFinished;
              });
            },
            onStar: (Todo todo) {
              setState(() {
                todo.isStar = !todo.isStar;
              });
            },
          );
        },
      ),
    );
  }
}

跳转到 "查看 TODO" 页面的逻辑比较简单, 只需要在 "编辑 TODO" 页面创建对应的 URL 以及跳转参数即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// lib/const/route_argument.dart
import 'package:todo/model/todo.dart';

class RegisterPageArgument {
  final String className;
  final String url;

  RegisterPageArgument(this.className, this.url);
}

enum OpenType {
  Add,
  Edit,
  Preview,
}

class EditTodoPageArgument {
  final OpenType openType;
  final Todo todo;

  EditTodoPageArgument(this.openType, this.todo);
}
1
2
3
4
5
// lib/pages/route_url.dart
const LOGIN_PAGE_URL = '/login';
const REGISTER_PAGE_URL = '/register';
const TODO_ENTRY_PAGE_URL = '/';
const EDIT_TODO_PAGE_URL = '/edit';
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// lib/pages/edit_todo.dart
import 'package:flutter/material.dart';

class EditTodoPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Text('edit todo'),
      ),
    );
  }
}
1
2
3
4
5
6
7
// lib/main.dart
final Map<String, WidgetBuilder> routes = {
  LOGIN_PAGE_URL: (context) => LoginPage(),
  REGISTER_PAGE_URL: (context) => RegisterPage(),
  TODO_ENTRY_PAGE_URL: (context) => TodoEntryPage(),
  EDIT_TODO_PAGE_URL: (context) => EditTodoPage(),
};

然后在 todo_list.dart 文件中与 todo_entry.dart 文件中增加对应的跳转回调:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// lib/pages/todo_list.dart
class _TodoListPageState extends State<TodoListPage> {
  List<Todo> todoList = generateTodos(100);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('清单'),
      ),
      body: ListView.builder(
        itemCount: todoList.length,
        itemBuilder: (context, index) {
          return TodoItem(
            todo: todoList[index],
            ...
            onTap: (Todo todo) {
              Navigator.of(context).pushNamed(
                EDIT_TODO_PAGE_URL,
                arguments: EditTodoPageArgument(OpenType.Preview, todo),
              );
            },
          );
        },
      ),
    );
  }
}

当然,此时查看待办事项详情的功能并不能生效,我们会在后面继续完善这个功能。

删除待办事项这一功能的实现原理和前几个功能的类似,不同之处在于这里需要弹出一个 Dialog。
可以直接参考 lib/components/delete_todo_dialog.dart 文件里的代码。这里我们仅仅展示删除功能的事件处理逻辑:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
// lib/components/delete_todo_dialog.dart
import 'package:flutter/material.dart';
import 'package:todo/model/todo.dart';

class DeleteTodoDialog extends Dialog {
  final Todo? todo;

  DeleteTodoDialog({super.key, this.todo});

  _dismissDialog(BuildContext context, bool delete) {
    Navigator.of(context).pop(delete);
  }

  @override
  Widget build(BuildContext context) {
    return new GestureDetector(
      onTap: () {
        _dismissDialog(context, false);
      },
      child: Material(
        type: MaterialType.transparency,
        child: Center(
          child: SizedBox(
            width: MediaQuery.of(context).size.width * 0.9,
            height: MediaQuery.of(context).size.height * 0.6,
            child: ClipRRect(
              borderRadius: BorderRadius.circular(6.0),
              child: Container(
                color: Colors.white,
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Container(
                      padding: const EdgeInsets.all(10),
                      child: Column(
                        children: <Widget>[
                          _createTitleWidget(context, todo!),
                          _createDescWidget(todo!),
                        ],
                      ),
                    ),
                    _createOperationWidget(context)
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }

  Widget _createTitleWidget(BuildContext context, Todo todo) {
    return Row(
      children: <Widget>[
        Container(
          height: 15.0,
          width: 15.0,
          decoration: BoxDecoration(
              borderRadius: BorderRadius.all(Radius.circular(7.5)),
              color: Color.fromARGB(255, 80, 210, 194)),
        ),
        Container(
          child: Padding(
            padding: const EdgeInsets.fromLTRB(10, 0, 0, 0),
            child: Text(
              todo.titile,
              overflow: TextOverflow.ellipsis,
              softWrap: false,
              maxLines: 1,
              style: TextStyle(
                  color: Color.fromARGB(255, 74, 74, 74),
                  fontSize: 18,
                  fontFamily: 'Avenir'),
            ),
          ),
        ),
        Expanded(
          child: Padding(
            padding: const EdgeInsets.fromLTRB(10, 0, 0, 0),
            child: Container(
              height: 1.0,
              color: Color.fromARGB(255, 216, 216, 216),
            ),
          ),
        ),
      ],
    );
  }

  Widget _createTimeWidget(String time) {
    return Row(
      children: <Widget>[
        Image.asset(
          'assets/images/time.png',
          width: 60.0,
          height: 60.0,
        ),
        Expanded(
          child: Text(
            time,
            style: TextStyle(color: Colors.black, fontSize: 14),
          ),
        )
      ],
    );
  }

  Widget _createDescWidget(Todo todo) {
    return Container(
      child: Text(
        todo.descriptionn,
        style: TextStyle(fontSize: 13, color: Colors.black),
      ),
    );
  }

  Widget _createOperationWidget(BuildContext context) {
    return Row(
      children: <Widget>[
        Expanded(
          child: GestureDetector(
            onTap: () {
              _dismissDialog(context, false);
            },
            child: Container(
              alignment: Alignment.center,
              height: 50,
              color: Color.fromARGB(255, 221, 221, 221),
              child: Text(
                '取消',
                style: TextStyle(color: Colors.white),
              ),
            ),
          ),
        ),
        Expanded(
          child: GestureDetector(
            onTap: () {
              _dismissDialog(context, true);
            },
            child: Container(
              alignment: Alignment.center,
              height: 50,
              color: Color.fromARGB(255, 255, 92, 92),
              child: Text(
                '删除',
                style: TextStyle(color: Colors.white),
              ),
            ),
          ),
        )
      ],
    );
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// lig/pages/todo_list.dart
class _TodoListPageState extends State<TodoListPage> {
  List<Todo> todoList = generateTodos(100);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ListView.builder(
        itemCount: todoList.length,
        itemBuilder: (context, index) {
          return TodoItem(
            todo: todoList[index],
            ...
            onLongPress: (Todo todo) async {
              bool result = await showCupertinoDialog(
                  context: context,
                  builder: (BuildContext context) {
                    return DeleteTodoDialog(
                      todo: todo,
                    );
                  });
              if (result) {
                setState(() {
                  todoList.remove(todo);
                });
              }
            },
          );
        },
      ),
    );
  }
}

完善列表的排序功能

完成了以上简单的事件回调以后,我们还需要完善一下目前待办事项应用中待办事项的排序规则。

我们制定的排序规则有如下几点。

  • 未完成的待办事项需要排在已完成的待办事项之前
  • 标星的待办事项需要排在未标星的待办事项之前
  • 高优先级的待办事项需要排在低优先级的待办事项之前
  • 开始日期较早的待办事项需要排在开始日期较晚的待办事项之前
  • 结束日期较早的待办事项需要排在结束日期较晚的待办事项之前

为了能让这些功能得到更好的复用,我们需要将之前的 List 对象抽象为一个单独的 TodoList 模型对象,并把相关的排序规则封装在这个 TodoList 对象中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// lib/model/todo_list.dart
import 'package:flutter/material.dart';
import 'package:todo/model/todo.dart';

class TodoList {
  final List<Todo> _todoList;

  TodoList(this._todoList) {
    _sort();
  }

  int get length => _todoList.length;
  List<Todo> get list => List.unmodifiable(_todoList);

  void add(Todo todo) {
    _todoList.add(todo);
    _sort();
  }

  void remove(String id) {
    Todo? todo = find(id);
    if (todo == null) {
      assert(false, 'Todo with $id is not exist');
      return;
    }
    int index = _todoList.indexOf(todo);
    _todoList.removeAt(index);
  }

  void update(Todo todo) {
    _sort();
  }

  Todo? find(String id) {
    int index = _todoList.indexWhere((todo) => todo.id == id);
    return index >= 0 ? _todoList[index] : null;
  }

  void _sort() {
    _todoList.sort((Todo a, Todo b) {
      if (!a.isFinished && b.isFinished) {
        return -1;
      }
      if (a.isFinished && !b.isFinished) {
        return 1;
      }
      if (a.isStar && !b.isStar) {
        return -1;
      }
      if (!a.isStar && b.isStar) {
        return 1;
      }
      if (a.priority.isHigher(b.priority)) {
        return -1;
      }
      if (b.priority.isHigher(a.priority)) {
        return 1;
      }
      int dateCompareResult = b.date.compareTo(a.date);
      if (dateCompareResult != 0) {
        return dateCompareResult;
      }
      return a.endTime.hour - b.endTime.hour;
    });
  }
}

接下来,我们需要在 pages/todo_list.dart 文件中将之前直接使用的 List 对象改为我们刚刚创建出来的 TodoList 对象:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// lib/utils/generate_todo.dart
TodoList generateTodos(int length) {
  List<Priority> priorities = [
    Priority.Unspecific,
    Priority.Medium,
    Priority.Medium,
    Priority.High,
  ];
  var list = List.generate(length, (i) {
    DateTime date = mockDate(DateTime(2019, 1, 1));
    DateTime startTime = date.add(Duration(hours: mockInteger(1, 9)));
    DateTime endTime = startTime.add(Duration(hours: mockInteger(1, 9)));
    return Todo(
      '${mockName()} - ${mockString()}',
      mockString(30),
      date,
      TimeOfDay.fromDateTime(startTime),
      TimeOfDay.fromDateTime(endTime),
      mockBool(),
      mockBool(),
      priorities[mockInteger(0, 3)],
    );
  });
  return TodoList(list);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// lib/pages/todo_list.dart
class _TodoListPageState extends State<TodoListPage> {
  TodoList todoList = generateTodos(100);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('清单'),
      ),
      body: ListView.builder(
        itemCount: todoList.length,
        itemBuilder: (context, index) {
          return TodoItem(
            todo: todoList.list[index],
            onFinished: (Todo todo) {
              setState(() {
                todo.isFinished = !todo.isFinished;
              });
            },
            onStar: (Todo todo) {
              setState(() {
                todo.isStar = !todo.isStar;
              });
            },
            onTap: (Todo todo) {
              Navigator.of(context).pushNamed(
                EDIT_TODO_PAGE_URL,
                arguments: EditTodoPageArgument(OpenType.Preview, todo),
              );
            },
            onLongPress: (Todo todo) async {
              bool result = await showCupertinoDialog(
                  context: context,
                  builder: (BuildContext context) {
                    return DeleteTodoDialog(
                      todo: todo,
                    );
                  });
              if (result) {
                setState(() {
                  todoList.remove(todo.id);
                });
              }
            },
          );
        },
      ),
    );
  }
}