⚠️本文为稀土技术社区首发签约文章,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
的一起,不对其子控件施加任何约束,不影响子控件的布局。
作用图:
@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
是经过按键或按键组合激活的键绑定。
具体参阅: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,假如您觉得文章对您有价值,希望您能给我的文章点个❤️,有问题需求联络我的话:我在这里 ,也能够经过的新的私信功用联络到我。假如您觉得文章还差了那么点东西,也请经过重视催促我写出更好的文章——万一哪天我前进了呢?