前言
在上一章中,我们分析了一个富文本编辑器需要有哪些模块组成。在本文中,让我们从零开始,去实现自定义的富文本编辑器。
注:本文篇幅较长,从失败的方案开始分析再到成功实现自定义富文本编辑器,真正的从0到1。建议收藏!
— 完整代码太多, 文章只分析核心代码,需要源码请到 代码仓库
错误示范
遭一蹶者得一便,经一事者长一智。——宋·无名氏《五代汉史平话·汉史》
在刚开始实现富文本时,为了更快速的实现富文本的功能,我利用了TextField这个组件,但写着写着发现TextField有着很大的局限性。不过错误示范也给我带来了一些启发,那么现在就让我和大家一起去探索富文本编辑器的世界吧。
最后效果图:
定义文本格式
作为基础的富文本编辑器实现,我们需要专注于简单且重要的部分,所以目前只需定义标题、文本对齐、文本粗体、文本斜体、下划线、文本删除线、文本缩进符等富文本基础功能。
定义文本颜色:
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);
}
}
定义功能枚举类
enum RichTextInputType {
header1,
header2,
...
}
定义富文本样式
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,
);
}
定义不同样式文本间距
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时,加上前置占位符
/// 效果-> ·Hello Taxze
String prefix(List<RichTextInputType> list) {
for (RichTextInputType i in list) {
switch (i) {
case RichTextInputType.list:
return '\u2022';
default:
return '';
}
}
return '';
}
封装RichTextField
为了让TextField
更好的使用自定义的样式,需要对它进行一些简单的封装。
=== 完整代码,请前往仓库中的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
的同时,不对其子控件施加任何约束,不影响子控件的布局。
效果图:
@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 = []
存放每个输入块的样式
再进一步分析后,我们还需要这些模块:
- 返回当前焦点所在输入块的索引
- 插入新的输入块
- 修改输入块的样式
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
。
@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
类型的数组。
class TextSpan extends InlineSpan{}
class WidgetSpan extends PlaceholderSpan{}
abstract class PlaceholderSpan extends InlineSpan {}
构建TextSpan
///构建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
,那么我们就要先从它入手。
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
中创建子部件。
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,用于细分新旧状态之间的变化量。
class BasicTextInput extends State<BasicTextInputState>
with TextSelectionDelegate
implements DeltaTextInputClient {}
让我们从用户行为来分析实现BasicTextInput,当用户编辑文字时,需要先点击屏幕,需要我们先获取到焦点后,用户才能进一步输入文字。
///获取焦点,键盘输入
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();
}
}
当用户编辑文本后,我们需要更新编辑文本的值。
///更新编辑的值,输入一个值就要经过该方法
@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]);
}
}
}
有了基础了编辑文字,那么如何复制粘贴文字呢?
//粘贴文字
@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();
}
隐藏文本工具栏
//隐藏工具栏
@override
void hideToolbar([bool hideHandles = true]) {
if (hideHandles) {
_selectionOverlay?.hide();
} else if (_selectionOverlay?.toolbarIsVisible ?? false) {
// 只隐藏工具栏
_selectionOverlay?.hideToolbar();
}
}
不过,当文本发生变化时,需要对文本编辑进行更新时,更新的值必须在文本选择的范围内。
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
@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实现了基本的富文本编辑器,通过失败的简单案例,在分析吸取经验后实现扩展好的富文本编辑器。在下一篇文章中,会实现更多对富文本编辑器的扩展。希望这篇文章能对你有所帮助,有问题欢迎在评论区留言讨论~