flutter聊天界面-TextField输入框实现@功能等匹配正则表达式展示高亮功能
一、简要描述
描述:
最近有位朋友讨论的时候,提到了输入框的高亮展示。在flutter TextField中需要插入特殊样式的标签,比如:“请 @张三 回答一下”,这一串字符在TextField中输入,当输入@时 弹出好友列表选择,然后将 “@张三”高亮显示在TextField中。
效果图如下
视频效果
昨天整理的文中,简单实用TextEditingController的buildTextSpan时候,直接修改代码
List<InlineSpan> textSpans = RichTextHelper.getRichText(value.text);
if (composingRegionOutOfRange) {
return TextSpan(style: style, children: textSpans);
}
会出现光标输入的问题,这里修改了一下,完善了一下。
大家可以使用rich_text_controller
来实现,查看rich_text_controller源码可以看到,RichTextController继承TextEditingController,重写了buildTextSpan。经过我在iPhone上测试,当输入@汉字的时候,对中文兼容会有问题,这里做一下修改完善修改。
二、TextEditingController的buildTextSpan方法
在TextEditingController中buildTextSpan方法中,我们可以看到,该方法中的代码
composingRegionOutOfRange:仅有输入完成的字
在最后一部分的代码中含有未输入完成的字
final TextStyle composingStyle = style?.merge(const TextStyle(decoration: TextDecoration.underline))
?? const TextStyle(decoration: TextDecoration.underline);
return TextSpan(
style: style,
children: <TextSpan>[
TextSpan(text: value.composing.textBefore(value.text)),
TextSpan(
style: composingStyle,
text: value.composing.textInside(value.text),
),
TextSpan(text: value.composing.textAfter(value.text)),
],
);
-
composingStyle未输入完成的字的样式,可以自己做下修改。
-
value.composing.textBefore:当前输入前面的字。
-
value.composing.textAfter:当前输入后面的字。
当输入过程中,我们将value.composing.textBefore,value.composing.textAfter匹配高亮即可
代码如下
value.composing.textBefore
TextSpan(style: style, children: buildRegExpSpan(context: context, text: value.composing.textBefore(value.text))),
value.composing.textAfter
TextSpan(style: style, children: buildRegExpSpan(context: context, text: value.composing.textAfter(value.text))),
匹配正则表达式
List<TextSpan> buildRegExpSpan(
{required BuildContext context,
TextStyle? style,
required String? text}) {
List<TextSpan> children = [];
if (!(text != null && text.isNotEmpty)) {
return children;
}
final matches = <String>{};
List<Map<String, List<int>>> matchIndex = [];
// Validating with REGEX
RegExp? allRegex;
allRegex = patternMatchMap != null
? RegExp(patternMatchMap?.keys.map((e) => e.pattern).join('|') ?? "",
caseSensitive: regExpCaseSensitive,
dotAll: regExpDotAll,
multiLine: regExpMultiLine,
unicode: regExpUnicode)
: null;
// Validating with Strings
RegExp? stringRegex;
stringRegex = stringMatchMap != null
? RegExp(r'\b' + stringMatchMap!.keys.join('|').toString() + r'+\$',
caseSensitive: regExpCaseSensitive,
dotAll: regExpDotAll,
multiLine: regExpMultiLine,
unicode: regExpUnicode)
: null;
text.splitMapJoin(
stringMatchMap == null ? allRegex! : stringRegex!,
onNonMatch: (String span) {
if (stringMatchMap != null &&
children.isNotEmpty &&
stringMatchMap!.keys.contains("${children.last.text}$span")) {
final String? ks =
stringMatchMap!["${children.last.text}$span"] != null
? stringMatchMap?.entries.lastWhere((element) {
return element.key
.allMatches("${children.last.text}$span")
.isNotEmpty;
}).key
: '';
children.add(TextSpan(text: span, style: stringMatchMap![ks!]));
return span.toString();
} else {
children.add(TextSpan(text: span, style: style));
return span.toString();
}
},
onMatch: (Match m) {
matches.add(m[0]!);
final RegExp? k = patternMatchMap?.entries.firstWhere((element) {
return element.key.allMatches(m[0]!).isNotEmpty;
}).key;
final String? ks = stringMatchMap?[m[0]] != null
? stringMatchMap?.entries.firstWhere((element) {
return element.key.allMatches(m[0]!).isNotEmpty;
}).key
: '';
if (deleteOnBack!) {
if ((isBack(text!, _lastValue) && m.end == selection.baseOffset)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
children.removeWhere((element) => element.text! == text);
text = text!.replaceRange(m.start, m.end, "");
selection = selection.copyWith(
baseOffset: m.end - (m.end - m.start),
extentOffset: m.end - (m.end - m.start),
);
});
} else {
children.add(
TextSpan(
text: m[0],
style: stringMatchMap == null
? patternMatchMap![k]
: stringMatchMap![ks],
),
);
}
} else {
children.add(
TextSpan(
text: m[0],
style: stringMatchMap == null
? patternMatchMap![k]
: stringMatchMap![ks],
),
);
}
final resultMatchIndex = matchValueIndex(m);
if (resultMatchIndex != null && onMatchIndex != null) {
matchIndex.add(resultMatchIndex);
onMatchIndex!(matchIndex);
}
return (onMatch(List<String>.unmodifiable(matches)) ?? '');
},
);
return children;
}
这里使用的是rich_text_controller中的代码,做了相应的修改,输入@张三正则表达式正常高亮显示了。
整个text_field_controller代码如下
import 'package:flutter/material.dart';
class TextFieldController extends TextEditingController {
final Map<RegExp, TextStyle>? patternMatchMap;
final Map<String, TextStyle>? stringMatchMap;
final Function(List<String> match) onMatch;
final Function(List<Map<String, List<int>>>)? onMatchIndex;
final bool? deleteOnBack;
String _lastValue = "";
/// controls the caseSensitive property of the full [RegExp] used to pattern match
final bool regExpCaseSensitive;
/// controls the dotAll property of the full [RegExp] used to pattern match
final bool regExpDotAll;
/// controls the multiLine property of the full [RegExp] used to pattern match
final bool regExpMultiLine;
/// controls the unicode property of the full [RegExp] used to pattern match
final bool regExpUnicode;
bool isBack(String current, String last) {
return current.length < last.length;
}
TextFieldController(
{String? text,
this.patternMatchMap,
this.stringMatchMap,
required this.onMatch,
this.onMatchIndex,
this.deleteOnBack = false,
this.regExpCaseSensitive = true,
this.regExpDotAll = false,
this.regExpMultiLine = false,
this.regExpUnicode = false})
: assert((patternMatchMap != null && stringMatchMap == null) ||
(patternMatchMap == null && stringMatchMap != null)),
super(text: text);
/// Setting this will notify all the listeners of this [TextEditingController]
/// that they need to update (it calls [notifyListeners]).
set text(String newText) {
value = value.copyWith(
text: newText,
selection: const TextSelection.collapsed(offset: -1),
composing: TextRange.empty,
);
}
/// Builds [TextSpan] from current editing value.
TextSpan buildTextSpan(
{required BuildContext context,
TextStyle? style,
required bool withComposing}) {
assert(!value.composing.isValid || !withComposing || value.isComposingRangeValid);
// If the composing range is out of range for the current text, ignore it to
// preserve the tree integrity, otherwise in release mode a RangeError will
// be thrown and this EditableText will be built with a broken subtree.
final bool composingRegionOutOfRange = !value.isComposingRangeValid || !withComposing;
if (composingRegionOutOfRange) {
List<TextSpan> children = [];
final matches = <String>{};
List<Map<String, List<int>>> matchIndex = [];
// Validating with REGEX
RegExp? allRegex;
allRegex = patternMatchMap != null
? RegExp(patternMatchMap?.keys.map((e) => e.pattern).join('|') ?? "",
caseSensitive: regExpCaseSensitive,
dotAll: regExpDotAll,
multiLine: regExpMultiLine,
unicode: regExpUnicode)
: null;
// Validating with Strings
RegExp? stringRegex;
stringRegex = stringMatchMap != null
? RegExp(r'\b' + stringMatchMap!.keys.join('|').toString() + r'+\$',
caseSensitive: regExpCaseSensitive,
dotAll: regExpDotAll,
multiLine: regExpMultiLine,
unicode: regExpUnicode)
: null;
text.splitMapJoin(
stringMatchMap == null ? allRegex! : stringRegex!,
onNonMatch: (String span) {
if (stringMatchMap != null &&
children.isNotEmpty &&
stringMatchMap!.keys.contains("${children.last.text}$span")) {
final String? ks =
stringMatchMap!["${children.last.text}$span"] != null
? stringMatchMap?.entries.lastWhere((element) {
return element.key
.allMatches("${children.last.text}$span")
.isNotEmpty;
}).key
: '';
children.add(TextSpan(text: span, style: stringMatchMap![ks!]));
return span.toString();
} else {
children.add(TextSpan(text: span, style: style));
return span.toString();
}
},
onMatch: (Match m) {
matches.add(m[0]!);
final RegExp? k = patternMatchMap?.entries.firstWhere((element) {
return element.key.allMatches(m[0]!).isNotEmpty;
}).key;
final String? ks = stringMatchMap?[m[0]] != null
? stringMatchMap?.entries.firstWhere((element) {
return element.key.allMatches(m[0]!).isNotEmpty;
}).key
: '';
if (deleteOnBack!) {
if ((isBack(text, _lastValue) && m.end == selection.baseOffset)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
children.removeWhere((element) => element.text! == text);
text = text.replaceRange(m.start, m.end, "");
selection = selection.copyWith(
baseOffset: m.end - (m.end - m.start),
extentOffset: m.end - (m.end - m.start),
);
});
} else {
children.add(
TextSpan(
text: m[0],
style: stringMatchMap == null
? patternMatchMap![k]
: stringMatchMap![ks],
),
);
}
} else {
children.add(
TextSpan(
text: m[0],
style: stringMatchMap == null
? patternMatchMap![k]
: stringMatchMap![ks],
),
);
}
final resultMatchIndex = matchValueIndex(m);
if (resultMatchIndex != null && onMatchIndex != null) {
matchIndex.add(resultMatchIndex);
onMatchIndex!(matchIndex);
}
return (onMatch(List<String>.unmodifiable(matches)) ?? '');
},
);
_lastValue = text;
return TextSpan(style: style, children: children);
}
final TextStyle composingStyle = style?.merge(const TextStyle(decoration: TextDecoration.underline))
?? const TextStyle(decoration: TextDecoration.underline);
return TextSpan(
children: <TextSpan>[
TextSpan(style: style, children: buildRegExpSpan(context: context, text: value.composing.textBefore(value.text))),
TextSpan(
style: composingStyle,
text: value.composing.textInside(value.text),
),
TextSpan(style: style, children: buildRegExpSpan(context: context, text: value.composing.textAfter(value.text))),
],
);
}
Map<String, List<int>>? matchValueIndex(Match match) {
final matchValue = match[0]?.replaceFirstMapped('#', (match) => '');
if (matchValue != null) {
final firstMatchChar = match.start + 1;
final lastMatchChar = match.end - 1;
final compactMatch = {
matchValue: [firstMatchChar, lastMatchChar]
};
return compactMatch;
}
return null;
}
List<TextSpan> buildRegExpSpan(
{required BuildContext context,
TextStyle? style,
required String? text}) {
List<TextSpan> children = [];
if (!(text != null && text.isNotEmpty)) {
return children;
}
final matches = <String>{};
List<Map<String, List<int>>> matchIndex = [];
// Validating with REGEX
RegExp? allRegex;
allRegex = patternMatchMap != null
? RegExp(patternMatchMap?.keys.map((e) => e.pattern).join('|') ?? "",
caseSensitive: regExpCaseSensitive,
dotAll: regExpDotAll,
multiLine: regExpMultiLine,
unicode: regExpUnicode)
: null;
// Validating with Strings
RegExp? stringRegex;
stringRegex = stringMatchMap != null
? RegExp(r'\b' + stringMatchMap!.keys.join('|').toString() + r'+\$',
caseSensitive: regExpCaseSensitive,
dotAll: regExpDotAll,
multiLine: regExpMultiLine,
unicode: regExpUnicode)
: null;
text.splitMapJoin(
stringMatchMap == null ? allRegex! : stringRegex!,
onNonMatch: (String span) {
if (stringMatchMap != null &&
children.isNotEmpty &&
stringMatchMap!.keys.contains("${children.last.text}$span")) {
final String? ks =
stringMatchMap!["${children.last.text}$span"] != null
? stringMatchMap?.entries.lastWhere((element) {
return element.key
.allMatches("${children.last.text}$span")
.isNotEmpty;
}).key
: '';
children.add(TextSpan(text: span, style: stringMatchMap![ks!]));
return span.toString();
} else {
children.add(TextSpan(text: span, style: style));
return span.toString();
}
},
onMatch: (Match m) {
matches.add(m[0]!);
final RegExp? k = patternMatchMap?.entries.firstWhere((element) {
return element.key.allMatches(m[0]!).isNotEmpty;
}).key;
final String? ks = stringMatchMap?[m[0]] != null
? stringMatchMap?.entries.firstWhere((element) {
return element.key.allMatches(m[0]!).isNotEmpty;
}).key
: '';
if (deleteOnBack!) {
if ((isBack(text!, _lastValue) && m.end == selection.baseOffset)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
children.removeWhere((element) => element.text! == text);
text = text!.replaceRange(m.start, m.end, "");
selection = selection.copyWith(
baseOffset: m.end - (m.end - m.start),
extentOffset: m.end - (m.end - m.start),
);
});
} else {
children.add(
TextSpan(
text: m[0],
style: stringMatchMap == null
? patternMatchMap![k]
: stringMatchMap![ks],
),
);
}
} else {
children.add(
TextSpan(
text: m[0],
style: stringMatchMap == null
? patternMatchMap![k]
: stringMatchMap![ks],
),
);
}
final resultMatchIndex = matchValueIndex(m);
if (resultMatchIndex != null && onMatchIndex != null) {
matchIndex.add(resultMatchIndex);
onMatchIndex!(matchIndex);
}
return (onMatch(List<String>.unmodifiable(matches)) ?? '');
},
);
return children;
}
}
至此可以看到效果图中@张三 高亮显示了。
三、使用TextFieldController测试@张三 高亮
调整好TextFieldController后,我这里测试@张三 高亮
我们进行初始化TextFieldController
// Add a controller
late TextFieldController _controller;
void initState() {
// TODO: implement initState
_controller = TextFieldController(
patternMatchMap: {
//
//* Returns every Hashtag with red color
//
RegExp(r"@[^\s]+\s?"):TextStyle(color:Colors.green),
//
//* Returns every Hashtag with red color
//
RegExp(r"\B#[a-zA-Z0-9]+\b"):TextStyle(color:Colors.red),
//
//* Returns every Mention with blue color and bold style.
//
RegExp(r"\B@[a-zA-Z0-9]+\b"):TextStyle(fontWeight: FontWeight.w800 ,color:Colors.blue,),
//
//* Returns every word after '!' with yellow color and italic style.
//
RegExp(r"\B![a-zA-Z0-9]+\b"):TextStyle(color:Colors.yellow, fontStyle:FontStyle.italic),
// add as many expressions as you need!
},
//* starting v1.2.0
// Now you have the option to add string Matching!
// stringMatchMap: {
// "String1":TextStyle(color: Colors.red),
// "String2":TextStyle(color: Colors.yellow),
// },
//! Assertion: Only one of the two matching options can be given at a time!
//* starting v1.1.0
//* Now you have an onMatch callback that gives you access to a List<String>
//* which contains all matched strings
onMatch: (List<String> matches){
// Do something with matches.
//! P.S
// as long as you're typing, the controller will keep updating the list.
},
deleteOnBack: true,
// You can control the [RegExp] options used:
regExpUnicode: true,
);
super.initState();
}
在TextField中使用TextFieldController。具体代码如下
TextField(
minLines: 1,
maxLines: null,
keyboardType: TextInputType.multiline,
textAlignVertical: TextAlignVertical.center,
autofocus: true,
focusNode: editFocusNode,
controller: _controller,
textInputAction: TextInputAction.send,
decoration: InputDecoration(
contentPadding: EdgeInsets.symmetric(vertical: 10, horizontal: 8.0),
filled: true,
isCollapsed: true,
floatingLabelBehavior: FloatingLabelBehavior.never,
hintText: "说点什么吧~",
hintStyle: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
fontStyle: FontStyle.normal,
color: ColorUtil.hexColor(0xACACAC),
decoration: TextDecoration.none,
),
enabledBorder: OutlineInputBorder(
/*边角*/
borderRadius: const BorderRadius.all(
Radius.circular(5.0), //边角为30
),
borderSide: BorderSide(
color: ColorUtil.hexColor(0xf7f7f7), //边框颜色为绿色
width: 1, //边线宽度为1
),
),
focusedBorder: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(5.0), //边角为30
),
borderSide: BorderSide(
color: ColorUtil.hexColor(0xECECEC), //边框颜色为绿色
width: 1, //宽度为1
),
),
),
)
经过输入删除测试,输入的“@张三”高亮显示在TextField中正常了。
使用TextEditingController的buildTextSpan,可以查看:https://blog.csdn.net/gloryFlow/article/details/132889374
完善TextField输入框匹配正则表达式高亮显示,可以查看:https://blog.csdn.net/gloryFlow/article/details/132899084
四、小结
flutter聊天界面-TextField输入框buildTextSpan实现@功能展示高亮功能。自定义修改TextEditingController。
内容较多,描述可能不准确,请见谅。
本文地址:https://blog.csdn.net/gloryFlow/article/details/132899084
学习记录,每天不停进步。