Skip to content

编辑页面1

构建简单的表单页面

我们先要搭建出表单页面的基本页面框架,然后再表单页面中填充文本类型比较简单的表单项,这部分内容相对比较简单

搭建页面框架

第一步,当我们从不同的页面进入编辑页时,需要让这个页面的右上角能够根据前一页的页面类型,显示不同类型的按钮,同时可以从查看模式进入编辑模式。
要完成这些操作,我们首先需要将 "编辑 TODO" 页面转变为 StatefulWidget 类型,并添加对应的逻辑:

1
2
3
4
5
6
// lib/const/route_argument.dart
enum OpenType {
  Add,
  Edit,
  Preview,
}
 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
// lib/pages/edit_todo.dart
import 'package:flutter/material.dart';
import 'package:todo/const/route_argument.dart';
import 'package:todo/model/todo.dart';

class EditTodoPage extends StatefulWidget {
  const EditTodoPage({super.key});
  @override
  State<EditTodoPage> createState() => _EditTodoPageState();
}

class _OpenTypeConfig {
  final String title;
  final IconData icon;
  final void Function() onPressed;

  const _OpenTypeConfig(this.title, this.icon, this.onPressed);
}

class _EditTodoPageState extends State<EditTodoPage> {
  OpenType? _openType;
  Todo? _todo;

  Map<OpenType, _OpenTypeConfig>? _openTypeConfigMap;

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

    _openTypeConfigMap = {
      OpenType.Preview: _OpenTypeConfig('查看TODO', Icons.edit, _edit),
      OpenType.Edit: _OpenTypeConfig('编辑TODO', Icons.check, _submit),
      OpenType.Add: _OpenTypeConfig('添加TODO', Icons.check, _submit),
    };
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    EditTodoPageArgument argument =
        ModalRoute.of(context)!.settings.arguments as EditTodoPageArgument;
    _openType = argument.openType;
    _todo = argument.todo ?? Todo();
  }

  void _edit() {
    setState(() {
      _openType = OpenType.Edit;
    });
  }

  void _submit() {
    Navigator.of(context).pop();
  }

  Widget _buildForm() {
    return Center(child: Text(_openType.toString()));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(_openTypeConfigMap![_openType]!.title),
        backgroundColor: Colors.white,
        centerTitle: true,
        actions: [
          IconButton(
            onPressed: _openTypeConfigMap![_openType]?.onPressed,
            icon: Icon(
              _openTypeConfigMap![_openType]?.icon,
              color: Colors.black87,
            ),
          ),
        ],
      ),
      body: _buildForm(),
    );
  }
}

在上面的这段代码中,大部分内容与普通页面的构建有关,
除了 didChangeDependencies 方法,其中的内容看起来和 initState 方法中的内容作用差不多 -- 都是初始化页面的一些初始数据,
那为什么我们会将一部分本应该放在 initState 方法中国呢的代码放在了这里呢?

原因在于,我们通过 ModalRout.of 这个之前在 InhertWidget 中学习到的方法获取了传递到页面中的参数。
在 Flutter 框架中,当 InhertWidget 中带有的 "数据" 发生变更时,Flutter 框架会通知所有获取过这个 "数据" 的 Widget "数据" 已经变更,
而这个变更对应的生命周期方法就是 didChangeDependencies。
Flutter 框架会在调用 didChangeDependencies 方法后继续调用 build 方法来更新页面。
由于 initState 方法在 State 的生命周期中只会调用一次,因此我们不能将 of 方法的调用放在 initState 方法中

除此之外,在这个版本的页面代码中,我们只是简单地展示了打开 "编辑 TODO" 页面的状态,同时在导航栏右侧的按钮被点击时,将当前页面切换到对应状态。
接下来,我们将会完善 "编辑 TODO" 页面的各项功能。

封装带有标题的 LabelGroup 组件

整个 "编辑 TODO" 页面主要是由一个可以滚动的 ScrollView 和大量表单项组成的。
为了方便抽象复用,我们这里统一将表单项输入框以外的部分抽象为 LabelGroup

我们来看看这个 LabelGroup 是如何封装的:

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

class LabelGroup extends StatelessWidget {
  LabelGroup({
    Key? key,
    required this.labelText,
    required this.labelStyle,
    required this.child,
    required this.padding,
  })  : assert(labelText != null),
        assert(child != null),
        super(key: key);

  final String labelText;
  final TextStyle labelStyle;
  final Widget child;
  final EdgeInsetsGeometry padding;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: padding,
      child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
        Text(
          labelText,
          style:
              labelStyle ?? Theme.of(context).inputDecorationTheme?.labelStyle,
        ),
        this.child
      ]),
    );
  }
}

在这个封装的组件中,我们允许外界指定顶部标题的内容、字体样式和整个组件内间距的大小

构建待办事项的标题和描述文本框

借助已经封装好的组件,我们来继续构建表单项:

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

const TextStyle _labelTextStyle = TextStyle(
  color: Color(0xFF1D1D26),
  fontFamily: 'Avenir',
  fontSize: 14.0,
);

const EdgeInsets _labelPadding = const EdgeInsets.fromLTRB(20, 10, 20, 20);
const InputBorder _textFormBorder = UnderlineInputBorder(
  borderSide: BorderSide(
    color: Colors.black26,
    width: 0.5,
  ),
);

class _EditTodoPageState extends State<EditTodoPage> {
  ...

  LabelGroup _buildTextFormField(
    String title,
    String hintText,
    int maxLines,
    String initialValue,
    FormFieldSetter<String> onSaved,
  ) {
    TextInputType inputType =
        maxLines == null ? TextInputType.multiline : TextInputType.text;
    return LabelGroup(
      labelText: title,
      labelStyle: _labelTextStyle,
      padding: _labelPadding,
      child: TextFormField(
        keyboardType: inputType,
        validator: (String? value) {
          return value!.length > 0 ? null : '$title不能为空';
        },
        onSaved: onSaved,
        textInputAction: TextInputAction.done,
        maxLines: maxLines,
        initialValue: initialValue,
        decoration: InputDecoration(
          hintText: hintText,
          enabledBorder: _textFormBorder,
        ),
      ),
    );
  }

}

这里我们首次使用了 TextFormField 这个 Widget,为什么在这里要使用它而不是继续使用 TextField 呢?
和 TextField 相比,TextFormField 少了一些与控制样式行为相关的属性,多了 4 个与表单提交相关的属性。
我们来看看这 4 个属性分别起什么作用。

  • autovalidate: 用于打开每个 TextFoormField 的自动校验功能。当其值设置为 true 时,TextFormField 会在每一次文本发生变更时调用 validator 属性设置的回调函数校验内容是否正确
  • initialValue: TextFormField 中文本的初始值
  • onSaved: 保存 TextFormField 内容的回调函数。当作为父容器的 Form 组件的 FormState.save 方法被调用时,每个 TextFormField 都会调用其对应的 onSaved 方法
  • validator: 检验 TextFormField 内容的回调函数

我们在搭建 "登录" 页面时,是在一个方法中校验用户名和密码是否合法,同时在另一个方法中获取所有输入框里的内容以便进行保存。
使用 TextFormField 可以帮我们简化这个过程,让我们能够把校验、保存逻辑写在每一个 TextFormField 中,这在我们构建一个内容较多的表单时是非常有利的。
了解了以上区别后,我们就可以利用 SingleChildScrollView 来搭建页面基本框架了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// lib/pages/edit_todo.dart
Widget _buildForm() {
    return SingleChildScrollView(
      child: Form(
        child: Column(
          children: [],
        ),
      ),
    );
  }

接下来我们需要利用 Form 组件对页面中的众多表单进行校验。
要实现这个功能,需要借助 GlobalKey 类,使得在 build 方法调用结束后依然可以获取到 Form 这个 StatefulWidget 的状态。
使用方法和之前用过的 TextController 类似:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// lib/pages/edit_todo.dart
class _EditTodoPageState extends State<EditTodoPage> {

  final GlobalKey<FormState> _formKey = GlobalKey();
  Map<OpenType, _OpenTypeConfig>? _openTypeConfigMap;

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

}

接下来,我们就利用之前写好的 _buildTextFormField 方法来构建最简单的两个表单项:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// lib/pages/edit_todo.dart
  Widget _buildForm() {
    return SingleChildScrollView(
      child: Form(
        key: _formKey,
        child: Column(
          children: [
            _buildTextFormField('名称', '任务名称', 1, _todo!.title, (value) {
              _todo!.title = value!;
            }),
            _buildTextFormField('描述', '任务描述', 1, _todo!.description,
                (value) => _todo!.description = value!)
          ],
        ),
      ),
    );
  }

做完了以上这些工作,我们就可以看到页面上出现了待办事项的标题和描述,并且在点击操作完成后,如果输入的内容不正确,就会出现对应的提示

构建较为复杂的日期选择器组件和时间选择器

现在,我们该实现日期选择器和时间选择器了。
日期选择器和时间选择器的交互效果相似,都是被点击后弹出一个选择对话框,选择日期和时间之后,页面上会展示选择效果

了解 DatePicker 和 TimePicker

DatePicker 和 TimePicker 是分别用来选择日期和时间的组件,一般很少直接使用,更多的是使用 showDatePicker 函数和 showTimePicker 函数。
这两个函数对弹出日期对话框和弹出时间对话框的操作做了封装,返回值是选择的日期值和时间值。
我们先来看看 showDatePicker 函数的典型用法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Future<DateTime> selectedDate = showDatePicker(
  context: context,
  initialDate: DateTime.now(),
  firstDate: DateTime(2018),
  lastDate: DateTime(2030),
  builder: (BuildContext context, Widget child) {
    return Theme(
      data: ThemeData.dart(),
      child: child,
    )
  }
)

执行这段代码会弹出一个日期选择器。这个选择器允许用户从 2018 年到 2030 年之间选择一个日期返回,
同时时间的初始选择值被设置为了当前日期。
showDatePicker 函数使用 builder 参数给日期选择器指定了黑色主题。

我们再来看看 showTimePicker 函数的典型用法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Future<TimeOfDay> selectedTime = showTimePicker(
  context: context,
  initialTime: TimeOfDay.now(),
  builder: (BuildContext context, Widget child) {
    return Theme(
      data: ThemeData.dark(),
      child: child,
    );
  }
);

执行这段代码会弹出一个时间选择器。
这个选择器允许用户选择时间。showTimePicker 函数使用 builder 参数给时间选择器指定了黑色主题

注意:
使用 showDatePicker 和 showTimePicker 这两个 API 展示出来的选择面板中的文字,其类型取决于系统的语言设置。
在我们的待办事项应用中,我们在 main.dart 中利用 supportedLocales 字段声明了我们支持的两个类型的语言: 英文和中文。
如果在模拟器中调试时发现弹出的页面是英文的,可以将模拟器中系统的语言设置为中文。

封装日期选择器和时间选择器

在了解了 showDataPikcer 和 showTimePicker 函数的简单用法之后,我们来看看如何在表单页面中使用它们。
在表单页面中,我们主要希望能够复用之前的 LabelGroup 制作一个日期或时间表单项,并在被点击后弹出日期选择器或时间选择器。
为了便于复用,我们继续对这样的功能进行封装。

我们将日期选择器封装为一个 DateFieldGroup 组件:

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

class DateFieldGroup extends StatelessWidget {
  const DateFieldGroup({
    Key? key,
    required this.initialDate,
    required this.startDate,
    required this.endDate,
    this.initialDatePickerMode = DatePickerMode.day,
    required this.child,
    required this.onSelect,
  }) : super(key: key);

  final DateTime initialDate;
  final DateTime startDate;
  final DateTime endDate;
  final DatePickerMode initialDatePickerMode;
  final Widget child;
  final Function(DateTime) onSelect;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      child: AbsorbPointer(
        child: child,
      ),
      onTap: () async {
        DateTime? selectedDate = await showDatePicker(
          context: context,
          initialDate: initialDate,
          firstDate: startDate,
          lastDate: endDate,
          initialDatePickerMode: initialDatePickerMode,
        );
        if (selectedDate != null && onSelect != null) {
          onSelect(selectedDate);
        }
      },
    );
  }
}

通过这段代码可以看出,新组件的代码极其简洁,大部分属性还是 showDatePicker 函数中的属性。
而 child 属性用于将 TextField 组件传递进来,而且可以让用户决定用什么组件显示日期。
这样,新组件就具有了很好的扩展性

对时间选择器的封装也是类似的,不过要更加简单一些:

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

class TimeFieldGroup extends StatelessWidget {
  const TimeFieldGroup({
    Key? key,
    required this.initialTime,
    required this.child,
    required this.onSelect,
  }) : super(key: key);

  final TimeOfDay initialTime;
  final Widget child;
  final Function(TimeOfDay) onSelect;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      child: AbsorbPointer(
        child: child,
      ),
      onTap: () async {
        TimeOfDay? timeOfDay =
            await showTimePicker(context: context, initialTime: initialTime);
        if (timeOfDay != null && onSelect != null) {
          onSelect(timeOfDay);
        }
      },
    );
  }
}

构建日期选择器和时间选择器

如何使用我们新封装的组件呢?
我们是仿照 TextField 组件实现的新组件,所以像使用 TextField 组件那样去使用新封装的组件就行了。
下面我们给待办事项创建页添加 "日期选择框"。
首先,需要利用我们已经封装好的组件来构建具体的 UI,首先是日期选择器:

1
2
3
4
5
6
// lib/extension/date_time.dart
extension DateTimeUtils on DateTime {
  ...

  String get dateString => '$year/$month/$day';
}
 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
// lib/pages/edit_todo.dart
class _EditTodoPageState extends State<EditTodoPage> {
  ...

  final TextEditingController _dateTextEditingController =
      TextEditingController();

  ...

  @override
  void didChangeDependencies() {
    ...
    _dateTextEditingController.text = _todo!.date!.dateString;
  }

  @override
  void dispose() {
    super.dispose();
    _dateTextEditingController.dispose();
  }

  _buildDateFormField(
    String title,
    String hintText,
    DateTime initialValue,
    TextEditingController controller,
    Function(DateTime) onSelect,
  ) {
    DateTime now = DateTime.now();
    return LabelGroup(
      labelText: title,
      labelStyle: _labelTextStyle,
      padding: _labelPadding,
      child: DateFieldGroup(
        onSelect: onSelect,
        child: TextFormField(
          controller: controller,
          decoration: InputDecoration(
            hintText: hintText,
            disabledBorder: _textFormBorder,
          ),
          validator: (String? value) {
            return value == null ? '$title 不能为空' : null;
          },
        ),
        initialDate: initialValue,
        startDate: initialValue ?? DateTime(now.year, now.month, now.day - 1),
        endDate: DateTime(2025),
      ),
    );
  }

  Widget _buildForm() {
    return SingleChildScrollView(
      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;
            }),
          ],
        ),
      ),
    );
  }
}

为了方便复用,需要将待办事项的开始日期转为 String 类型的数据,我们将相关逻辑写在了 lib/extension/date_time.dart

然后是时间选择器:

1
2
3
4
5
6
// 新建 lib/extension/time_of_day.dart
import 'package:flutter/material.dart';

extension TimeOfDayUtils on TimeOfDay {
  String get timeString => '$hour:$minute';
}
  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
// lib/pages/edit_todo.dart
import 'package:todo/extension/time_of_day.dart';

class _EditTodoPageState extends State<EditTodoPage> {
  ...
  final TextEditingController _startTimeTextEditingController =
      TextEditingController();
  final TextEditingController _endTimeTextEditingController =
      TextEditingController();

  @override
  void didChangeDependencies() {
    ...
    _startTimeTextEditingController.text = _todo!.startTime.timeString;
    _endTimeTextEditingController.text = _todo!.endTime.timeString;
  }

  @override
  void dispose() {
    ...
    _startTimeTextEditingController.dispose();
    _endTimeTextEditingController.dispose();
  }

  _buildTimeFormField(
    String title,
    String hintText,
    TextEditingController controller,
    TimeOfDay initialValue,
    Function(TimeOfDay) onSelect,
  ) {
    DateTime now = DateTime.now();
    return LabelGroup(
      labelText: title,
      labelStyle: _labelTextStyle,
      padding: _labelPadding,
      child: TimeFieldGroup(
        onSelect: onSelect,
        child: TextFormField(
          controller: controller,
          decoration: InputDecoration(
            hintText: hintText,
            disabledBorder: _textFormBorder,
          ),
          validator: (String? value) {
            return value!.length > 0 ? null : '$title 不能为空';
          },
        ),
        initialTime: initialValue,
      ),
    );
  }

  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(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: [
                    Expanded(
                      child: _buildTimeFormField(
                          '开始时间',
                          '请选择开始时间',
                          _startTimeTextEditingController,
                          _todo!.startTime, (value) {
                        _todo!.startTime = value;
                        _startTimeTextEditingController.text =
                            _todo!.startTime.timeString;
                      }),
                    ),
                    Expanded(
                      child: _buildTimeFormField(
                          '终止时间',
                          '请选择终止时间',
                          _endTimeTextEditingController,
                          _todo!.endTime, (value) {
                        _todo!.endTime = value;
                        _endTimeTextEditingController.text =
                            _todo!.endTime.timeString;
                      }),
                    ),
                  ],
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}