前言
在上一章中,我们分析了一个富文本编辑器需要有哪些模块组成。在本文中,让我们从零开始,去实现自定义的富文本编辑器。
注:本文篇幅较长,从失败的方案开始分析再到成功实现自定义富文本编辑器,真正的从0到1。建议收藏!
— 完整代码太多, 文章只分析核心代码,需要源码请到 代码仓库,也可在本站下载代码拷贝。
错误示范
遭一蹶者得一便,经一事者长一智。——宋·无名氏《五代汉史平话·汉史》
在刚开始实现富文本时,为了更快速的实现富文本的功能,我利用了TextField这个组件,但写着写着发现TextField有着很大的局限性。不过错误示范也给我带来了一些启发,那么现在就让我和大家一起去探索富文本编辑器的世界吧。
最后效果图:
定义文本格式
作为基础的富文本编辑器实现,我们需要专注于简单且重要的部分,所以目前只需定义标题、文本对齐、文本粗体、文本斜体、下划线、文本删除线、文本缩进符等富文本基础功能。
定义文本颜色
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
class RichTextColor { //定义默认颜色 static const defaultTextColor = Color(0xFF000000); static const c_FF0000 = Color(0xFFFF0000); ... ///用户自定义颜色解析 ///=== 如需方法分析,请参考https://juejin.cn/post/7154151529572728868#heading-11 === Color stringToColor(String s) { if (s.startsWith('rgba')) { s = s.substring(5); s = s.substring(0, s.length - 1); final arr = s.split(',').map((e) => e.trim()).toList(); return Color.fromRGBO(int.parse(arr[0]), int.parse(arr[1]), int.parse(arr[2]), double.parse(arr[3])); } ... return const Color.fromRGBO(0, 0, 0, 0); } } |
定义功能枚举类
1 2 3 4 5 |
enum RichTextInputType { header1, header2, ... } |
定义富文本样式
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 |
TextStyle richTextStyle(List<RichTextInputType> list, {Color? textColor}) { //默认样式 double fontSize = 18.0; FontWeight fontWeight = FontWeight.normal; Color richTextColor = RichTextColor.defaultTextColor; TextDecoration decoration = TextDecoration.none; FontStyle fontStyle = FontStyle.normal; //分析用户选中样式 for (RichTextInputType i in list) { switch (i) { case RichTextInputType.header1: fontSize = 28.0; fontWeight = FontWeight.w700; break; ... } } return TextStyle( fontSize: fontSize, fontWeight: fontWeight, fontStyle: fontStyle, color: richTextColor, decoration: decoration, ); } |
定义不同样式文本间距
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
EdgeInsets richTextPadding(List<RichTextInputType> list) { //默认间距 EdgeInsets edgeInsets = const EdgeInsets.symmetric( horizontal: 16.0, vertical: 4.0, ); for (RichTextInputType i in list) { switch (i) { case RichTextInputType.header1: edgeInsets = const EdgeInsets.only( top: 24.0, right: 16.0, bottom: 8.0, left: 16.0, ); break; ... } } return edgeInsets; } |
当为list type时,加上前置占位符
1 2 3 4 5 6 7 8 9 10 11 12 |
/// 效果-> ·Hello Taxze String prefix(List<RichTextInputType> list) { for (RichTextInputType i in list) { switch (i) { case RichTextInputType.list: return '\u2022'; default: return ''; } } return ''; } |
封装RichTextField
为了让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 |
=== 完整代码,请前往仓库中的rich_text_field.dart === @override Widget build(BuildContext context) { return TextField( controller: controller, focusNode: focusNode, //用于自动获取焦点 autofocus: true, //multiline为多行文本,常配合maxLines使用 keyboardType: TextInputType.multiline, //将maxLines设置为null,从而取消对行数的限制 maxLines: null, //光标颜色 cursorColor: RichTextColor.defaultTextColor, textAlign: textAlign, decoration: InputDecoration( border: InputBorder.none, //当为list type时,加入占位符 prefixText: prefix(inputType), prefixStyle: richTextStyle(inputType), //减少垂直高度减少,设为密集模式 isDense: true, contentPadding: richTextPadding(inputType), ), style: richTextStyle(inputType, textColor: textColor), ); } |
自定义Toolbar工具栏
这里使用PreferredSize
组件,在自定义AppBar
的同时,不对其子控件施加任何约束,不影响子控件的布局。
效果图:
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 |
@override Widget build(BuildContext context) { return PreferredSize( //直接设置AppBar的高度 preferredSize: const Size.fromHeight(56.0), child: Material( //绘制适当的阴影 elevation: 4.0, color: widget.color, //SingleChildScrollView包裹Row,使其能横向滚动 child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ //功能按钮 Card( //是否选中了该功能 color: widget.inputType.contains(RichTextInputType.header1) ? widget.colorSelected : null, child: IconButton( icon: const Icon(Icons.font_download_sharp), color: widget.inputType.contains(RichTextInputType.header1) ? Colors.white : Colors.black, onPressed: () { //选中或取消该功能 widget.onInputTypeChange(RichTextInputType.header1); setState(() {}); }, ), ), ... ], ), ))); } |
全局控制管理
分析需要实现的功能后,我们需要将每一块样式分为一个输入块 (block) 。因此,我们需要存储三个列表,用来管理:
List<FocusNode> _nodes = []
存放每个输入块的焦点List<TextEditingController> _controllers = []
存放每个输入块的控制器List<List<RichTextInputType>> _types = []
存放每个输入块的样式
再进一步分析后,我们还需要这些模块:
- 返回当前焦点所在输入块的索引
- 插入新的输入块
- 修改输入块的样式
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 |
class RichTextEditorProvider extends ChangeNotifier { //默认样式 List<RichTextInputType> inputType = [RichTextInputType.normal]; ... //存放每个输入框的焦点 final List<FocusNode> _nodes = []; int get focus => _nodes.indexWhere((node) => node.hasFocus); //返回当前焦点索引 FocusNode nodeAt(int index) => _nodes.elementAt(index); ... //改变输入块样式 void setType(RichTextInputType type) { //判断改变的type是不是三种标题中的一种 if (type == RichTextInputType.header1 || type == RichTextInputType.header2 || type == RichTextInputType.header3) { //三种标题只能同时存在一个,isAdd用来判断是删除标题样式,还是修改标题样式 bool isAdd = true; //暂存需要删除的样式 RichTextInputType? begin; for (RichTextInputType i in inputType) { if ((i == RichTextInputType.header1 || i == RichTextInputType.header2 || i == RichTextInputType.header3)) { begin = i; if (i == type) { //如果用户点击改变的样式,已经存在了,证明需要删除这个样式。 isAdd = false; } } } //删除或修改样式 if (isAdd) { inputType.remove(begin); inputType.add(type); } else { inputType.remove(type); } } ... else { //如果不是以上type,则直接添加 inputType.add(type); } //修改输入块属性 _types.removeAt(focus); _types.insert(focus, inputType); notifyListeners(); } //在用户将焦点更改为另一个输入文本块时,更新键盘工具栏和insert() void setFocus(List<RichTextInputType> type) { inputType = type; notifyListeners(); } //插入 void insert({ int? index, String? text, required List<RichTextInputType> type, }) { // \u200b是Unicode中的零宽度字符,可以理解为不可见字符,给文本前加上它,目的是为了检测删除事件。 final TextEditingController controller = TextEditingController( text: '\u200B${text ?? ''}', ); controller.addListener(() { //如果用户随后按下退格键并删除起始字符,即\u200B //就会检测到删除事件,删除焦点文本输入块,同时将焦点移动到上面的文本输入块。 if (!controller.text.startsWith('\u200B')) { final int index = _controllers.indexOf(controller); if (index > 0) { //通过该语句可以轻松地将两个单独的块合并为一个 controllerAt(index - 1).text += controller.text; //文本选择 controllerAt(index - 1).selection = TextSelection.fromPosition( TextPosition( offset: controllerAt(index - 1).text.length - controller.text.length, ), ); //获取光标 nodeAt(index - 1).requestFocus(); //删除文本输入块 _controllers.removeAt(index); _nodes.removeAt(index); _types.removeAt(index); notifyListeners(); } } //处理删除事件。因为我们在封装TextField时,使用了keyboardType: TextInputType.multiline的键盘类型 //当用户按下回车键后,我们需要检测是否包含Unicode 的\n字符,如果包含了,我们需要创建新的文本编辑块。 if (controller.text.contains('\n')) { final int index = _controllers.indexOf(controller); List<String> split = controller.text.split('\n'); controller.text = split.first; insert( index: index + 1, text: split.last, type: typeAt(index).contains(RichTextInputType.list) ? [RichTextInputType.list] : [RichTextInputType.normal]); controllerAt(index + 1).selection = TextSelection.fromPosition( const TextPosition(offset: 1), ); nodeAt(index + 1).requestFocus(); notifyListeners(); } }); //创建新的文本输入块 _controllers.insert(index!, controller); _types.insert(index, type); _nodes.insert(index, FocusNode()); } } |
布局
常用Stack
,将工具栏Appbar
固定在页面底部。前面我们定义了ChangeNotifier
,现在需要使用ChangeNotifierProvider
。
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 |
@override Widget build(BuildContext context) { return ChangeNotifierProvider<RichTextEditorProvider>( create: (_) => RichTextEditorProvider(), builder: (BuildContext context, Widget? child) { return Stack(children: [ Positioned( top: 16, left: 0, right: 0, bottom: 56, child: Consumer<RichTextEditorProvider>( builder: (_, RichTextEditorProvider value, __) { return ListView.builder( itemCount: value.length, itemBuilder: (_, int index) { //分配焦点给它本身及其子Widget //同时内部管理着一个FocusNode,监听焦点的变化,来保持焦点层次结构与Widget层次结构同步。 return Focus( onFocusChange: (bool hasFocus) { if (hasFocus) { value.setFocus(value.typeAt(index)); } }, //文本输入块 child: RichTextField( inputType: value.typeAt(index), controller: value.controllerAt(index), focusNode: value.nodeAt(index), ), ); }, ); }, ), ), //固定在页面底部 Positioned( bottom: 0, left: 0, right: 0, child: Selector<RichTextEditorProvider, List<RichTextInputType>>( selector: (_, RichTextEditorProvider value) => value.inputType, builder: (BuildContext context, List<RichTextInputType> value, _) { //工具栏 return RichTextToolbar( inputType: value, onInputTypeChange: Provider.of<RichTextEditorProvider>( context, listen: false, ).setType, ); }, ), ) ]); }, ); } |
分析总结
通过上面的步骤,我们就能实现效果图中的功能了。但是,这样实现后,会出现几个对于富文本来说致命的问题:
- 由于
TextField
对富文本支持不完善,在对文本添加颜色、文本段落中添加图片时,有较大的困难。 - 无法选中
ListView
中未渲染的TextField
- ...
在遇到这些问题后,我想到了RichText
。它除了可以支持TextSpan
,还可以支持WidgetSpan
,这样在对文本添加颜色,或者在文本中插入图片这样放入Widget
的功能时就比较灵活了。对于文本选择问题,通过渲染多个TextField
不是个好方案。
正确案例
为了解决分析出的问题,第一点就是,我们不能再渲染多个TextField
,虽然也能通过同时控制多个controller
来解决部分问题,但是实现成本较高,实现后也会有很多缺陷。所以实现方案要从渲染多个输入块转为一个输入块,渲染多个TextSpan
。方案有了,那么让我们开始实现吧!
实现buildTextSpan方法来将文本转化为TextSpan
在之前的基础文本知识篇中,我们知道RichText
的text
属性接收一个InlineSpan
类型的对象(TextSpan
和WidgetSpan
是InlineSpan
的子类),而InlineSpan
又有一个叫做children
的List属性,接收InlineSpan
类型的数组。
1 2 3 |
class TextSpan extends InlineSpan{} class WidgetSpan extends PlaceholderSpan{} abstract class PlaceholderSpan extends InlineSpan {} |
构建TextSpan
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 |
///构建TextSpan @override TextSpan buildTextSpan({ required BuildContext context, TextStyle? style, required bool withComposing, }) { assert(!value.composing.isValid || !withComposing || value.isComposingRangeValid); //保留TextRanges到InlineSpan的映射以替换它。 final Map<TextRange, InlineSpan> rangeSpanMapping = <TextRange, InlineSpan>{}; // 迭代TextEditingInlineSpanReplacement,将它们映射到生成的InlineSpan。 if (replacements != null) { for (final TextEditingInlineSpanReplacement replacement in replacements!) { _addToMappingWithOverlaps( replacement.generator, TextRange(start: replacement.range.start, end: replacement.range.end), rangeSpanMapping, value.text, ); } } ... // 根据索引进行排序 final List<TextRange> sortedRanges = rangeSpanMapping.keys.toList(); sortedRanges.sort((a, b) => a.start.compareTo(b.start)); // 为未替换的文本范围创建TextSpan并插入替换的span final List<InlineSpan> spans = <InlineSpan>[]; int previousEndIndex = 0; for (final TextRange range in sortedRanges) { if (range.start > previousEndIndex) { spans.add(TextSpan( text: value.text.substring(previousEndIndex, range.start))); } spans.add(rangeSpanMapping[range]!); previousEndIndex = range.end; } // 后面添加的文字使用默认的TextSpan if (previousEndIndex < value.text.length) { spans.add(TextSpan( text: value.text.substring(previousEndIndex, value.text.length))); } return TextSpan( style: style, children: spans, ); } |
文本输入块的基础实现
为了更好的实现文本输入块,TextField
是不能够满足我们的。现在让我们开始实现自己的文本输入块。分析TextEditingController
我们可以知道,TextField
的最后执行相关逻辑的Widget
是_Editable
,那么我们就要先从它入手。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
return CompositedTransformTarget( link: _toolbarLayerLink, child: Semantics( onCopy: _semanticsOnCopy(controls), onCut: _semanticsOnCut(controls), onPaste: _semanticsOnPaste(controls), child: _ScribbleFocusable( focusNode: widget.focusNode, editableKey: _editableKey, enabled: widget.scribbleEnabled, updateSelectionRects: () { _openInputConnection(); _updateSelectionRects(force: true); }, child: _Editable( key: _editableKey, ... ), ), ), ); |
因为InlineSpan
有一个叫做children
的List属性,用于接收InlineSpan
类型的数组。我们需要通过遍历InlineSpan
,在WidgetSpan
中创建子部件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class _Editable extends MultiChildRenderObjectWidget { ... static List<Widget> _extractChildren(InlineSpan span) { final List<Widget> result = <Widget>[]; //通过visitChildren来实现对子节点的遍历 span.visitChildren((span) { if (span is WidgetSpan) { result.add(span.child); } return true; }); return result; } ... } |
定义了_Editable
后,我们需要构建基本的文本输入块。
Flutter 3.0以后,加入了DeltaTextInputClient,用于细分新旧状态之间的变化量。
1 2 3 |
class BasicTextInput extends State<BasicTextInputState> with TextSelectionDelegate implements DeltaTextInputClient {} |
让我们从用户行为来分析实现BasicTextInput,当用户编辑文字时,需要先点击屏幕,需要我们先获取到焦点后,用户才能进一步输入文字。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
///获取焦点,键盘输入 bool get _hasFocus => widget.focusNode.hasFocus; ///在获得焦点时打开输入连接。焦点丢失时关闭输入连接。 void _openOrCloseInputConnectionIfNeeded() { if (_hasFocus && widget.focusNode.consumeKeyboardToken()) { _openInputConnection(); } else if (!_hasFocus) { _closeInputConnectionIfNeeded(); widget.controller.clearComposing(); } } void requestKeyboard() { if (_hasFocus) { _openInputConnection(); } else { widget.focusNode.requestFocus(); } } |
当用户编辑文本后,我们需要更新编辑文本的值。
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 |
///更新编辑的值,输入一个值就要经过该方法 @override void updateEditingValueWithDeltas(List<TextEditingDelta> textEditingDeltas) { TextEditingValue value = _value; ... if (selectionChanged) { manager.updateToggleButtonsStateOnSelectionChanged(value.selection, widget.controller as ReplacementTextEditingController); } } @override void userUpdateTextEditingValue( TextEditingValue value, SelectionChangedCause cause) { if (value == _value) return; final bool selectionChanged = _value.selection != value.selection; if (cause == SelectionChangedCause.drag || cause == SelectionChangedCause.longPress || cause == SelectionChangedCause.tap) { // 这里的变化来自于手势,它调用RenderEditable来改变用户选择的文本区域。 // 创建一个TextEditingDeltaNonTextUpdate后,我们可以获取Delta的历史RenderEditable final bool textChanged = _value.text != value.text; if (selectionChanged && !textChanged) { final TextEditingDeltaNonTextUpdate selectionUpdate = TextEditingDeltaNonTextUpdate( oldText: value.text, selection: value.selection, composing: value.composing, ); if (widget.controller is ReplacementTextEditingController) { (widget.controller as ReplacementTextEditingController) .syncReplacementRanges(selectionUpdate); } manager.updateTextEditingDeltaHistory([selectionUpdate]); } } } |
有了基础了编辑文字,那么如何复制粘贴文字呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
//粘贴文字 @override Future<void> pasteText(SelectionChangedCause cause) async { ... // 粘贴文字后,光标的位置应该被定位于粘贴的内容后面 final int lastSelectionIndex = math.max( pasteRange.baseOffset, pasteRange.baseOffset + data.text!.length); _userUpdateTextEditingValueWithDelta( TextEditingDeltaReplacement( oldText: textEditingValue.text, replacementText: data.text!, replacedRange: pasteRange, selection: TextSelection.collapsed(offset: lastSelectionIndex), composing: TextRange.empty, ), cause, ); //如果用户操作来源于文本工具栏,那么则隐藏工具栏 if (cause == SelectionChangedCause.toolbar) hideToolbar(); } |
隐藏文本工具栏
1 2 3 4 5 6 7 8 9 10 |
//隐藏工具栏 @override void hideToolbar([bool hideHandles = true]) { if (hideHandles) { _selectionOverlay?.hide(); } else if (_selectionOverlay?.toolbarIsVisible ?? false) { // 只隐藏工具栏 _selectionOverlay?.hideToolbar(); } } |
不过,当文本发生变化时,需要对文本编辑进行更新时,更新的值必须在文本选择的范围内。
1 2 3 4 5 6 7 8 9 10 |
void _updateOrDisposeOfSelectionOverlayIfNeeded() { if (_selectionOverlay != null) { if (_hasFocus) { _selectionOverlay!.update(_value); } else { _selectionOverlay!.dispose(); _selectionOverlay = null; } } } |
构建_Editable
,Shortcuts
是通过按键或按键组合激活的键绑定。
具体参考:https://docs.flutter.dev/development/ui/advanced/actions_and_shortcuts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
@override Widget build(BuildContext context) { return Shortcuts( shortcuts: kIsWeb ? _defaultWebShortcuts : <ShortcutActivator, Intent>{}, child: Actions( actions: _actions, child: Focus( focusNode: widget.focusNode, child: Scrollable( viewportBuilder: (context, position) { return CompositedTransformTarget( link: _toolbarLayerLink, child: _Editable( key: _textKey, ... ), ); }, ), ), ), ); } |
分析到这里,我们就把自定义的富文本文本输入块实现了。当然,目前还要许多需要扩展和优化的地方,大家有兴趣可以持续关注代码仓库~
尾述
在这篇文章中,我们从0到1实现了基本的富文本编辑器,通过失败的简单案例,在分析吸取经验后实现扩展好的富文本编辑器。在下一篇文章中,会实现更多对富文本编辑器的扩展。希望这篇文章能对你有所帮助,有问题欢迎在评论区留言讨论~
参考
Flutter 快速解析 TextField 的内部原理 — @恋猫de小郭