Flutter最佳实践:IM聊天文字链接自动识别跳转

一、需求来源

最近遇到一个需求:

1、IM模块聊天消息链接自动识别跳转,需要识别邮箱,识别网络连接等。

2、同时可以长按选择文字。

最终效果如下:

二、使用示例

1、封装 RichTextExt 静态函数 createTextSpansByRegExp 基于正则匹配链接与邮箱。

2、SelectableText 实现长按拖选文字及长按菜单自定义,当 onSelectionChanged: null 时,长按选择无效。

dart 复制代码
  final String autoLinkText = '''
如需帮助,请访问官网 https://flutter.dev 或发送邮件至 support@example.com。
也可浏览 www.github.com/flutter 了解项目动态,联系邮箱 flutter.dev@google.com。
''';

/// 自动识别链接与邮箱
Widget buildAutoLinkRichText() {
 //正则匹配链接与邮箱
  final linkRegExp = RegExp(
    r'(?:https?://|www.)[^\s,。;、<>[]""]+|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}',
    caseSensitive: false,
  );
  DLog.d('linkRegExp 识别结果: $linkRegExp');
  return SelectableText.rich(
    TextSpan(
      style: TextStyle(
        fontSize: 16,    
      ),
      children: RichTextExt.createTextSpansByRegExp(
        text: autoLinkText,
        regExp: linkRegExp,
        linkStyle: const TextStyle(
          fontSize: 16,
          color: Colors.blue,
          fontWeight: FontWeight.w600,
          decorationColor: Colors.blue,
          decoration: TextDecoration.underline,
        ),
        onLink: (v) {
          DLog.d('自定义高亮: $v');
          if (v.startsWith('http')) {
            openLink(v);
          } else {
            openEmail(v);
          }
        },
      ),
    ),
    contextMenuBuilder: SelectableRegionExt.editableTextContextMenu,
    // onSelectionChanged: null,
  );
}

Future<void> openUrl(Uri uri) async {
  try {
    final launched = await launchUrl(uri, mode: LaunchMode.externalApplication);
    if (!launched) {
      ToastUtil.info('打开链接失败: $uri');
      return;
    }
    DLog.d('打开链接: $uri');
  } catch (e) {
    ToastUtil.info('打开链接失败: $uri');
  }
}

Future<void> openLink(String url) async {
  final uri = url.startsWith('http') ? Uri.parse(url) : Uri.parse('https://$url');
  await openUrl(uri);
}

Future<void> openEmail(String email) async {
  final uri = Uri(scheme: 'mailto', path: email);
  await openUrl(uri);
}

三、源码

rich_text_ext.dart

dart 复制代码
//
//  rich_text_ext.dart
//  flutter_templet_project
//
//  Created by shang on 7/31/21 1:48 PM.
//  Copyright © 7/31/21 shang. All rights reserved.
//

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

extension RichTextExt on RichText {
  /// 创建 List<TextSpan>
  ///
  /// text 整个段落
  /// textTaps 高亮字符串数组
  /// style 段落样式
  /// linkStyle 高亮样式
  /// prefix 切割符号,避免和文章包含字符串重复
  /// suffix 切割符号,避免和文章包含字符串重复
  /// onLink 高亮部分点击事件
  static List<TextSpan> createTextSpans({
    required String text,
    required List<String> textTaps,
    TextStyle? style,
    TextStyle? linkStyle,
    String prefix = "_&t",
    String suffix = "_&t",
    void Function(String v)? onLink,
  }) {
    final pattern = textTaps.map((d) => RegExp.escape(d)).join('|');
    final regExp = RegExp(pattern, multiLine: true, caseSensitive: false);
    final textNew = text.splitMapJoin(
      regExp,
      onMatch: (m) => '$prefix${m[0]}$suffix', // (or no onMatch at all)
      onNonMatch: (n) => n,
    );

    final list = textNew.split(RegExp('$prefix|$suffix'));
    return list.map((e) {
      if (e.isNotEmpty) {
        final isEquel =
            textTaps.contains(e) || textTaps.contains(e.toLowerCase()) || textTaps.contains(e.toUpperCase());
        if (isEquel) {
          return TextSpan(
            text: e,
            style: linkStyle ?? TextStyle(color: Colors.blue),
            recognizer: onLink == null ? null : TapGestureRecognizer()
              ?..onTap = () {
                onLink?.call(e);
              },
          );
        }
      }
      return TextSpan(text: e, style: style);
    }).toList();
  }

  /// 根据正则表达式创建 TextSpan
  ///
  /// text 整个段落
  /// regExp 正则表达式
  /// style 段落样式
  /// linkStyle 高亮样式
  /// prefix 切割符号,避免和文章包含字符串重复
  /// suffix 切割符号,避免和文章包含字符串重复
  /// onLink 高亮部分点击事件
  static List<TextSpan> createTextSpansByRegExp({
    required String text,
    required RegExp regExp,
    TextStyle? style,
    TextStyle? linkStyle,
    String prefix = "_&t",
    String suffix = "_&t",
    void Function(String v)? onLink,
  }) {
    final links = regExp.allMatches(text).map((e) => e.group(0)).where((e) => e != null).map((e) => e!).toList();
    return RichTextExt.createTextSpans(
      text: text,
      textTaps: links,
      style: style,
      linkStyle: linkStyle,
      prefix: prefix,
      suffix: suffix,
      onLink: onLink,
    );
  }
}

selectable_region_ext.dart 源码

dart 复制代码
//
//  SelectableRegionExt.dart
//  flutter_templet_project
//
//  Created by shang on 2026/6/16 18:36.
//  Copyright © 2026/6/16 shang. All rights reserved.
//

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_templet_project/extension/extension_local.dart';

extension SelectableRegionExt on SelectableRegion {
  /// 文字消息上下文菜单
  static Widget editableTextContextMenu(BuildContext context, EditableTextState state) {
    final anchors = state.contextMenuAnchors;
    final style = TextStyle(color: context.themeData.colorScheme.primary);
    final children = [
      TextButton(
        onPressed: () {
          state.copySelection(SelectionChangedCause.toolbar);
        },
        child: Text('复制', style: style),
      ),
      TextButton(
        onPressed: () {
          state.selectAll(SelectionChangedCause.toolbar);
        },
        child: Text('全选', style: style),
      ),
      TextButton(
        onPressed: () {
          state.searchWebForSelection(SelectionChangedCause.toolbar);
        },
        child: Text('搜索', style: style),
      ),
      TextButton(
        onPressed: () {
          FocusManager.instance.primaryFocus?.unfocus();
          state.hideToolbar();
        },
        child: Text('取消', style: style),
      ),
    ];
    return CupertinoTextSelectionToolbar(
      anchorAbove: anchors.primaryAnchor,
      anchorBelow: anchors.secondaryAnchor == null ? anchors.primaryAnchor : anchors.secondaryAnchor!,
      children: children,
    );
    // return AdaptiveTextSelectionToolbar(
    //   anchors: editableTextState.contextMenuAnchors,
    //   children: children
    // );
  }

  /// 文字消息上下文菜单
  static Widget selectableRegionContextMenu(BuildContext context, SelectableRegionState state) {
    final anchors = state.contextMenuAnchors;
    return CupertinoTextSelectionToolbar(
      anchorAbove: anchors.primaryAnchor,
      anchorBelow: anchors.secondaryAnchor ?? anchors.primaryAnchor,
      children: [
        CupertinoTextSelectionToolbarButton.text(
          onPressed: () {
            state.copySelection(SelectionChangedCause.toolbar);
          },
          text: '复制',
        ),
        CupertinoTextSelectionToolbarButton.text(
          onPressed: () {
            state.selectAll(SelectionChangedCause.toolbar);
          },
          text: '全选',
        ),
        CupertinoTextSelectionToolbarButton.text(
          onPressed: () {
            FocusManager.instance.primaryFocus?.unfocus();
            state.hideToolbar();
          },
          text: '取消',
        ),
      ],
    );
  }
}

总结

1、核心基于富文本 SelectableText.rich 实现,用正则将连接匹配为特殊样式的 TextSpan进行渲染。如果需要支持更多的场景,自己扩展正则支持即可。

2、通过第三方库也可以实现同样的效果。 flutter_linkify

3、 github

相关推荐
怕浪猫3 小时前
FDE 如何正确解决现场项目工程性的问题
前端·后端·ai编程
西瓜太郎12343 小时前
外层模型卡片与分组详情成功率不一致,应该怎么排查?
前端·typescript·go·软件工程·react
GreenTea3 小时前
vLLM 与 SGLang KV Cache 底层实现机制深度调研报告
前端·后端·算法
●VON4 小时前
Flutter 鸿蒙插件适配实战:用 device_screen_brightness 2.0.0 控制并监听屏幕亮度
flutter·华为·harmonyos
几何心凉6 小时前
没有万能模型:我用 AiiOnly Token Plan,把多款大模型的长处拼进同一个项目
前端
kyriewen6 小时前
Claude Code 的额度今天缩水了17%——官方公告上写的是"永久提高25%"
前端·ai编程·claude
雪芽蓝域zzs7 小时前
vue解构平铺VS对象包裹
前端·javascript·vue.js
风骏时光牛马8 小时前
从零到实战,完整AI学习路线
前端
三十而立洋8 小时前
深入理解 Monorepo:子包安装依赖
前端·前端工程化
IT_陈寒8 小时前
Java Stream处理大集合,我的内存怎么就炸了
前端·人工智能·后端