Flutter从0到1实现高性能、多功能的富文本编辑器(基础实战篇)

3,687 阅读10分钟

⚠️本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

前言

在上一章中,我们分析了一个富文本编辑器需要有哪些模块组成。在本文中,让我们从零开始,去实现自定义的富文本编辑器。

注:本文篇幅较长,从失败的方案开始分析再到成功实现自定义富文本编辑器,真正的从0到1。建议收藏!

— 完整代码太多, 文章只分析核心代码,需要源码请到 代码仓库

错误示范

遭一蹶者得一便,经一事者长一智。——宋·无名氏《五代汉史平话·汉史》

在刚开始实现富文本时,为了更快速的实现富文本的功能,我利用了TextField这个组件,但写着写着发现TextField有着很大的局限性。不过错误示范也给我带来了一些启发,那么现在就让我和大家一起去探索富文本编辑器的世界吧。

最后效果图:

1.gif

定义文本格式

作为基础的富文本编辑器实现,我们需要专注于简单且重要的部分,所以目前只需定义标题、文本对齐、文本粗体、文本斜体、下划线、文本删除线、文本缩进符等富文本基础功能。

定义文本颜色:
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的同时,不对其子控件施加任何约束,不影响子控件的布局。

效果图:

2.png

  @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

在之前的基础文本知识篇中,我们知道RichTexttext属性接收一个InlineSpan类型的对象(TextSpanWidgetSpanInlineSpan的子类),而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;
    }
  }
}

构建_EditableShortcuts是通过按键或按键组合激活的键绑定。

具体参考:docs.flutter.dev/development…

@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小郭

用flutter实现富文本编辑器

flutter_quill

关于我

Hello,我是Taxze,如果您觉得文章对您有价值,希望您能给我的文章点个❤️,有问题需要联系我的话:我在这里 ,也可以通过掘金的新的私信功能联系到我。如果您觉得文章还差了那么点东西,也请通过关注督促我写出更好的文章——万一哪天我进步了呢?😝