Flutter从0到1实现高性能多功能的富文本编辑器

Posted bug樱樱

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Flutter从0到1实现高性能多功能的富文本编辑器相关的知识,希望对你有一定的参考价值。

通过阅读本文,您将了解到

  1. 了解富文本编辑器需要拥有的功能
  2. 知道编写富文本编辑器需要的代码模块
  3. 学会定义富文本配置JSON,并将其解析为富文本

前言:

经过前面两篇文章的基础知识铺垫,我们终于要进入到专栏的核心内容 — 富文本。富文本编辑器可以说是APP中最复杂,但使用场景又极广的组件之一。例如各大笔记APP的核心功能、闲鱼的商品发布功能、还有掘金APP的发布文章&发布沸点功能等,可以说是富文本编辑器让用户能以更简单更便携的方式记录内容。不过Flutter只有最基础的文本编辑组件TextField,在遇到复杂场景时就比较吃力了,例如图片的添加,有序段落…本文通过分析市场上的各大富文本编辑器的功能和Flutter优秀的富文本插件,从而来自定义自己的富文本编辑器,与大家一起探索文本的世界…

注:Flutter目前已经有许多优秀的开源富文本编辑器,例如:flutter_quill。为了更好的实现属于自己的`富文本编辑器,我们必须要先了解&学习这些优秀的开源项目。

对比分析各大APP的富文本编辑器

对比各大APP的富文本编辑器后,我们可以将富文本功能总结为这些部分:

基础功能扩展功能
文本斜体撤回←→
可改变文本大小图片📸
可改变文本字体格式视频🎬
可改变文本样式(颜色、粗细)代码块💻
文本下划线&删除线链接🔍
标题(h1,h2,h3)语音📢
文本缩进绘画块🎨
文本对齐自定义背景🖼

协议选择

如今有很多优秀的富文本编辑器,例如QuillwangEditorProsemirror。根据开源协议、可扩展性、生态等方面的对比考虑之后,本专栏选用Quill协议做为我们富文本编辑器的协议。

——图片来源: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();
    ​
      @override
      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;
      
    
    
  • 总结

    看到这里,是不是觉得还是有点不清晰,毕竟我们是分析了部分内容,对于拥有富文本的模块,我们可以总结成为下面这张图。

解析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";
        
        return Color(int.parse(s, radix: 16)); //返回Color值,radix:默认基数10进制,我们需要指定是16进制
      
      return const Color.fromRGBO(0, 0, 0, 0);
    
    
  • 编写JSON实体类

    class TextRichInfo 
      String? message;
      Map<String, dynamic>? richTexts;
      String? color;
      int? textSize;
      String? insert;
    ​
      TextRichInfo(
          this.message, this.richTexts, this.color, this.textSize, this.insert);
    ​
      TextRichInfo.fromJson(Map<String, dynamic> json) 
        message = json["message"];
        richTexts = json["richTexts"];
        color = richTexts!['color'];
        textSize = richTexts!['textSize'];
        insert = json['insert'] ?? null;
      
    
    
  • 解析JSON方法

    List<TextSpan> _textSpanList() 
      List<TextSpan> spanList = [];
      //将网络获取到的Json格式字符串解析
      var textJsonMap = json.decode(widget.richTextJsonConfig);
      for (var i in textJsonMap) 
        var data = TextRichInfo.fromJson(i);
        String message = data.message!;
        int textSize = data.textSize!;
        Color richTextColor = stringToColor(data.color!);
        spanList.add(TextSpan(
          text: message,
          style: TextStyle(color: richTextColor, fontSize: textSize.toDouble()),
        ));
        //判断该段落是否结束,如果结束添加回车。
        String? insert = data.insert;
        if (insert != null) 
          spanList.add(const TextSpan(
            text: "\\n",
          ));
        
      
      return spanList;
    
    
  • 使用

    //富文本渲染build
    @override
    Widget build(BuildContext context) 
      return Text.rich(TextSpan(children: _textSpanList()));
    
    //使用
    @override
      Widget build(BuildContext context) 
        return Scaffold(
          body: Center(
            child: RichTextEditor(
                richTextJsonConfig:
                    r'["message": "Flutter Editor Taxze","richTexts": "color": "#e60000","textSize": 32,"insert": "\\n","message": "富文本好像是个大坑","richTexts": "color": "rgba(32, 54, 190, 1)","textSize": 24]'),
          ),
        );
    
    

    这样一番操作下来后,我们就实现了将JSON数据解析为富文本的功能。但需要注意的一点,目前是模拟从服务端获取数据,所以需要确保在上传数据的时候,colortextSize都是有一个默认值的。效果图如下:

尾述

在这篇文章中,我们分析了一个富文本编辑器需要有哪些功能,也分析了优秀的富文本编辑器Flutter Quill,知道了实现富文本编辑器需要有哪些模块。最后我们对自定义富文本编辑器做了一个开头,简单实现了对富文本JSON配置数据的解析。在下一篇文章中,会详细分析自定义富文本编辑器。希望这篇文章能对你有所帮助,有问题欢迎在评论区留言讨论~

参考

flutter_quill

作者:编程的平行世界
链接:https://juejin.cn/post/7154151529572728868
更多Android学习笔记+视频资料可点击下方卡片获取~

以上是关于Flutter从0到1实现高性能多功能的富文本编辑器的主要内容,如果未能解决你的问题,请参考以下文章

Flutter从0到1实现高性能多功能的富文本编辑器(模块分析篇)

在 Flutter 中具有格式化功能的富文本编辑器应用程序

Android实现EditText的富文本编辑

Android实现EditText的富文本编辑

Android实现EditText的富文本编辑

TinyMCE 一款非常不错的富文本编辑器