Skip to content

编辑页面2

构建优先级展示框

现在,我们来构建优先级展示框,和之前的日期选择器和时间选择器的不同之处在于,此处需要我们的优先级展示框能够从所点击文本框的位置展示出来,而不是覆盖在整个页面之上

实现优先级展示框

我们已经为优先级设定好了完整的数据模型。
因此这里就可以直接使用之前设定好的数据模型来实现优先级展示框的 UI 内容。
首先我们需要将优先级表单的基本样子展示出来:

 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
// lib/pages/edit_todo.dart
class _EditTodoPageState extends State<EditTodoPage> {
  ...

  Widget _buildForm() {
    bool canEdit = _openType != OpenType.Preview;
    return SingleChildScrollView(
      child: IgnorePointer(
        ignoring: !canEdit,
        child: GestureDetector(
          child: Form(
            key: _formKey,
            child: Column(
              children: [
                _buildTextFormField('名称', '任务名称', 1, _todo!.title, (value) {
                  _todo!.title = value!;
                }),
                _buildTextFormField('描述', '任务描述', 1, _todo!.description,
                    (value) => _todo!.description = value!),
                _buildDateFormField(
                    '日期', '请选择日期', _todo!.date!, _dateTextEditingController,
                    (value) {
                  _todo!.date = value.dayTime;
                  _dateTextEditingController.text = _todo!.date!.dateString;
                }),
                Row(
                  ...
                ),
                _buildPriorityFormField('优先级'),
              ],
            ),
          ),
        ),
      ),
    );
  }

  Widget _buildPriorityFormField(
    String title,
  ) {
    return LabelGroup(
      labelText: title,
      labelStyle: _labelTextStyle,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Container(
            padding: const EdgeInsets.fromLTRB(0, 10, 0, 10),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Container(
                  child: Text(_todo!.priority.description),
                ),
                Container(
                  width: 100,
                  height: 50,
                  alignment: Alignment.center,
                  child: Container(
                    width: 100,
                    height: 5,
                    color: _todo!.priority.color,
                  ),
                ),
              ],
            ),
          ),
          Divider(
            height: 1,
            thickness: 1,
            color: Colors.black26,
          ),
        ],
      ),
      padding: _labelPadding,
    );
  }
}

实现优先级弹出菜单

在 Flutter 中,如果我们想实现弹出菜单这种 UI 效果,可以使用 Material 库中的 PopupMenuButton 组件

PopupMenuButton 组件的使用也非常简单,只需要我们提供 itemBuilder、child 和 onSelected 三个属性就可以了。
这里我们依旧将这些优先级封装成一个组件:

 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
// lib/component/priority_field_group.dart
import 'package:flutter/material.dart';
import 'package:todo/model/todo.dart';

class PriorityFieldGroup extends StatelessWidget {
  const PriorityFieldGroup({
    Key? key,
    required this.initialValue,
    required this.onChange,
    required this.child,
  }) : super(key: key);

  final Priority initialValue;
  final Function(Priority) onChange;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return PopupMenuButton(
      itemBuilder: (BuildContext context) =>
          Priority.values.map(_buildPriorityPopupMenuItem).toList(),
      onSelected: onChange,
      child: child,
    );
  }

  PopupMenuItem<Priority> _buildPriorityPopupMenuItem(Priority priority) {
    return PopupMenuItem(
      value: priority,
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Text(priority.description),
          Container(
            width: 100,
            height: 5,
            color: priority.color,
          )
        ],
      ),
    );
  }
}

然后将 PriorityFieldGroup 组件添加到 _buildPriorityFormField 方法中:

 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/edit_todo.dart
import 'package:todo/component/priority_field_group.dart';

class _EditTodoPageState extends State<EditTodoPage> {

  Widget _buildPriorityFormField(
    String title,
  ) {
    return LabelGroup(
      labelText: title,
      labelStyle: _labelTextStyle,
      child: PriorityFieldGroup(
        initialValue: _todo!.priority,
        onChange: (Priority priority) {
          setState() {
            _todo!.priority = priority;
          }
        },
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          ...
        ),
      ),
      padding: _labelPadding,
    );
  }
}

此时点击优先级表单,就会展示出对应的优先级面板

完善表单细节内容

我们已经把表单整体的框架都构建完成了,接下来,我们会继续完善整个表单中比较细节的内容

完善表单中的细节内容

构建完成优先级弹出框以后,我们的表单内容已经基本完成了。
不过还需要保证当 OpenType 的取值是 Preview 的时候,整个表单的内容不能被编辑,这里我们可以直接使用 IgnorePointer 这个 Widget,
让整个表单内容在不允许编辑的时候不响应任何点击事件。

同时还需要实现在键盘弹出后,触摸表单的其他区域即可收回键盘,因此我们可以利用 GestureDetector 取消输入焦点组件中的当前焦点状态

要想实现以上两个功能,仅仅需要增加一些代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// lib/pages/edit_todo.dart
class _EditTodoPageState extends State<EditTodoPage> {
  Widget _buildForm() {
    bool canEdit = _openType != OpenType.Preview;
    return SingleChildScrollView(
      child: IgnorePointer(
        ignoring: !canEdit,
        child: GestureDetector(
          behavior: HitTestBehavior.opaque,
          onTap: () {
            FocusManager.instance.primaryFocus?.unfocus();
          },
          child: Form(
            key: _formKey,
            child: Column(
              ...
            ),
          ),
        ),
      ),
    );
  }
}

这里我们用到了 GestureDetector 中的 behavior 属性,将其值设置为了 HitTestBehavior.opaque,
这么做的主要目的是当我们点击到页面中本身不能响应事件的位置时,可以触发 GestureDetector 的 onTap 方法。
如果使用 behavior 属性的默认值 HitTestBehavior.deferToChild,那么会发现前面所说的这一类点击行为并不能触发我们设置的 onTap 方法。

为什么会存在这样的现象呢?我们可以看一下目前页面的布局结构:

在我们当前的页面中,只有 GestureDetector B 到 E 以及 TextFormField 组件可以响应事件。
如果 behavior 属性使用默认值,那么只有点击到 GestureDetector B 到 E 以及 TextFormField(均为 GestureDetector A 的可响应事件的孩子节点)的时候,才 "可能" 触发 GestureDetector A 的 onTap 方法。
又由于在 Flutter 中,各 GestureDetector 的 onTap 方法是互斥的,TextFormField 组件的点击事件与 onTap 方法也是互斥的,因此当 GestureDector B 到 E 的 onTap 方法被调用以及当 TextFormField 组件被点击时,GestureDetector A 的 onTap 方法并不会被调用,
也就是说在当前页面中,GestureDetector A 的 onTap 方法是不会得到调用的。
在这种情况下,我们可以认为 GestureDetector A 是透明的,点击到 A 本身并不会触发其本身的事件。

而如果把 behavior 属性的值设置为 HitTestBehavior.opaque,GestureDetector A 就是一个不透明的区域,无论是用户是否点击到 GestureDetector B 到 E 或者 TextFormField 组件,
只要点击区域是在 GestureDetector A 的范围中,其 onTap 方法就会被调用

将新创建的待办事项添加到列表中

此时我们的 "编辑 TODO" 页面已经完全拥有了获取完整待办事项信息的能力。
不过当填写完一个待办事项的信息后,点击完成,此时还不能在列表中看到这个待办事项,
我们还需要修改一下 TodoEntryPage 中的对应跳转方法,使得当页面返回 "列表" 页面的时候,能够获取到新创建的 Todo 对象:

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

class _TodoEntryPageState extends State<TodoEntryPage>
    with WidgetsBindingObserver {

  _onTabChange(int index) async {
    setState(() {
      currentIndex = index;
    });
    if (index == 2) {
      Todo? todo = await Navigator.of(context).pushNamed(
        EDIT_TODO_PAGE_URL,
        arguments: EditTodoPageArgument(OpenType.Add, Todo()),
      );
      if (todo != null) {
        index = 0;
      }
    }
  }

}        

注意此时我们还不能将更新加入到 TodoListpage 中,因此我们需要想办法更新 TodooListPage 内部的状态,可以继续使用 GlobalKey 这个类解决这个问题:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// lib/pages/todo_list.dart
class TodoListPageState extends State<TodoListPage> {
  ...
  @override
  TodoListPageState createState() => TodoListPageState();

  void addTodo(Todo todo) {
    setState(() {
      todoList!.add(todo);
    });
  }

}
 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/pages/todo_entry.dart
class _TodoEntryPageState extends State<TodoEntryPage>
    with WidgetsBindingObserver {
  ...
  GlobalKey<TodoListPageState> todoListPageState = GlobalKey();

  @override
  void initState() {
    super.initState();
    currentIndex = 0;
    pages = [
      TodoListPage(todoList: todoList),
      CalendarPage(),
      Container(),
      ReporterPage(),
      AboutPage(),
    ];
    WidgetsBinding.instance.addObserver(this);
  }

  _onTabChange(int index) async {
    setState(() {
      currentIndex = index;
    });
    if (index == 2) {
      Todo? todo = await Navigator.of(context).pushNamed(
        EDIT_TODO_PAGE_URL,
        arguments: EditTodoPageArgument(OpenType.Add, Todo()),
      );
      if (todo != null) {
        index = 0;
        todoListPageState.currentState!.addTodo(todo);
      }
    }
  }

}

最后,我们还需要修改待办事项 "列表" 页面中点击某个表单项后的逻辑,保证当某个待办事项被修改后,"列表" 页面中也会得到相应的更新:

 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
// lib/pages/todo_list.dart
class TodoListPageState extends State<TodoListPage> {
  ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('清单'),
      ),
      body: RefreshIndicator(
        onRefresh: () => widget.todoList!.syncWithNetwork(),
        child: ListView.builder(
          itemCount: todoList!.length,
          itemBuilder: (context, index) {
            return TodoItem(
              ...
              onTap: (Todo todo) async {
                await Navigator.of(context).pushNamed(
                  EDIT_TODO_PAGE_URL,
                  arguments: EditTodoPageArgument(OpenType.Preview, todo),
                );
                setState(() {
                  todoList!.update(todo);
                });
              },
              ...
            );
          },
        ),
      ),
    );
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// lib/pages/edit_todo.dart
class _EditTodoPageState extends State<EditTodoPage> {

  void _submit() {
    //  validate 方法会触发 Form 组件中所有 TextFormField 的 validator 方法
    if (_formKey.currentState!.validate()) {
      // 同样,save 方法会触发 Form 组件中所有 TextFormField 的 onSave 方法
      _formKey.currentState!.save();
      Navigator.of(context).pop(_todo);
    }
  }

}
 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/main.dart
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      ...
      onGenerateRoute: (RouteSettings settings) {
        if ([REGISTER_PAGE_URL, LOGIN_PAGE_URL].contains(settings.name)) {
          ...
        } else if ([EDIT_TODO_PAGE_URL].contains(settings.name)) {
          return MaterialPageRoute<Todo>(
            builder: routes[settings.name]!,
            settings: settings,
            fullscreenDialog: true,
          );
        }
        return MaterialPageRoute(
            builder: routes[settings.name] as Widget Function(BuildContext),
            settings: settings);
      },
    );
  }
}
 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/pages/todo_list.dart
class TodoListPageState extends State<TodoListPage> {

  void _updateTodoList() {
    TodoListChangeInfo changeInfo = todoList!.value;
    if (changeInfo.type == TodoListChangeType.Update) {
      setState(() {});
    } else if (changeInfo.type == TodoListChangeType.Delete) {
      Todo todo = changeInfo.todoList[changeInfo.insertOrRemoveIndex];
      animatedListKey.currentState!.removeItem(changeInfo.insertOrRemoveIndex, (
        BuildContext context,
        Animation<double> animation,
      ) {
        return SizeTransition(
          sizeFactor: animation,
          child: TodoItem(todo: todo),
        );
      });
    } else if (changeInfo.type == TodoListChangeType.Insert) {
      setState(() {});
    } else {
      // do nothing
    }
  }
}
 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/model/todo_list.dart
class TodoList extends ValueNotifier<TodoListChangeInfo> {
  ...

  void add(Todo todo) {
    _todoList.add(todo);
    _sort();
    _dbProvider!.add(todo);
    int index = _todoList.indexOf(todo);
    value = TodoListChangeInfo(
      insertOrRemoveIndex: index,
      type: TodoListChangeType.Insert,
      todoList: list,
    );
  }



  void update(Todo todo) {
    _dbProvider!.update(todo);
    _sort();
    value = TodoListChangeInfo(
      type: TodoListChangeType.Update,
      todoList: list,
    );
  }

}