Flutter从0到1实现高性能多功能的富文本编辑器(模块分析篇)
Posted 编程的平行世界
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Flutter从0到1实现高性能多功能的富文本编辑器(模块分析篇)相关的知识,希望对你有一定的参考价值。
通过阅读本文,您将了解到
- 了解富文本编辑器需要拥有的功能
- 知道编写富文本编辑器需要的代码模块
- 学会定义富文本配置JSON,并将其解析为富文本
前言:
经过前面两篇文章的基础知识铺垫,我们终于要进入到专栏的核心内容 — 富文本。富文本编辑器可以说是APP中最复杂,但使用场景又极广的组件之一。例如各大笔记APP的核心功能、闲鱼的商品发布功能、还有掘金APP的发布文章&发布沸点功能等,可以说是富文本编辑器让用户能以更简单更便携的方式记录内容。不过Flutter只有最基础的文本编辑组件TextField,在遇到复杂场景时就比较吃力了,例如图片的添加,有序段落…本文通过分析市场上的各大富文本编辑器的功能和Flutter优秀的富文本插件,从而来自定义自己的富文本编辑器,与大家一起探索文本的世界…
注:Flutter目前已经有许多优秀的开源富文本编辑器,例如:flutter_quill。为了更好的实现属于自己的`富文本编辑器,我们必须要先了解&学习这些优秀的开源项目。
对比分析各大APP的富文本编辑器
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VOHjSdew-1669544651776)(https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e48f2ab9191c4ffd8d70c2cea8f58435~tplv-k3u1fbpfcp-watermark.image?)]
对比各大APP的富文本编辑器后,我们可以将富文本功能总结为这些部分:
基础功能 | 扩展功能 |
---|---|
文本斜体 | 撤回←→ |
可改变文本大小 | 图片📸 |
可改变文本字体格式 | 视频🎬 |
可改变文本样式(颜色、粗细) | 代码块💻 |
文本下划线&删除线 | 链接🔍 |
标题(h1,h2,h3) | 语音📢 |
文本缩进 | 绘画块🎨 |
文本对齐 | 自定义背景🖼 |
… | … |
协议选择
如今有很多优秀的富文本编辑器,例如Quill、wangEditor、Prosemirror。根据开源协议、可扩展性、生态等方面的对比考虑之后,本专栏选用Quill协议做为我们富文本编辑器的协议。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Vl3dKmqU-1669544651778)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/7e527fb6666f4aef9533c8200cc19180~tplv-k3u1fbpfcp-zoom-1.image)]
——图片来源:Quill富文本编辑器的实践 - DevUI
富文本插件基础分析 — FlutterQuill
FlutterQuill是Quill在Flutter的版本,我们来分析下它的基础构成部分、以便更好的实现我们的富文本。
——注:分析的为部分代码,目的在于了解Flutter实现富文本需要哪些部分。
-
定义配置文件
为了保存输入的文本与样式,需要定义JSON配置文件,在文本需要保存时,只需将JSON提交到服务器。在渲染时,只需通过解析JSON内容进行渲染。
[ "insert": "Flutter Quill" //需要插入的文本 , "attributes": "header": 1 //h1标题样式 , "insert": "\\n" //换行 , ]
-
定义样式文件
富文本存在许多的样式,例如一二三级的标题样式,字体的颜色,字体的格式…若不定义这些样式文件,那么代码将难以修改维护。
颜色基础样式类:
=====定义颜色基础样式类,包含对JSON颜色的解析方法===== Color stringToColor(String? s, [Color? originalColor]) //定义基础的颜色 switch (s) case 'transparent': return Colors.transparent; case 'black': return Colors.black; .... //解析JSON数据 //"color": "rgba(0, 0, 0, 0.847)" if (s!.startsWith('rgba')) s = s.substring(5); //取rgba( 五个字符 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])); //返回Color值
字体大小基础类:
=====定义字体大小基础类===== dynamic getFontSize(dynamic sizeValue) //选择已定义的字体大小(小,大,超大) if (sizeValue is String && ['small', 'large', 'huge'].contains(sizeValue)) return sizeValue; //自定义字体大小 if (sizeValue is double) return sizeValue; if (sizeValue is int) return sizeValue.toDouble(); assert(sizeValue is String); final fontSize = double.tryParse(sizeValue); //检查字符串是否为数字字符串 if (fontSize == null) //如果不是,抛出一个异常 throw 'Invalid size $sizeValue'; return fontSize;
样式
=====文本样式===== bold: const TextStyle(fontWeight: FontWeight.bold), italic: const TextStyle(fontStyle: FontStyle.italic), small: const TextStyle(fontSize: 12), ... h1: DefaultTextBlockStyle( //h1的字体样式 defaultTextStyle.style.copyWith( fontSize: 34, color: defaultTextStyle.style.color!.withOpacity(0.70), height: 1.15, fontWeight: FontWeight.w300, decoration: TextDecoration.none, ), const Tuple2(16, 0), //间距 const Tuple2(0, 0), null), //自定义的样式类 class DefaultTextBlockStyle DefaultTextBlockStyle( this.style, this.verticalSpacing, this.lineSpacing, this.decoration, );
-
自定义控件
-
自定义光标
在android和在ios上的输入框光标是差距很大的,若没有分开适配,那会出现许多奇怪的问题。
-
定义光标的样式
class CursorStyle const CursorStyle( required this.color, required this.backgroundColor, this.width = 1.0, ... this.opacityAnimates = false, this.paintAboveText = false, ); ... //定义光标的圆角 final Radius? radius; //在绘制光标时使用的偏移量(文本的大小、样式、字体格式改变后、偏移量也会改变) final Offset? offset; //光标闪烁动画的绘制,在android平台和ios平台有不同的表现,需要适配 final bool opacityAnimates; //判断光标的绘制方式 final bool paintAboveText; ...
-
定义光标控制器
通过
ChangeNotifier
监听更新光标的动画与样式。class CursorCont extends ChangeNotifier CursorCont( required CursorStyle style, ... ) :_style = style ... CursorStyle _style; CursorStyle get style => _style; //在样式发生变化时,执行notifyListeners()方法 set style(CursorStyle value) if (_style == value) return; _style = value; notifyListeners(); //控制光标是否显示 void _cursorTick(Timer timer) _targetCursorVisibility = !_targetCursorVisibility; //如果需要显示光标,我们需要将不透明度的值设为1.0,如果想要光标消失,我们需要将其设为0.0 //当然,这个变化的过程是有曲线动画的,而且android和ios的动画时间会不同。 final targetOpacity = _targetCursorVisibility ? 1.0 : 0.0; if (style.opacityAnimates) _blinkOpa1cityController.animateTo(targetOpacity, curve: Curves.easeOut); else _blinkOpacityController.value = targetOpacity; ...
-
绘制光标
class CursorPainter ... //在画布指定位置绘制光标。 void paint( Canvas canvas, Offset offset, TextPosition position, bool lineHasEmbed) // 光标偏移量 var relativeCaretOffset = editable!.getOffsetForCaret(position, prototype); ... if (caretRect.left < 0.0) //为了避免ios光标因为滚动始终保持在一行的开头。不过会导致光标更靠近右边的字符,但是影响不大。 caretRect = caretRect.shift(Offset(-caretRect.left, 0)); final caretHeight = editable!.getFullHeightForCaret(position); if (caretHeight != null) //isAppleOS是自定义的基础函数,判断当前系统是macOS还是iOS if (isAppleOS()) // 将光标垂直居中插入 caretRect = Rect.fromLTWH( caretRect.left, caretRect.top + (caretHeight - caretRect.height) / 2, caretRect.width, caretRect.height, ); ...
-
适配iOS浮动光标
//使用浮动光标 FloatingCursorPainter get _floatingCursorPainter => FloatingCursorPainter( floatingCursorRect: _floatingCursorRect, style: _cursorController.style, ); class FloatingCursorPainter FloatingCursorPainter( required this.floatingCursorRect, required this.style, ); CursorStyle style; //使用Rect来存储绘制的位置信息 Rect? floatingCursorRect; final Paint floatingCursorPaint = Paint(); void paint(Canvas canvas) final floatingCursorRect = this.floatingCursorRect; final floatingCursorColor = style.color.withOpacity(0.75); if (floatingCursorRect == null) return; //绘制圆角矩形(绘制浮动光标) canvas.drawRRect( //使用fromRectAndRadius定义一个Rect RRect.fromRectAndRadius(floatingCursorRect, _kFloatingCaretRadius), floatingCursorPaint..color = floatingCursorColor, );
-
-
通过继承
RenderObjectWidget
自定义输入框控件为了有更好的可扩展性,对于文本的输入和编辑,就最好不要使用
TextField
这些Flutter已经提供的组件了。我们通过RenderEditableBox
去实现可编辑的输入框。我们可以通过RenderEditableBox
实现文本选择、文本光标的操作。class RenderEditableTextLine extends RenderEditableBox
-
自定义文本选择范围
-
…
-
-
自定义全局控制器
===基础控制,外部调用大部分都通过这个=== class QuillController extends ChangeNotifier ... //基础富文本,提供给外部使用。只需要在使用时定义一个controller即可使用Flutter Quill编辑器 //QuillController _controller = QuillController.basic(); factory QuillController.basic() return QuillController( document: Document(), selection: const TextSelection.collapsed(offset: 0), ); //获取选择的样式 Style getSelectionStyle() return document .collectStyle(selection.start, selection.end - selection.start) .mergeAll(toggledStyle); //清空当前富文本中的内容 void clear() replaceText(0, plainTextEditingValue.text.length - 1, '', const TextSelection.collapsed(offset: 0)); ...
-
定义基础规则(插入、删除、其他)
——因规则较多,此处我们只分析部分规则
保留该行文本的样式。在用户按下回车键时,或粘贴包含多行的文本时,触发该规则。
class PreserveBlockStyleOnInsertRule extends InsertRule const PreserveBlockStyleOnInsertRule(); Delta? applyRule(Delta document, int index, int? len, Object? data, Attribute? attribute) //此规则只对包含'\\n'的文本起作用 if (data is! String || !data.contains('\\n')) return null; final itr = DeltaIterator(document)..skip(index); // 继续查找下一个'\\n' final nextNewLine = _getNextNewLine(itr); final lineStyle = Style.fromJson(nextNewLine.item1?.attributes ?? <String, dynamic>); final blockStyle = lineStyle.getBlocksExceptHeader(); // 多行文本是否在一个区块中,如果不是,就忽略该文本。 if (blockStyle.isEmpty) return null; final resetStyle = <String, dynamic>; //如果这一行文本有文本样式,我们需要将样式保留到新的一行文本中 if (lineStyle.containsKey(Attribute.header.key)) resetStyle.addAll(Attribute.header.toJson()); // 检查每一行文本,确保在同一块中,使用了相同的样式 final lines = data.split('\\n'); final delta = Delta()..retain(index + (len ?? 0)); for (var i = 0; i < lines.length; i++) final line = lines[i]; if (line.isNotEmpty) delta.insert(line); if (i == 0) // 第一行完全继承于lineStyle delta.insert('\\n', lineStyle.toJson()); else if (i < lines.length - 1) final blockAttributes = blockStyle.isEmpty ? null : blockStyle.map<String, dynamic>((_, attribute) => MapEntry<String, dynamic>(attribute.key, attribute.value)); //在文本最后插入'\\n' delta.insert('\\n', blockAttributes); // 如果自定义了换行样式,则替换原始换行样式 if (resetStyle.isNotEmpty) delta ..retain(nextNewLine.item2!) ..retain((nextNewLine.item1!.data as String).indexOf('\\n')) ..retain(1, resetStyle); return delta;
-
总结
看到这里,是不是觉得还是有点不清晰,毕竟我们是分析了部分内容,对于拥有富文本的模块,我们可以总结成为下面这张图。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-V0OVxuVX-1669544651780)(https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/07b8cc1b46ed425ab53441ed23513f9d~tplv-k3u1fbpfcp-watermark.image?)]
解析JSON,渲染文本
看到这里,相信你已经知道富文本编辑器由哪些模块组成了。那么就让我们开始实现属于我们的富文本编辑器吧。
-
定义JSON配置文件
好的组件都是一点点迭代起来的,刚开始我们不需要定义太多的参数,后面一点一点迭代优化即可。在这里,我们定义一个基础的JSON文件。用于控制文本的大小和颜色。
[ "message": "Flutter Editor Taxze", //正文 "richTexts": //文本属性 "color": "#e60000", "textSize": 32 , "insert": "\\n" //判断该段落是否已经结束。 , "message": "富文本好像是个大坑", "richTexts": "color": "rgba(32, 54, 190, 1)", "textSize": 24 , "insert": "\\n" ]
-
编写颜色解析方法
Color stringToColor(String s) if (s.startsWith('rgba')) s = s.substring(5); //取rgba( 五个字符 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])); //返回Color值 else if (s.startsWith('#')) s = s.toUpperCase().replaceAll("#", ""); //将字符串转为大写,同时将#号去掉 if (s.length == 6) //判断是否为正确的颜色格式 s = "FF$s"Flutter从0到1实现高性能多功能的富文本编辑器(模块分析篇)