前言
如果有一天,我跑路了,肯定是因为更新不动这 2
个组件。
三方 | 官方 |
---|---|
ExtendedText | Text |
ExtendedTextField | TextField |
起源
支持图文混排和自定义溢出效果的富文本
思绪被一下子拉回了四年前,当时的 Text
不支持图文混合富文本(那时候还没有 WidgetSpan
),不支持自定义文本溢出的效果(滑鸡,现在都还没有支持),不支持选择。有需求就有动力,很快就做出了第一版的 ExtendedText。
- Flutter RichText支持图片显示和自定义图片效果 - 掘金 (juejin.cn)
- Flutter RichText支持自定义文本溢出效果 - 掘金 (juejin.cn)
- Flutter RichText支持自定义文字背景 - 掘金 (juejin.cn)
- Flutter RichText支持特殊文字效果 - 掘金 (juejin.cn)
支持图文混合的输入框
在搞定了文本之后,根据相同的原理,又增加输入框的图文混合功能。
支持选择的富文本
再根据输入框的原理,给富文本增加了选择功能。
自定义输入框选择菜单和选择器
当时还没有 TextSelectionControls
,没法自定义菜单和选择器。
自定义文本溢出位置
增加指定文本溢出(省略号)的位置
老架构
功能对比
Flutter
目前稳定版本 3.10.0
以及老架构下面的功能对比:
功能 | ExtendedText | Text | 说明 |
---|---|---|---|
富文本图文混合 | ✓ | ✓ | 使用 WidgetSpan |
选择 | ✓ | ✓ | ExtendedText 使用和输入框一套的逻辑;Text 使用最新的 SelectionArea |
自定义文本溢出效果 | ✓ | x | ExtendedText 支持文本溢出为自定义的 Widget ,并且支持溢出的位置(前,中,后)。 |
复制真实文本 | ✓ | x | ExtendedText 支持复制出文本的真实值,Text 只能复制出 WidgetSpan 的占位值 (\uFFFC ) |
------------------分割线-----------------------
功能 | ExtendedTextField | TextField | 说明 |
---|---|---|---|
富文本图文混合 | ✓ | x | ExtendedTextField 支持图文混合,由 SpecialTextSpanBuilder 生成。 TextField 由 TextEditingController 的 buildTextSpan 生成,但是 Selection 会出现问题。 |
选择 WidgetSpan | ✓ | ✓ | 支持选择 WidgetSpan |
复制真实文本 | ✓ | x | ExtendedTextField 支持复制出文本的真实值,TextField 只能复制出 WidgetSpan 的占位值 (\uFFFC ) |
extended_text_library
├─ lib
│ ├─ extended_text_library.dart
│ └─ src
│ ├─ background_text_span.dart
│ ├─ extended_text_typedef.dart
│ ├─ extended_text_utils.dart
│ ├─ extended_widget_span.dart
│ ├─ extension.dart
│ ├─ image_span.dart
│ ├─ painting_image_span.dart
│ ├─ special_inline_span_base.dart
│ ├─ special_text_span.dart
│ ├─ special_text_span_builder.dart
│ └─ text_painter_helper.dart
│ ├─ render_object
│ │ ├─ extended_text_render_box.dart
│ │ └─ extended_text_selection_render_object.dart
│ ├─ selection
│ │ ├─ extended_text_selection.dart
│ │ ├─ extended_text_selection_overlay.dart
│ │ ├─ painter.dart
│ │ ├─ scribble_focusable.dart
│ │ └─ typedef.dart
└─ pubspec.yaml
special_inline_span_base.dart
, 主要定义了actualText
,deleteAll
,textRange
。
其中 actualText
表示文本的真实内容,比如 [1]
代表表情图片,表情图片是展示在前端的,[1]
为后端真实内容。
deleteAll
代表是否把这个特殊的文本当作一个整体,比如输入框里面删除表情图片,会删除掉 [1]
而不是 ]
;或者比如真实文本$FlutterCandies$
,展示内容 FlutterCandies
, 在选择的时候,是否是直接选中整体(true
),还是说能选中 FlutterCandies
的一部分(false
)。
textRange
代表在真实内容中的具体位置。
render_object
文件夹下面extended_text_render_box.dart
主要支持图文混合,extended_text_selection_render_object.dart
主要是绘制Selection
相关的代码。
ExtendedText 和 ExtendedTextField 的最终绘制代码都继承了这 2 个文件,让它们都能支持图文混合以及 Selection
。
selection
文件夹下面是对Selection
支持的共享代码。
extended_text
├─ lib
│ ├─ extended_text.dart
│ └─ src
│ ├─ extended_render_paragraph.dart
│ ├─ extended_rich_text.dart
│ ├─ extended_text.dart
│ ├─ extended_text_typedef.dart
│ ├─ selection
│ │ ├─ extended_text_selection.dart
│ │ └─ extended_text_selection_pointer_handler.dart
│ ├─ text_overflow_render_mixin.dart
│ └─ text_overflow_widget.dart
├─ pubspec.yaml
对应类与文件:
三方 | 位置 | 官方 | 位置 |
---|---|---|---|
ExtendedText | extended_text.dart | Text | text.dart |
ExtendedRichText | extended_rich_text.dart | RichText | basic.dart |
ExtendedRenderParagraph | extended_render_paragraph.dart | RenderParagraph | paragraph.dart |
ExtendedTextSelection | extended_text_selection.dart | EditableText | editable_text.dart |
extended_text_field
├─ lib
│ ├─ extended_text_field.dart
│ └─ src
│ ├─ extended_editable_text.dart
│ ├─ extended_render_editable.dart
│ ├─ extended_text_field.dart
│ └─ keyboard
│ ├─ binding.dart
│ └─ focus_node.dart
└─ pubspec.yaml
对应类与文件:
三方 | 位置 | 官方 | 位置 |
---|---|---|---|
ExtendedTextField | extended_text_field.dart | TextField | text.dart |
ExtendedEditableText | extended_editable_text.dart | EditableText | editable_text.dart |
ExtendedRenderEditable | extended_render_editable.dart | RenderEditable | editable.dart |
优点缺点
优点
由 extended_text_library 共同处理了图文混合和 Selection
的逻辑,一套代码,2
个库能共享。在项目前期,确实减少不少工作量。
缺点
随着官方的版本迭代,Text
和 TextField
的代码不断更新,特别是 SelectionArea
的出现,很难做到在旧的架构下面再继续同步官方的版本迭代。
RenderEditable
和 EditableTextState
被到处传的情况,现在依然还是这样。对于三方开发者来说,我们只能不断的复制更多的源码到自己库里面。
新架构
其实很早就有重构计划,我一直在等待,Text
和 TextField
稳定,但是现在依然还是看到有大量的废弃的 api
在代码里面,不知道什么时候才能删掉,稳定下来。
但是也是到了不得不的进行重构的时候了,重构先要保证之前的功能,然后是以后能够更容易的维护。
重构之后,ExtendedText
会跟随 Text
代码迭代,ExtendedTextField
会跟随 TextField
代码迭代,绘制部分的代码不再共用。
extended_text
├─ lib
│ ├─ extended_text.dart
│ └─ src
│ ├─ extended
│ │ ├─ rendering
│ │ │ └─ paragraph.dart
│ │ ├─ selection_mixin.dart
│ │ ├─ text_overflow_mixin.dart
│ │ └─ widgets
│ │ ├─ rich_text.dart
│ │ ├─ text.dart
│ │ └─ text_overflow_widget.dart
│ └─ official
│ ├─ rendering
│ │ └─ paragraph.dart
│ └─ widgets
│ ├─ rich_text.dart
│ └─ text.dart
├─ pubspec.yaml
-
official
文件夹中为从源码中复制出来的代码,要求里面的文件尽量不要进行改动,方便后面维护的时候直接跟原文件进行对比merge
。 -
extended
文件夹中相同位置,相同名字的文件,是我们的扩展实现。text_overflow_mixin.dart
为自定义文本溢出的实现;selection_mixin.dart
处理Selection
的实现,比如复制真实文本,deleteAll
的处理。
后面,只需要针对 official
的文件跟官方最新的代码进行对比,将对应的代码 merge
过来即可。
extended_text_field
├─ lib
│ ├─ extended_text_field.dart
│ └─ src
│ ├─ extended
│ │ ├─ cupertino
│ │ │ └─ spell_check_suggestions_toolbar.dart
│ │ ├─ material
│ │ │ └─ spell_check_suggestions_toolbar.dart
│ │ │ └─ selectable_text.dart
│ │ ├─ rendering
│ │ │ └─ editable.dart
│ │ └─ widgets
│ │ ├─ editable_text.dart
│ │ ├─ spell_check.dart
│ │ ├─ text_field.dart
│ │ └─ text_selection.dart
│ ├─ keyboard
│ │ ├─ binding.dart
│ │ └─ focus_node.dart
│ └─ official
│ ├─ material
│ │ └─ selectable_text.dart
│ ├─ rendering
│ │ └─ editable.dart
│ └─ widgets
│ ├─ editable_text.dart
│ ├─ spell_check.dart
│ ├─ text_field.dart
│ └─ text_selection.dart
└─ pubspec.yaml
-
official
文件夹中为从源码中复制出来的代码,要求里面的文件尽量不要进行改动,方便后面维护的时候直接跟原文件进行对比merge
。由于RenderEditable
和EditableTextState
被到处传的情况,我们需要复制更多的源码到自己的项目中。 -
extended
文件夹中相同位置,相同名字的文件,是我们的扩展实现。
后面,只需要针对 official
的文件跟官方最新的代码进行对比,将对应的代码 merge
过来即可。
extended_text_library
├─ lib
│ ├─ extended_text_library.dart
│ └─ src
│ ├─ background_text_span.dart
│ ├─ extended_text_utils.dart
│ ├─ extended_text_typedef.dart
│ ├─ extended_widget_span.dart
│ ├─ image_span.dart
│ ├─ painting_image_span.dart
│ ├─ special_inline_span_base.dart
│ ├─ special_text_span.dart
│ ├─ special_text_span_builder.dart
│ └─ text_painter_helper.dart
└─ pubspec.yaml
共用的绘制代码已移除,增加了 extended_text_utils.dart
处理 Selection
的相关相同处理逻辑。
功能对比
重构之后,新架构支持了 Flutter ``3.10.0
版本的全部功能,代码也更加清楚,针对官方的扩展的功能也能一目了然。
基于 Flutter
目前稳定版本 3.10.0
新架构下面的功能对比:
功能 | ExtendedText | Text | 说明 |
---|---|---|---|
富文本图文混合 | ✓ | ✓ | 使用 WidgetSpan |
选择 | ✓ | ✓ | 使用 SelectionArea |
自定义文本溢出效果 | ✓ | x | ExtendedText 支持文本溢出为自定义的 Widget ,并且支持溢出的位置(前,中,后)。 |
复制真实文本 | ✓ | x | ExtendedText 支持复制出文本的真实值,Text 只能复制出 WidgetSpan 的占位值 (\uFFFC ) |
------------------分割线-----------------------
功能 | ExtendedTextField | TextField | 说明 |
---|---|---|---|
富文本图文混合 | ✓ | x | ExtendedTextField 支持图文混合,由 SpecialTextSpanBuilder 生成。 TextField 由 TextEditingController 的 buildTextSpan 生成,但是 Selection 会出现问题。 |
选择 WidgetSpan | ✓ | ✓ | 支持选择 WidgetSpan |
复制真实文本 | ✓ | x | ExtendedTextField 支持复制出文本的真实值,TextField 只能复制出 WidgetSpan 的占位值 (\uFFFC ) |
注意
菜单(ContextMenu
)和光标选择器(Handles
)
现在这2个东西是分开实现了。
ExtendedText
对于 ExtendedText
来说,菜单(ContextMenu
)由 SelectionArea.contextMenuBuilder
实现,而光标选择器(Handles
) 还是由 SelectionArea.selectionControls
实现。
SelectionArea.contextMenuBuilder
例子
class CommonSelectionArea extends StatelessWidget {
const CommonSelectionArea({
super.key,
required this.child,
this.joinZeroWidthSpace = false,
});
final Widget child;
final bool joinZeroWidthSpace;
@override
Widget build(BuildContext context) {
SelectedContent? _selectedContent;
return SelectionArea(
selectionControls: MyTextSelectionControls(),
contextMenuBuilder:
(BuildContext context, SelectableRegionState selectableRegionState) {
return AdaptiveTextSelectionToolbar.buttonItems(
buttonItems: <ContextMenuButtonItem>[
ContextMenuButtonItem(
onPressed: () {
// TODO(zmtzawqlp): how to get Selectable
// and _clearSelection is not public
// https://github.com/flutter/flutter/issues/126980
// onCopy: () {
// _copy();
// // In Android copy should clear the selection.
// switch (defaultTargetPlatform) {
// case TargetPlatform.android:
// case TargetPlatform.fuchsia:
// _clearSelection();
// case TargetPlatform.iOS:
// hideToolbar(false);
// case TargetPlatform.linux:
// case TargetPlatform.macOS:
// case TargetPlatform.windows:
// hideToolbar();
// }
// },
// if (_selectedContent != null) {
// String content = _selectedContent!.plainText;
// if (joinZeroWidthSpace) {
// content = content.replaceAll(zeroWidthSpace, '');
// }
// Clipboard.setData(ClipboardData(text: content));
// selectableRegionState.hideToolbar(true);
// selectableRegionState._clearSelection();
// }
selectableRegionState
.copySelection(SelectionChangedCause.toolbar);
// remove zeroWidthSpace
if (joinZeroWidthSpace) {
Clipboard.getData('text/plain').then((ClipboardData? value) {
if (value != null) {
// remove zeroWidthSpace
final String? plainText =
value.text?.replaceAll(ExtendedTextLibraryUtils.zeroWidthSpace, '');
if (plainText != null) {
Clipboard.setData(ClipboardData(text: plainText));
}
}
});
}
},
type: ContextMenuButtonType.copy,
),
ContextMenuButtonItem(
onPressed: () {
selectableRegionState.selectAll(SelectionChangedCause.toolbar);
},
type: ContextMenuButtonType.selectAll,
),
ContextMenuButtonItem(
onPressed: () {
launchUrl(Uri.parse(
'mailto:zmtzawqlp@live.com?subject=extended_text_share&body=${_selectedContent?.plainText}'));
selectableRegionState.hideToolbar();
},
type: ContextMenuButtonType.custom,
label: 'like',
),
],
anchors: selectableRegionState.contextMenuAnchors,
);
// return AdaptiveTextSelectionToolbar.selectableRegion(
// selectableRegionState: selectableRegionState,
// );
},
onSelectionChanged: (SelectedContent? value) {
print(value?.plainText);
_selectedContent = value;
},
child: child,
);
}
}
SelectionArea.selectionControls
例子
这里要特别注意,必须 with
TextSelectionHandleControls
,不然源码还是会走 TextSelectionControls.buildToolbar
去创建菜单(ContextMenu
)。
class MyTextSelectionControls extends TextSelectionControls
with TextSelectionHandleControls {
MyTextSelectionControls({this.joinZeroWidthSpace = false});
final bool joinZeroWidthSpace;
/// Returns the size of the Material handle.
@override
Size getHandleSize(double textLineHeight) =>
const Size(_kHandleSize, _kHandleSize);
/// Builder for material-style text selection handles.
@override
Widget buildHandle(
BuildContext context, TextSelectionHandleType type, double textLineHeight,
[VoidCallback? onTap, double? startGlyphHeight, double? endGlyphHeight]) {
final Widget handle = SizedBox(
width: _kHandleSize,
height: _kHandleSize,
child: Image.asset(
'assets/40.png',
),
);
// [handle] is a circle, with a rectangle in the top left quadrant of that
// circle (an onion pointing to 10:30). We rotate [handle] to point
// straight up or up-right depending on the handle type.
switch (type) {
case TextSelectionHandleType.left: // points up-right
return Transform.rotate(
angle: math.pi / 4.0,
child: handle,
);
case TextSelectionHandleType.right: // points up-left
return Transform.rotate(
angle: -math.pi / 4.0,
child: handle,
);
case TextSelectionHandleType.collapsed: // points up
return handle;
}
}
/// Gets anchor for material-style text selection handles.
///
/// See [TextSelectionControls.getHandleAnchor].
@override
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight,
[double? startGlyphHeight, double? endGlyphHeight]) {
switch (type) {
case TextSelectionHandleType.left:
return const Offset(_kHandleSize, 0);
case TextSelectionHandleType.right:
return Offset.zero;
default:
return const Offset(_kHandleSize / 2, -4);
}
}
}
ExtendedTextField
对于 ExtendedTextField
来说,菜单(ContextMenu
)由 ExtendedTextField.extendedContextMenuBuilder
实现,而光标选择器(Handles
) 还是由 ExtendedTextField.selectionControls
实现。
ExtendedTextField.extendedContextMenuBuilder
例子
static Widget defaultContextMenuBuilder(
BuildContext context, ExtendedEditableTextState editableTextState) {
return AdaptiveTextSelectionToolbar.buttonItems(
buttonItems: <ContextMenuButtonItem>[
...editableTextState.contextMenuButtonItems,
ContextMenuButtonItem(
onPressed: () {
launchUrl(
Uri.parse(
'mailto:zmtzawqlp@live.com?subject=extended_text_share&body=${editableTextState.textEditingValue.text}',
),
);
editableTextState.hideToolbar(true);
editableTextState.textEditingValue
.copyWith(selection: const TextSelection.collapsed(offset: 0));
},
type: ContextMenuButtonType.custom,
label: 'like',
),
],
anchors: editableTextState.contextMenuAnchors,
);
// return AdaptiveTextSelectionToolbar.editableText(
// editableTextState: editableTextState,
// );
}
ExtendedTextField.selectionControls
例子
这里要特别注意,必须 with
TextSelectionHandleControls
,不然源码还是会走 TextSelectionControls.buildToolbar
去创建菜单(ContextMenu
)。
const double _kHandleSize = 22.0;
/// Android Material styled text selection controls.
class MyTextSelectionControls extends TextSelectionControls
with TextSelectionHandleControls {
/// Returns the size of the Material handle.
@override
Size getHandleSize(double textLineHeight) =>
const Size(_kHandleSize, _kHandleSize);
/// Builder for material-style text selection handles.
@override
Widget buildHandle(
BuildContext context, TextSelectionHandleType type, double textLineHeight,
[VoidCallback? onTap, double? startGlyphHeight, double? endGlyphHeight]) {
final Widget handle = SizedBox(
width: _kHandleSize,
height: _kHandleSize,
child: Image.asset(
'assets/40.png',
),
);
// [handle] is a circle, with a rectangle in the top left quadrant of that
// circle (an onion pointing to 10:30). We rotate [handle] to point
// straight up or up-right depending on the handle type.
switch (type) {
case TextSelectionHandleType.left: // points up-right
return Transform.rotate(
angle: math.pi / 4.0,
child: handle,
);
case TextSelectionHandleType.right: // points up-left
return Transform.rotate(
angle: -math.pi / 4.0,
child: handle,
);
case TextSelectionHandleType.collapsed: // points up
return handle;
}
}
/// Gets anchor for material-style text selection handles.
///
/// See [TextSelectionControls.getHandleAnchor].
@override
Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight,
[double? startGlyphHeight, double? endGlyphHeight]) {
switch (type) {
case TextSelectionHandleType.left:
return const Offset(_kHandleSize, 0);
case TextSelectionHandleType.right:
return Offset.zero;
default:
return const Offset(_kHandleSize / 2, -4);
}
}
@override
bool canSelectAll(TextSelectionDelegate delegate) {
// Android allows SelectAll when selection is not collapsed, unless
// everything has already been selected.
final TextEditingValue value = delegate.textEditingValue;
return delegate.selectAllEnabled &&
value.text.isNotEmpty &&
!(value.selection.start == 0 &&
value.selection.end == value.text.length);
}
}
已知问题
deleteAll
deleteAll
这个参数源于,展示在页面上面的文字/图形,并不是它们的真实文本内容。当它等于 false
的时候,我们很难将屏幕上面选中的内容,跟实际真实想要的内容有所关联起来。这个问题一直都存在,暂时没有想到合理的解决方案。
- 对于 ExtendedText 你可以通过重载
SpecialInlineSpanBase
的getSelectedContent
的方法,去重载被选择的内容。默认deleteAll
为true
的时候返回的是actualText
,否则返回的是屏幕上显示的内容showText
。
/// showText is the text on screen
String getSelectedContent(String showText) {
if (deleteAll) {
return actualText;
}
return showText;
}
例子:
class DollarText extends SpecialText {
DollarText(TextStyle? textStyle, SpecialTextGestureTapCallback? onTap,
{this.start})
: super(flag, flag, textStyle, onTap: onTap);
static const String flag = '\$';
final int? start;
@override
InlineSpan finishText() {
final String text = getContent();
return _SpecialTextSpan(
text: text,
actualText: toString(),
start: start!,
deleteAll: false,
style: textStyle?.copyWith(color: Colors.orange, fontSize: 16),
mouseCursor: SystemMouseCursors.text,
recognizer: TapGestureRecognizer()
..onTap = () {
if (onTap != null) {
onTap!(toString());
}
});
}
}
class _SpecialTextSpan extends SpecialTextSpan {
_SpecialTextSpan({
super.style,
required super.text,
super.actualText,
super.start = 0,
super.deleteAll = true,
super.recognizer,
super.children,
super.semanticsLabel,
super.mouseCursor,
super.onEnter,
super.onExit,
});
@override
String getSelectedContent(String showText) {
return '${DollarText.flag}$showText${DollarText.flag}';
}
}
效果如下:
- 对于 ExtendedTextField 来说,这个属性等于
false
的时候就更难处理了。因为输入框有编辑功能,用户可能删除,粘贴文本。所以在 ExtendedTextField 上面,不建议使用deleteAll
为flase
的特殊文本。
后续可能考虑移除 deleteAll
属性,特殊文本默认就是当作一个整体。
SelectionArea 的问题
选择算法的问题
这是官方的 SelectionArea
的问题,
SelectionArea is not working well · Issue #126817 · flutter/flutter (github.com),源于 SelectionArea
要支持跨文本可以选择。
多个文本,会根据 0xFFFC
占位符(PlaceholderSpan
)进行分割并且根据自身的位置,确定前后顺序。但在,可能因为文本的字体大小,导致计算出错。比如下面 FlutterCandies
设置了更小的字体,结果被计算到了 if you want to ...
文本的后面。
SelectionArea(
child: Text.rich(TextSpan(children: <InlineSpan>[
TextSpan(
text:
'[love]Extended text help you to build rich text quickly. any special text you will have with extended text. '
'It\'s my pleasure to invite you to join ',
),
WidgetSpan(child: SizedBox.shrink()),
TextSpan(
text: 'FlutterCandies',
style: TextStyle(fontSize: 10),
),
WidgetSpan(child: SizedBox.shrink()),
TextSpan(
text: 'if you want to improve flutter .[love]'
'if you meet any problem, please let me know @zmtzawqlp .[sun_glasses]',
),
])),
)
临时解决方案:
- 调整字体,避免这种场景,狗头, 坐等修复。
p4
的 issue
才 5000
多个,狗头。
后续继续关注,实际上应该要有一个 parent
的概念才好区分。
- 使用
ExtendedSelectableText
,它和输入框是一套逻辑,缺点是没法指定文本溢出效果。 - 实在是不想改源码了,而且复制了的话,官方的
Text
就没法加入了。但是也不是不可以。
选中一个词时候的异常
_SelectableFragment _handleSelectWord assert failed · Issue #127076 · flutter/flutter (github.com)
这个问题我在 extended_text
做了简单的修复。具体最终方案还是看看官方回复再说。
Fix _handleSelectWord error · fluttercandies/extended_text@e5e7623 (github.com)
结语
有一说一,糖果的组件,一般都能在大版本更新之后,及时地更新适配。三方开发者,不是全职做这些东西,不可能把全部的精力放在这个上面。Flutter
的 master
和 beta
分支,代码更新很快,而且还会有回滚的情况,想让开发者提前去适配 master
和 beta
分支是太不现实的。
哦,对了,东西弄出来了,老板们记得打钱哈!
爱 Flutter
,爱糖果
,欢迎加入Flutter Candies,一起生产可爱的Flutter小糖果QQ群:181398081
最最后放上 Flutter Candies 全家桶,真香。