Flutter 富文本(表情+@功能)

597 阅读1分钟

一.富文本正则匹配测试网站:regexr.com/ 1,自定义格式 表情: '[:smile]','[:winking]' 通过正则匹配到对应的图片

艾特功能: (@[\w\d-\u4e00-\u9fa5]+#\d+#)

匹配例如@开头的符合格式的一串字符:@zhoujielun#1#

2,匹配输出展示 使用TextSpan 的 InlineSpan进行展示: 输入框 :ExtendedTextField配合specialTextSpanBuilder展示

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:meetu_pro/common/utils/logger_util.dart';

import '../../../modules/chat/utils/image_util.dart';
import 'emoji_text.dart';
import 'emoji_view.dart';

typedef AtTextCallback = Function(String showText, String actualText);

class EmojiSpecialTextSpanBuilder extends SpecialTextSpanBuilder {
  final AtTextCallback? atCallback;

  final Map<String, String> allAtMap;
  final TextStyle? atStyle;

  EmojiSpecialTextSpanBuilder({
    this.atCallback,
    this.atStyle,
    this.allAtMap = const <String, String>{},
  });

  @override
  TextSpan build(
    String data, {
    TextStyle? textStyle,
    SpecialTextGestureTapCallback? onTap,
  }) {
    StringBuffer buffer = StringBuffer();
    if (kIsWeb) {
      return TextSpan(text: data, style: textStyle);
    }
    final List<InlineSpan> children = <InlineSpan>[];

    var regexEmoji = emojiFaces.keys
        .toList()
        .join('|')
        .replaceAll('[', '\\[')
        .replaceAll(']', '\\]');

    final list = [regexAt, regexEmoji];
    final pattern = '(${list.toList().join('|')})';
    final atReg = RegExp(regexAt);
    final emojiReg = RegExp(regexEmoji);

    data.splitMapJoin(
      RegExp(pattern),
      onMatch: (Match m) {
        late InlineSpan inlineSpan;
        String value = m.group(0)!;
        try {
          if (atReg.hasMatch(value)) {
            
            String? name = value.split('#').first;
            inlineSpan = SpecialTextSpan(
              text: name,
              actualText: value,
              start: m.start,
              style: atStyle,
            );
            buffer.write('$name ');
          } else if (emojiReg.hasMatch(value)) {
            inlineSpan = ImageSpan(
              ImageUtil.emojiImage(value),
              imageWidth: 20.w,
              imageHeight: 20.w,
              start: m.start,
              actualText: value,
            );
          } else {
            inlineSpan = TextSpan(text: value, style: textStyle);
            buffer.write(value);
          }
        } catch (e) {
          printDebugLog('error: $e');
        }
        children.add(inlineSpan);
        return '';
      },
      onNonMatch: (text) {
        children.add(TextSpan(text: text, style: textStyle));
        buffer.write(text);
        return '';
      },
    );
    if (null != atCallback) atCallback!(buffer.toString(), data);
    return TextSpan(children: children, style: textStyle);
  }

  @override
  SpecialText? createSpecialText(
    String flag, {
    TextStyle? textStyle,
    SpecialTextGestureTapCallback? onTap,
    required int index,
  }) {
    return null;
  }
}

text文本展示 ,自定义text视图:

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:meetu_pro/modules/chat/utils/image_util.dart';

import 'emoji_view.dart';

/// message content: @uid1 @uid2 xxxxxxx
///

enum ChatTextModel { match, normal }

class EmojiText extends StatelessWidget {
  final String text;
  final TextStyle? textStyle;
  final InlineSpan? prefixSpan;

  /// isReceived ? TextAlign.left : TextAlign.right
  final TextAlign textAlign;
  final TextOverflow overflow;
  final int? maxLines;
  final double textScaleFactor;

  /// all user info
  /// key:userid
  /// value:username
  final Map<String, String> allAtMap;
  final List<MatchPattern> patterns;
  final ChatTextModel model;

  // final TextAlign textAlign;
  const EmojiText({
    Key? key,
    required this.text,
    this.allAtMap = const <String, String>{},
    this.prefixSpan,
    this.patterns = const <MatchPattern>[],
    this.textAlign = TextAlign.left,
    this.overflow = TextOverflow.clip,
    this.textStyle,
    this.maxLines,
    this.textScaleFactor = 1.0,
    this.model = ChatTextModel.match,
  }) : super(key: key);

  static final _textStyle = TextStyle(
    fontSize: 14.sp,
    color: const Color(0xFF333333),
  );

  @override
  Widget build(BuildContext context) {
    final List<InlineSpan> children = <InlineSpan>[];

    if (prefixSpan != null) children.add(prefixSpan!);

    if (model == ChatTextModel.normal) {
      _normalModel(children);
    } else {
      _matchModel(children);
    }

    return Container(
      constraints: BoxConstraints(maxWidth: 0.5.sw),
      child: RichText(
        textAlign: textAlign,
        overflow: overflow,
        maxLines: maxLines,
        textScaleFactor: textScaleFactor,
        text: TextSpan(children: children),
      ),
    );
  }

  _normalModel(List<InlineSpan> children) {
    var style = textStyle ?? _textStyle;
    children.add(TextSpan(text: text, style: style));
  }

  _matchModel(List<InlineSpan> children) {
    var style = textStyle ?? _textStyle;

    final _mapping = Map<String, MatchPattern>();

    patterns.forEach((e) {
      if (e.type == PatternType.at) {
        _mapping[regexAt] = e;
      } else if (e.type == PatternType.email) {
        _mapping[regexEmail] = e;
      } else if (e.type == PatternType.mobile) {
        _mapping[regexMobile] = e;
      } else if (e.type == PatternType.tel) {
        _mapping[regexTel] = e;
      } else if (e.type == PatternType.url) {
        _mapping[regexUrl] = e;
      } else {
        _mapping[e.pattern!] = e;
      }
    });

    var regexEmoji = emojiFaces.keys
        .toList()
        .join('|')
        .replaceAll('[', '\\[')
        .replaceAll(']', '\\]');

    _mapping[regexEmoji] = MatchPattern(type: PatternType.emoji);
    _mapping[regexAt] = MatchPattern(type: PatternType.at);
    final pattern;

    if (_mapping.length > 1) {
      pattern = '(${_mapping.keys.toList().join('|')})';
    } else {
      pattern = regexEmoji;
    }

    // match  text
    stripHtmlIfNeeded(text).splitMapJoin(
      RegExp(pattern),
      onMatch: (Match match) {
        var matchText = match[0]!;
        var value = matchText;
        var inlineSpan;
        final mapping = _mapping[matchText] ??
            _mapping[_mapping.keys.firstWhere((element) {
              final reg = RegExp(element);
              return reg.hasMatch(matchText);
            }, orElse: () {
              return '';
            })];
        if (mapping != null) {
          if (mapping.type == PatternType.at) {
            // String uid = matchText.replaceFirst("@", "").trim();
            // value = uid;
            // if (allAtMap.containsKey(uid)) {
            //   matchText = '@${allAtMap[uid]!} ';
            // }
            String name = matchText.split('#').first;
            value = name;
            if (name.isNotEmpty) {
              matchText = name;
            }
            inlineSpan = TextSpan(
                text: matchText,
                style: const TextStyle(color: Color(0xff21D684)));
          }
          if (mapping.type == PatternType.emoji) {
            inlineSpan = ImageSpan(
              ImageUtil.emojiImage(matchText),
              imageWidth: style.fontSize!,
              imageHeight: style.fontSize!,
            );
          } else {
            inlineSpan = TextSpan(
              text: matchText,
              style: mapping.style ?? style,
              recognizer: mapping.onTap == null
                  ? null
                  : (TapGestureRecognizer()
                    ..onTap = () => mapping.onTap!(
                        _getUrl(value, mapping.type), mapping.type)),
            );
          }
        } else {
          inlineSpan = TextSpan(text: matchText, style: style);
        }
        children.add(inlineSpan);
        return '';
      },
      onNonMatch: (text) {
        children.add(TextSpan(text: text, style: style));
        return '';
      },
    );
  }

  _getUrl(String text, PatternType type) {
    switch (type) {
      case PatternType.url:
        return text.substring(0, 4) == 'http' ? text : 'http://$text';
      case PatternType.email:
        return text.substring(0, 7) == 'mailto:' ? text : 'mailto:$text';
      case PatternType.tel:
      case PatternType.mobile:
        return text.substring(0, 4) == 'tel:' ? text : 'tel:$text';
      // case PatternType.PHONE:
      //   return text.substring(0, 4) == 'tel:' ? text : 'tel:$text';
      default:
        return text;
    }
  }

  static String stripHtmlIfNeeded(String text) {
    return text.replaceAll(RegExp(r'<[^>]*>|&[^;]+;'), ' ');
  }
}

class MatchPattern {
  PatternType type;

  String? pattern;

  TextStyle? style;

  Function(String link, PatternType? type)? onTap;

  MatchPattern({required this.type, this.pattern, this.style, this.onTap});
}

enum PatternType { at, email, mobile, tel, url, emoji, custom }

/// 空格@uid空格 @xxx-
/// r"(\s@\S+\-)"
const regexAt = r"(@[\w\d\-\u4e00-\u9fa5]+#\d+#)";

/// Email Regex - A predefined type for handling email matching
const regexEmail = r"\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b";

/// URL Regex - A predefined type for handling URL matching
const regexUrl =
    r"[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:._\+-~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:_\+.~#?&\/\/=]*)";

// Regex of exact mobile.
const String regexMobile =
    '^(\\+?86)?((13[0-9])|(14[57])|(15[0-35-9])|(16[2567])|(17[01235-8])|(18[0-9])|(19[1589]))\\d{8}\$';

/// Regex of telephone number.
const String regexTel = '^0\\d{2,3}[-]?\\d{7,8}';