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

相关推荐
掘金一周2 小时前
企业中要做智能体,最佳的方案是什么? | 沸点周刊 6.18
前端·人工智能·ai编程
Darling噜啦啦2 小时前
CSS 3D 变换与 Flex 布局实战:从零打造旋转立方体
前端·css
秃头网友小李2 小时前
前端难点:keep-alive 缓存什么?RouterView 的 key 为什么要带 scopeId?
前端·vue.js
鱼人2 小时前
CSS 变量:一个变量救你一百次复制粘贴
前端
长大19883 小时前
CSS 到底是什么?和 HTML 的区别一次讲清楚
前端
禅思院3 小时前
路由性能优化终极指南:从懒加载漏洞到边缘渲染的架构跃迁
前端·架构·前端框架
怕浪猫3 小时前
Electron 开发实战(十六):总结与展望|生态现状、框架对比、行业趋势与学习指南
前端·javascript·electron
文心快码BaiduComate3 小时前
Comate 搭载GLM-5.2:百万上下文,稳定支撑长程任务
前端·程序员·开源
星栈3 小时前
Dioxus 的 `rsx!` 语法:如果你会 React,上手确实特别快
前端·前端框架