前言:
在上一篇文章中,我们讲解了Flutter文本的组成部分和Flutter 文本渲染到屏幕上的逻辑。文本的输出我们已经分析完成了,那么文本的输入又是怎么样的呢?在Flutter中,我们知道文本的输入可以通过TextField
等组件将文字输入到App中,但是它背后的原理是什么呢,为什么可以编辑文本呢?在这一篇文章中,就让我们从Flutter的可编辑文本的实现原理,再到自定义可编辑的文本...希望能对你认识Flutter的文本编辑有所帮助。
注:本文的涉及较多文本编辑的核心逻辑,和大量的功能实践,建议收藏!
TextField背后的存在
在开始具体的分析前,大家可以先看下上面这张流程图,如果你和我一样,好奇Flutter的文本渲染和文本编辑之间有哪些联系,那么当你看完上图后会发现,从TextPainter
开始就是相同的了,这也意味着,我们可以只分析TextPainter
上层的部分。
组件层
每当我们想要在Flutter中进行文本的输入或者编辑时,我们通常会首先想到TextField
这个组件,除了iOS和macOS外的系统都会使用它,它是属于Material库的一部分,和它对应的是Cupertino库中的CupertinoTextField
。
除了这两个组件,大家可能还会想到TextFormField
这个组件,但是它其实只是一个能帮助你更快速的实现一些类似保存逻辑的功能,它本质上还是TextField
。
1 2 3 4 5 6 7 8 9 10 11 |
class TextFormField extends FormField<String> { TextFormField({ }) return UnmanagedRestorationScope( ... child: TextField( ..... ), ); }, ); } |
TextField
和CupertinoTextField
它们是有状态的组件。它们需要处理焦点、手势、鼠标悬停...等内容。但是无论是使用 TextField
还是 CupertinoTextField
最后都会创建EditableText
。
-
来自TextField 类 - 材料库 - Dart API:
EditableText,它是
TextField
核心的原始文本编辑控件。EditableText
小部件很少直接使用,除非您正在实现完全不同的设计语言,例如 Cupertino。
当本文写到这里时,发现郭哥已经很详细的分析了TextField
的内部原理,对于TextField
的内部原理本文就不过多赘述了。
推荐阅读:Flutter 快速解析 TextField 的内部原理 — @恋猫de小郭
如何自定义编辑的行为?
自定义的编辑行为主要为下面几块部分
- 格式化输入框的数据
- 自定义文本选中范围
- 自定义光标位置
通过TextInputFormatter格式化输入框的数据
在日常开发中,我们经常会碰到需要用户提交身份证信息,电话号码,银行卡号码等需求,在输入框中获取有效的格式化的数据是必不可少的。在自定义后,能使提交流程简化,大幅度减少错误信息,提高用户体验。
在Flutter中可以使用TextInputFormatter
这个类获取到有效的格式化的数据,TextField
可以使用它在编辑文本时纠正文本格式。
基本使用:
Flutter提供了两个基础的TextInputFormatter
FilteringTextInputFormatter
— 创建一个格式化工具,常与正则表达式一起使用。LengthLimitingTextInputFormatter
— 只允许输入一定数量的字符。
1 2 3 4 5 6 7 8 9 |
TextFormField( inputFormatters: [ //.allow是只允许输入xx //.deny是不允许输入xx //FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z]')),不允许输入字母 FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z]')),//只允许输入字母 LengthLimitingTextInputFormatter(5) //只允许输入五个字符 ], ) |
自定义TextInputFormatter
在日常开发时,仅仅只靠正则表达式是不够的,我们需要针对需求创建自定义的格式化工具。我们可以扩展TextInputFormatter
实现formatEditUpdate
方法来实现自定义的TextInputFormatter
。
为了有更多的扩展性,此处实现一个格式化模板,只需传入xxx-xxxx-xxxx
的手机号格式即可,若有其他需求,如银行卡号码等,只需传入它的格式。
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 |
class AllFormatter extends TextInputFormatter { final String model; //格式 final String? separator; //识别格式后中间的分割字符 AllFormatter({ required this.model, required this.separator, }); //通过TextEditingValue可以读取和写入文本 @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue) { var oldText = oldValue.text; var newText = newValue.text; //判断是否有输入文本 if (newText.isNotEmpty) { if (newText.length > oldText.length) { if (newText.length > model.length) return oldValue; if (newText.length < model.length && model[newText.length - 1] == separator) { return TextEditingValue( //text代表用户输入后的文本(用户自己输入的,经过程序逻辑处理后的文本) text: "$oldText$separator${newText.substring(newText.length - 1)}", //通过selection你可以知道当前所选择的光标位置和选择范围 selection: TextSelection.collapsed(offset: newValue.selection.end + 1)); } } } return newValue; } } |
使用
1 2 3 |
TextField( inputFormatters: [AllFormatter(model: "xxx-xxxx-xxxx", separator: '-')], ); |
若需求改变只需要传入不同的model
,和separator
就可以了。
在开发需求中,碰到需要限制金额等,限制输入数字大小的需求时,我们也一样可以通过自定义TextInputFormatter
来实现。设置好限制的大小后,如果输入的值超过这个数字,则值自动等于限制的大小。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
class MaxInputFormatter extends TextInputFormatter { final double maxValue; //需要限制的大小 MaxInputFormatter({required this.maxValue}); @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue) { String newText = newValue.text; //通过double.tryParse() 检查字符串是否为数字字符串。 //如果返回值等于null,则输入不是数字字符串。 double? value = double.tryParse(newText); if (value == null) { return TextEditingValue(text: newText, selection: newValue.selection); } if (value > maxValue) { newText = maxValue.toString(); } return TextEditingValue(text: newText, selection: newValue.selection); } } |
通过TextInputFormatter
我们可以很容易的实现各种格式化的工具,还有很多的功能大家可以自行探索。
自定义文本选中范围
自定义文本可以很大程度上提高用户的体验(前提是处理好的情况下),例如在一段长文本中,可以通过文本选中范围,快速定位到需要的文本,然后对进行复制、删除、修改...等功能。
在Flutter中,我们可以通过设置TextField
的controller
中的selection
,来实现文本选中。
我们通过实现在一段文本中,快速定位选中姓名的例子,来看下怎样自定义文本选中范围。
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 |
int extent = 0; int base = 0; selectText(String text) { String name = "Taxze"; //判断文本中是否有需要查找的内容 if (text.contains(name)) { //定位到出现内容的第一个位置 extent = text.indexOf(name); base = extent + name.length; } } Widget selectionText() => TextField( controller: TextEditingController.fromValue( TextEditingValue( // 设置内容 text: "Hello Taxze", //设置选中范围 selection: TextSelection( baseOffset: base, extentOffset: extent, ), ), ), ); |
自定义选中文本范围使用恰当的话,我相信可以给用户带来更好的体验!
自定义光标位置
与自定义选中文本范围一样,自定义光标的位置也会有更多的体验。自定义光标和自定义选中范围类似,这里就不在多说了。
1 2 3 4 5 |
TextEditingValue( // 设置内容 text: "Hello Taxze", selection: TextSelection.collapsed(offset: 10), //设置光标位置 ), |
TextEditingValue
分析了这么多TextEditingValue
的应用,现在来分析它本身。
TextEditingValue
有三个属性:
-
String text
:TextField
显示的默认值,相当于TextEditingController
中的text
。因为查看源码就可以发现,TextEditingController
里的text
最终将会赋值给TextEditingValue.text
。12TextEditingController({ String? text }): super(text == null ? TextEditingValue.empty : TextEditingValue(text: text)); TextSelection selection
:通过它可以知道当前选择的光标位置和选择范围,通过它也可以设置光标在换行时的精确位置。TextRange composing
:当前编辑单词的偏移量,当你输入某些文本时,它的下方会有下划线,同时,系统键盘的上方会有建议的文本,点击建议的文本即可替换下划线的文本。
可编辑的文本包含哪些内容呢?
我们已经知道在Flutter中,无论是使用 TextField
还是 CupertinoTextField
最后都会创建EditableText
。也就是因为这个EditableText
它将其他的可编辑的模块都集成了后,才能与系统键盘进行通信,才能在编辑文本时,出现光标、选中文本、可垂直滚动文本...
①具有样式、结构(文本高度)、文本对齐方式、本地化
EditableText
中,具有样式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
//可以重写此方法以自定义文本的外观。 TextSpan buildTextSpan({required BuildContext context, TextStyle? style , required bool withComposing}) { if (!value.isComposingRangeValid || !withComposing) { return TextSpan(style: style, text: text); } final TextStyle composingStyle = style?.merge(const TextStyle(decoration: TextDecoration.underline)) ?? const TextStyle(decoration: TextDecoration.underline); return TextSpan( style: style, children: <TextSpan>[ TextSpan(text: value.composing.textBefore(value.text)), TextSpan( style: composingStyle, text: value.composing.textInside(value.text), ), TextSpan(text: value.composing.textAfter(value.text)), ], ); } |
通过StrutStyle
已确保输入的文本符合分配的空间
1 2 3 4 5 6 7 |
StrutStyle get strutStyle { //如果为空,将继承style if (_strutStyle == null) { return StrutStyle.fromTextStyle(style, forceStrutHeight: true); } return _strutStyle!.inheritFromTextStyle(style); } |
文本具有对齐方式,默认为TextAlign.start
,同时具有文本的方向textDirection
,用于决定TextAlign.start
或TextAlign.end
的值。
1 2 3 4 5 6 7 |
EditableText({ super.key, ... this.textAlign = TextAlign.start, this.textDirection, ... }) |
具有Locale
,可以根据手机系统语言环境的不同,以不同的方式呈现文本。
②具有文本布局
EditableText
的布局取决于maxLines
、minLines
和是否启用expands
- 如果最大行数为一(默认为一),则将在一行上水平滚动。
- 如果最大行数为空,则设置为最小行数,并垂直增长。
- 如果最大行数大于 1,则按照最小行数进行布局,并垂直一行行增加,直到达到最大行数。
- 当达到其最大高度,它将垂直滚动。
- 如果启用了扩展,它会根据传入的约束调整大小。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
static TextInputType _inferKeyboardType({ required Iterable<String>? autofillHints, required int? maxLines, }) { if (autofillHints == null || autofillHints.isEmpty) { return maxLines == 1 ? TextInputType.text : TextInputType.multiline; } ... if (maxLines != 1) { return TextInputType.multiline; } ... return inferKeyboardType[effectiveHint] ?? TextInputType.text; } |
在EditableText
的build
方法中,嵌套了一层Scrollable
,从而使文本可以垂直滚动显示多行文本,水平滚动以显示单行文本。
1 2 3 4 5 6 7 8 9 |
@override Widget build(BuildContext context) { ... return MouseRegion( ... child: Scrollable() ), ); } |
③对文本的更改有完整的处理流程
当文本的内容被更改时,EditableText
首先会调用onChanged
,通常会通知TextFiled
去更改文本、光标或选择文本范围。
1 |
final ValueChanged<String>? onChanged; |
然后当用户按下键盘上的搜索或者发送键时,会调用onEditingComplete
,将用户输入的内容提交给controller
。
注EditableText
通过_finalizeEditing
处理键盘的操作。
1 2 3 4 5 6 7 8 9 |
@override void performAction(TextInputAction action) { switch (action) { ... //完成编辑(不代表用户结束了输入文本) _finalizeEditing(action, shouldUnfocus: false); break; } } |
最后当用户确认输入完成后,调用onSubmitted
(大部分情况下,onSubmitted
会在onChanged
后调用)。
④当文本发生更改时,可通过updateEditingValue
更新编辑的文本
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 |
TextEditingValue? _lastKnownRemoteTextEditingValue; @override TextEditingValue get currentTextEditingValue => _value; //用于处理文本的编辑更新 @override void updateEditingValue(TextEditingValue value) { if (!_shouldCreateInputConnection) { return; } if (widget.readOnly) { //如果是只读的模式下,只需要观察选择文本范围,其他都不用关心。 value = _value.copyWith(selection: value.selection); } _lastKnownRemoteTextEditingValue = value; if (value == _value) { //如果在输入一个数字后,删除它,这时候引擎会通知两次,所以需要一个判断。 return; } if (value.text == _value.text && value.composing == _value.composing) { //当只有文本选择范围发生变化时 _handleSelectionChanged(value.selection, (_textInputConnection?.scribbleInProgress ?? false) ? SelectionChangedCause.scribble : SelectionChangedCause.keyboard); } else { ... } //无论发生了什么变化,都需要一个showCaretOnScreen,使用户能观察到文本发生的变化。 _scheduleShowCaretOnScreen(withAnimation: true); if (_hasInputConnection) { _stopCursorBlink(resetCharTicks: false); _startCursorBlink(); } } |
如何更好的处理输入表单?
表单是我们用于收集用户数据的重要方式,它在应用程序中是不可或缺的组件(不只是移动端)。在用户的登录/注册、地址填写、身份信息填写...等场景中有着很重要的作用,那么在Flutter中,如何使用Form类带来更好的用户体验呢?
①通过Globalkey保存表单状态
Flutter Form组件是用于保存、验证表单文本的。
1 2 3 4 5 6 |
final _formKey = GlobalKey<FormState>(); Widget formText() => Form( key: _formKey, ... ); |
②将TextFormField添加到表单中
添加两个TextFormField
,用于获取姓名和电话号码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
Form( key: _formKey, child: Column( children: <Widget>[ TextFormField( keyboardType: TextInputType.name, //当获取到焦点时,弹出的键盘类型,使其编辑框有更好的用于体验。 textInputAction: TextInputAction.next, //设置键盘右下角的操作按钮按钮,此处为→按钮 decoration: const InputDecoration( hintText: '请输入姓名', labelText: 'Name', //当获取到焦点时显示 ), ), TextFormField( keyboardType: TextInputType.phone, textInputAction: TextInputAction.done,//此处为完成按钮 decoration: const InputDecoration( hintText: '请输入电话号码', labelText: 'Phone Number', ), ), ], ), ) |
③分配FocusNode,使表单可以提交数据
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 |
final _formKey = GlobalKey<FormState>(); //定义两个FocusNode final FocusNode _nameFocusNode = FocusNode(); final FocusNode _phoneFocusNode = FocusNode(); _nextFocus(FocusNode focusNode) { //点击键盘上的next按钮,之间聚焦到下个焦点的输入框,提高用户体验 FocusScope.of(context).requestFocus(focusNode); } _submitForm() { //底部弹出完成SnackBar ScaffoldMessenger.of(context) .showSnackBar(const SnackBar(content: Text('完成'))); } Widget formText() => Form( key: _formKey, child: Column( children: <Widget>[ TextFormField( keyboardType: TextInputType.name, textInputAction: TextInputAction.next, focusNode: _nameFocusNode, onFieldSubmitted: (String value) { _nextFocus(_phoneFocusNode); //点击按钮触发的回调 }, decoration: const InputDecoration( hintText: '请输入姓名', labelText: 'Name', ), ), TextFormField( keyboardType: TextInputType.phone, textInputAction: TextInputAction.done, focusNode: _phoneFocusNode, onFieldSubmitted: (String value) { _submitForm(); }, decoration: const InputDecoration( hintText: '请输入电话号码', labelText: 'Phone Number', ), ), ], ), ); |
④验证数据
在提交数据前,我们可以根据需求对数据进行验证,大家可以根据需求自己定义,例如:
1 2 3 4 5 6 |
String _dataInput(String value) { if (value.trim().isEmpty) { return '此为必填项'; } return ""; } |
尾述
在这篇文章中,我们知道了文本的编辑是包含了哪些内容,知道了如何自定义编辑的操作,也知道了如何更好的实现一个表单。但这也只是文本的输入编辑、文本的优化的冰山一角。在后续的文章中我也会和大家一起持续探索。希望这篇文章能对你有所帮助,有问题欢迎在评论区留言讨论~
参考&推荐阅读
Flutter 快速解析 TextField 的内部原理 — @恋猫de小郭