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

前言

在上一章中,咱们剖析了一个富文本修改器需求有哪些模块组成。在本文中,让咱们从零开端,去完成自界说的富文本修改器。

注:本文篇幅较长,从失利的计划开端剖析再到成功完成自界说富文本修改器,真实的从0到1。建议保藏!

— 完好代码太多, 文章只剖析核心代码,需求源码请到 代码库房

过错演示

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

在刚开端完成富文本时,为了更快速的完成富文本的功用,我利用了TextField这个组件,但写着写着发现TextField有着很大的局限性。不过过错演示也给我带来了一些启发,那么现在就让我和咱们一起去探索富文本修改器的国际吧。

最后作用图:

界说文本格式

作为根底的富文本修改器完成,咱们需求专心于简略且重要的部分,所以现在只需界说标题、文本对齐、文本粗体、文本斜体、下划线、文本删去线、文本缩进符等富文本根底功用。

界说文本色彩:
class RichTextColor {
 //界说默许色彩
 static const defaultTextColor = Color(0xFF000000);
​
 static const c_FF0000 = Color(0xFFFF0000);
  ...
  
 ///用户自界说色彩解析 
 ///=== 如需办法剖析,请参阅https:///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的一起,不对其子控件施加任何约束,不影响子控件的布局。

作用图:

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

 @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,假如您觉得文章对您有价值,希望您能给我的文章点个❤️,有问题需求联络我的话:我在这里 ,也能够经过的新的私信功用联络到我。假如您觉得文章还差了那么点东西,也请经过重视催促我写出更好的文章——万一哪天我前进了呢?