はじめに
この4月から新社会人になりました。
24卒エンジニア🔰のようです。
現在、趣味でMastodonのFlutter製クライアントアプリを友人と開発しているのですが、X(旧Twitter)のように、タイムライン上のテキストからURLやメンション、ハッシュタグを検出し、リンクテキスト化する実装に苦戦したのでメモを残しておきます。
問題
X等のSNSのタイムライン上では、投稿内のURL、メンション、ハッシュタグがテキストリンクとして色分けされたり、タップ可能になっていたりします。
しかし、Flutterでテキストを表示するためのTextウィジェットは、テキストリンクを検出することができないため、以下のような表示になってしまいます。
解決策
そこで、実装で一手間加え、テキストからURL、メンション、ハッシュタグを検出し、テキストリンクの表示を行う方法をご紹介します。
Patternsクラス
正規表現をのパターンを定義したPatternsクラスを作成します。
URL、メンション、ハッシュタグをそれぞれ正規表現を用いて検出するために作成します。
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
で表示するために作成します。
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形式で返します。
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のリストを生成します。
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),
);
}
}
サンプルコード(全文)
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、メンション、ハッシュタグがそれぞれ検出され、テキストリンク化されました。
!
テキストを選択可能にする方法
上記の実装だと、テキストが選択できないため、スマホ上でテキストのコピペ等を行うことができません。
テキストを選択可能にするには、LinkableTextクラスのbuildメソッドを以下のように書き換えてください。
@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),
);
}

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