【Flutter】URL、メンション、ハッシュタグを正規表現で検出&テキストリンク表示する方法

## はじめに
この4月から新社会人になりました。
24卒エンジニア🔰のようです。
現在、趣味でMastodonのFlutter製クライアントアプリを友人と開発しているのですが、X（旧Twitter）のように、タイムライン上のテキストからURLやメンション、ハッシュタグを検出し、リンクテキスト化する実装に苦戦したのでメモを残しておきます。

## 問題
X等のSNSのタイムライン上では、投稿内のURL、メンション、ハッシュタグがテキストリンクとして色分けされたり、タップ可能になっていたりします。
しかし、Flutterでテキストを表示するためのTextウィジェットは、テキストリンクを検出することができないため、以下のような表示になってしまいます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2017091/af46d5d8-75a5-12c0-5179-172c6bb24f0d.png" width="300">

## 解決策
そこで、実装で一手間加え、テキストからURL、メンション、ハッシュタグを検出し、テキストリンクの表示を行う方法をご紹介します。

## Patternsクラス
正規表現をのパターンを定義したPatternsクラスを作成します。
URL、メンション、ハッシュタグをそれぞれ正規表現を用いて検出するために作成します。
```dart
class Patterns {
  Patterns._();

  static final RegExp url = RegExp(r"https?://[\w!?/+\-_~;.,*&@#$%()'[\]]+");
  static final RegExp mention = RegExp(r'@(\w+)');
  static final RegExp hashtagPattern = RegExp(r'#[0-9a-zA-Zぁ-んァ-ヶｱ-ﾝﾞﾟ一-龠]+');
}
```

## Stylesクラス
TextStyleを定義したStylesクラスを作成します。
通常テキストのcolorを`Colors.black`で、テキストリンクのcolorを`Colors.blue`で表示するために作成します。
```dart
class Styles {
  Styles._();

  static const TextStyle normal = TextStyle(color: Colors.black);
  static const TextStyle hyperlink = TextStyle(color: Colors.blue);
}
```

## TextUtilクラス
Text関連のロジックを定義したTextUtilクラスを作成します。
findLinkableTextMatchesメソッドでは、引数として与えられたtextにURL、メンション、ハッシュタグが含まれているか判定し、マッチの結果をList形式で返します。
```dart
class TextUtil {
  TextUtil._();

  static List<Match> findLinkableTextMatches(String text) {
    final List<Match> matches = [];

    final urlMatches = Patterns.url.allMatches(text);
    final mentionMatches = Patterns.mention.allMatches(text);
    final hashtagMatches = Patterns.hashtagPattern.allMatches(text);

    matches.addAll(urlMatches);
    matches.addAll(mentionMatches);
    matches.addAll(hashtagMatches);
    matches.sort((a, b) => a.start.compareTo(b.start));

    return matches;
  }
}
```

## LinkableTextクラス
上記3つのクラスを利用し、テキスト内から検出したURL、メンション、ハッシュタグをテキストリンクとして表示するUIクラスです。
textSpanListメソッドでは、マッチ結果のリストを元にTextSpanのリストを生成します。
```dart
class LinkableText extends StatelessWidget {
  const LinkableText({super.key, required this.text});

  final String text;

  List<TextSpan> textSpanList(List<Match> allMatches) {
    final textSpans = <TextSpan>[];
    int currentPosition = 0;

    for (var match in allMatches) {
      if (currentPosition < match.start) {
        final textPart = text.substring(currentPosition, match.start);
        textSpans.add(
          TextSpan(text: textPart, style: Styles.normal),
        );
      }

      final matchedText = text.substring(match.start, match.end);
      if (Patterns.url.hasMatch(matchedText)) {
        textSpans.add(
          TextSpan(
            text: matchedText,
            style: Styles.hyperlink,
            recognizer: TapGestureRecognizer()
              ..onTap = () => onTapUrl(matchedText),
          ),
        );
      } else if (Patterns.mention.hasMatch(matchedText)) {
        textSpans.add(
          TextSpan(
            text: matchedText,
            style: Styles.hyperlink,
            recognizer: TapGestureRecognizer()
              ..onTap = () => onTapMention(matchedText),
          ),
        );
      } else if (Patterns.hashtagPattern.hasMatch(matchedText)) {
        textSpans.add(
          TextSpan(
            text: matchedText,
            style: Styles.hyperlink,
            recognizer: TapGestureRecognizer()
              ..onTap = () => onTapHashtag(matchedText),
          ),
        );
      }

      currentPosition = match.end;
    }
    if (currentPosition < text.length) {
      final remainingText = text.substring(currentPosition);
      textSpans.add(
        TextSpan(text: remainingText, style: Styles.normal),
      );
    }
    return textSpans;
  }

  void onTapMention(String mention) {
    debugPrint('On tap mention: $mention');
  }

  void onTapUrl(String url) {
    debugPrint('On tap url: $url');
  }

  void onTapHashtag(String hashtag) {
    debugPrint('On tap hashtag: $hashtag');
  }

  @override
  Widget build(BuildContext context) {
    final allMatches = TextUtil.findLinkableTextMatches(text);
    final textSpans = textSpanList(allMatches);
    return RichText(
      text: allMatches.isEmpty
          ? TextSpan(text: text)
          : TextSpan(children: textSpans),
    );
  }
}
```

## サンプルコード（全文）
```dart
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({super.key});

  final String text = '''
    こんにちは @userさん！
    新しい https://www.example.comへようこそ！
    記念すべき #初投稿 ですね！
  ''';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Center(
          child: LinkableText(text: text),
        ),
      ),
    );
  }
}

class Patterns {
  Patterns._();

  static final RegExp url = RegExp(r"https?://[\w!?/+\-_~;.,*&@#$%()'[\]]+");
  static final RegExp mention = RegExp(r'@(\w+)');
  static final RegExp hashtagPattern = RegExp(r'#[0-9a-zA-Zぁ-んァ-ヶｱ-ﾝﾞﾟ一-龠]+');
}

class Styles {
  Styles._();

  static const TextStyle normal = TextStyle(color: Colors.black);
  static const TextStyle hyperlink = TextStyle(color: Colors.blue);
}

class TextUtil {
  TextUtil._();

  static List<Match> findLinkableTextMatches(String text) {
    final List<Match> matches = [];

    final urlMatches = Patterns.url.allMatches(text);
    final mentionMatches = Patterns.mention.allMatches(text);
    final hashtagMatches = Patterns.hashtagPattern.allMatches(text);

    matches.addAll(urlMatches);
    matches.addAll(mentionMatches);
    matches.addAll(hashtagMatches);
    matches.sort((a, b) => a.start.compareTo(b.start));

    return matches;
  }
}

class LinkableText extends StatelessWidget {
  const LinkableText({super.key, required this.text});

  final String text;

  List<TextSpan> textSpanList(List<Match> allMatches) {
    final textSpans = <TextSpan>[];
    int currentPosition = 0;

    for (var match in allMatches) {
      if (currentPosition < match.start) {
        final textPart = text.substring(currentPosition, match.start);
        textSpans.add(
          TextSpan(text: textPart, style: Styles.normal),
        );
      }

      final matchedText = text.substring(match.start, match.end);
      if (Patterns.url.hasMatch(matchedText)) {
        textSpans.add(
          TextSpan(
            text: matchedText,
            style: Styles.hyperlink,
            recognizer: TapGestureRecognizer()
              ..onTap = () => onTapUrl(matchedText),
          ),
        );
      } else if (Patterns.mention.hasMatch(matchedText)) {
        textSpans.add(
          TextSpan(
            text: matchedText,
            style: Styles.hyperlink,
            recognizer: TapGestureRecognizer()
              ..onTap = () => onTapMention(matchedText),
          ),
        );
      } else if (Patterns.hashtagPattern.hasMatch(matchedText)) {
        textSpans.add(
          TextSpan(
            text: matchedText,
            style: Styles.hyperlink,
            recognizer: TapGestureRecognizer()
              ..onTap = () => onTapHashtag(matchedText),
          ),
        );
      }

      currentPosition = match.end;
    }
    if (currentPosition < text.length) {
      final remainingText = text.substring(currentPosition);
      textSpans.add(
        TextSpan(text: remainingText, style: Styles.normal),
      );
    }
    return textSpans;
  }

  void onTapMention(String mention) {
    debugPrint('On tap mention: $mention');
  }

  void onTapUrl(String url) {
    debugPrint('On tap url: $url');
  }

  void onTapHashtag(String hashtag) {
    debugPrint('On tap hashtag: $hashtag');
  }

  @override
  Widget build(BuildContext context) {
    final allMatches = TextUtil.findLinkableTextMatches(text);
    final textSpans = textSpanList(allMatches);
    return RichText(
      text: allMatches.isEmpty
          ? TextSpan(text: text)
          : TextSpan(children: textSpans),
    );
  }
}
```

## 実行結果
上記のサンプルコードを`main.dart`に貼り付けて実行すると、以下のようなUIとなります。
URL、メンション、ハッシュタグがそれぞれ検出され、テキストリンク化されました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2017091/11f440e8-72b9-2b70-095d-f98c9c7b1751.png" width="300">!

## テキストを選択可能にする方法
上記の実装だと、テキストが選択できないため、スマホ上でテキストのコピペ等を行うことができません。
テキストを選択可能にするには、LinkableTextクラスのbuildメソッドを以下のように書き換えてください。
```dart
  @override
  Widget build(BuildContext context) {
    final allMatches = TextUtil.findLinkableTextMatches(text);
    final textSpans = textSpanList(allMatches);
    return SelectableText.rich(
      allMatches.isEmpty ? TextSpan(text: text) : TextSpan(children: textSpans),
    );
  }
```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2017091/59f63a8b-d301-98bb-331a-1f5481060763.png" width="300">

## おわりに
正規表現を用いてURL、メンション、ハッシュタグを検出し、テキストリンク表示する実装についてご紹介させていただきました。
SNS等の実装でお役に立てれば幸いです。

## 参考
- https://qiita.com/str32/items/a692073af32757618042
- https://api.flutter.dev/flutter/material/SelectableText/SelectableText.rich.html