はじめに
Text ウィジェットで表示している説明文に URL が混ざっているとき、それをタップで外部ブラウザに飛ばしたい、というのはアプリ開発でよく出てくる要望です。
普段は url_launcher を TextButton などに紐付けるだけで済みますが、API レスポンスや CMS の本文など、実行時にしか中身が決まらないテキストに URL が混ざっている場合は、URL 部分だけを TextSpan に切り出して TapGestureRecognizer を付ける必要があります。
この記事では、外部パッケージに頼らず RichText 相当の Text.rich だけでリンク化する LinkifiedText ウィジェットを実装する過程と、その途中でハマった「和文との相性問題」「リンクの数だけ TapGestureRecognizer を作るときのリソース管理」あたりを共有します。
完成イメージ
例えば次のようなテキストを渡すと:
ドキュメント(https://docs.flutter.dev)。続きの本文があります。
https://docs.flutter.dev の部分だけがリンクとして色付き+下線で表示され、タップで外部ブラウザが起動します。末尾の )。 は通常テキスト扱い。http:// 始まりはセキュリティ観点であえて検出対象から外しました。
なぜパッケージを使わなかったか
flutter_linkify のような既存パッケージもありますが、執筆時点で最終リリースからしばらく更新が止まっており、新しい Flutter / Dart バージョンへの追従が読めない状態です。今回は「https:// のみ」「末尾句読点のトリム挙動をこちらで調整したい」という要件で、依存先のメンテ状況を気にしたくないこともあり、Text.rich で 100 行ちょいに収める方針にしました。
全体構成
ウィジェットとパーサを 1 ファイルにまとめます。
lib/components/linkified_text.dart
- LinkSegment : テキストを「リンク」「非リンク」のかたまりに分けた要素
- parseLinkSegments() : 文字列を List<LinkSegment> に分割する関数(テスト対象)
- LinkifiedText : 受け取った文字列を Text.rich で描画する StatefulWidget
分割ロジックを関数として切り出しておけば、ウィジェットを起動しなくても flutter_test で気軽に検証できます。
パーサ部分
まずは URL 検出と分割のロジックです。
import 'package:flutter/material.dart';
@immutable
class LinkSegment {
const LinkSegment({
required this.text,
required this.isLink,
});
final String text;
final bool isLink;
}
// URL の継続文字を印字可能 ASCII (0x21-0x7E) に限定する簡易ヒューリスティック。
// 厳密な URL 文字集合ではないが、和文の句読点や括弧で URL が停止するので、
// `[^\s]+` のように日本語まで吸い込むのを防げる。
final _urlPattern = RegExp(r'https://[\x21-\x7E]+');
final _trailingPunctuation = RegExp(r'[。、,.\))」』!?:;]+$');
List<LinkSegment> parseLinkSegments(String text) {
if (text.isEmpty) {
return const [];
}
final segments = <LinkSegment>[];
var cursor = 0;
for (final match in _urlPattern.allMatches(text)) {
var url = match.group(0)!;
var end = match.end;
// 末尾の句読点・閉じ括弧を URL から除外し、
// その分だけ末尾位置を巻き戻して通常テキスト側に戻す
final trailing = _trailingPunctuation.firstMatch(url);
if (trailing != null) {
final trimLength = trailing.end - trailing.start;
url = url.substring(0, url.length - trimLength);
end = match.end - trimLength;
}
if (url.isEmpty) {
continue;
}
if (match.start > cursor) {
segments.add(
LinkSegment(text: text.substring(cursor, match.start), isLink: false),
);
}
segments.add(LinkSegment(text: url, isLink: true));
cursor = end;
}
if (cursor < text.length) {
segments.add(
LinkSegment(text: text.substring(cursor), isLink: false),
);
}
return segments;
}
ハマりどころ 1: [^\s]+ だと和文を吸い込んでしまう
最初は素直に RegExp(r'https://[^\s]+') で書いていました。「URL の継続文字は『非空白文字』」という、まあ素朴な定義です。
しかし日本語テキストでは大抵 URL の前後にスペースを入れない書き方をします。例えば:
参照(https://example.com/foo)。続き
[^\s]+ だと https:// から末尾の き まで全部マッチしてしまい、その後で末尾の [。、,.))」』]+$ を当てても き で止まって何もトリムできません。結果として 1 行まるごと URL 扱い、というバグになります。
対策として、URL の継続文字を 印字可能 ASCII(0x21-0x7E) に限定します。
final _urlPattern = RegExp(r'https://[\x21-\x7E]+');
) までは ASCII なので URL に含めたうえで、その後の日本語 。 で停止 → 末尾 ) を _trailingPunctuation で剥がす → 期待どおり「参照(」「https://example.com/foo」「)。続き」の 3 セグメントに分割されます。
なお [\x21-\x7E]+ は厳密な URL 文字集合ではなく、<・>・"・\・^・{・|・} のような URL として不適切な文字も拾います。RFC 準拠が必要なら別途検証が要りますが、今回は「文中の URL を素朴に検出する」要件向けの割り切りです。同様に _trailingPunctuation も今回扱う句読点のみで、] などは対象外。文体に合わせて拡張する想定です。
ハマりどころ 2: 末尾句読点の位置の戻し
url.substring(0, url.length - trimLength) で URL から末尾を削るだけでなく、次のセグメントが始まる位置 cursor も end = match.end - trimLength に巻き戻す 必要があります。これを忘れると、削った )。 が誰にも拾われずに消えます。
cursor という変数で「これまでに消費した位置」を持つようにし、次の非リンクセグメントは text.substring(cursor, match.start) で切り出す方式にすると、トリムされた末尾文字も自然と次セグメントの先頭に含まれます。
ウィジェット部分
パーサができたら、Text.rich で描画する部分です。
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
class LinkifiedText extends StatefulWidget {
const LinkifiedText({
super.key,
required this.text,
this.style,
this.linkStyle,
this.textAlign,
this.maxLines,
this.overflow,
this.launchMode = LaunchMode.externalApplication,
});
final String text;
final TextStyle? style;
final TextStyle? linkStyle;
final TextAlign? textAlign;
final int? maxLines;
final TextOverflow? overflow;
final LaunchMode launchMode;
@override
State<LinkifiedText> createState() => _LinkifiedTextState();
}
class _LinkifiedTextState extends State<LinkifiedText> {
List<LinkSegment> _segments = const [];
final List<TapGestureRecognizer> _recognizers = [];
@override
void initState() {
super.initState();
_rebuildSegments();
}
@override
void didUpdateWidget(LinkifiedText oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.text != widget.text) {
_rebuildSegments();
}
}
@override
void dispose() {
_disposeRecognizers();
super.dispose();
}
void _disposeRecognizers() {
for (final recognizer in _recognizers) {
recognizer.dispose();
}
_recognizers.clear();
}
void _rebuildSegments() {
_disposeRecognizers();
_segments = parseLinkSegments(widget.text);
for (final segment in _segments) {
if (!segment.isLink) {
continue;
}
final url = segment.text;
_recognizers.add(
TapGestureRecognizer()..onTap = () => _openUrl(url),
);
}
}
Future<void> _openUrl(String url) async {
final uri = Uri.tryParse(url);
if (uri == null) {
return;
}
// launchUrl は false 戻り値 or 例外で失敗を伝える。今回は握り潰しているが、
// 業務要件によっては try/catch + ログ / SnackBar 等を足す。
await launchUrl(uri, mode: widget.launchMode);
}
@override
Widget build(BuildContext context) {
final effectiveLinkStyle = widget.linkStyle ??
(widget.style ?? const TextStyle()).copyWith(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
);
var recognizerIndex = 0;
final spans = <TextSpan>[];
for (final segment in _segments) {
if (segment.isLink) {
spans.add(
TextSpan(
text: segment.text,
style: effectiveLinkStyle,
recognizer: _recognizers[recognizerIndex++],
),
);
} else {
spans.add(TextSpan(text: segment.text));
}
}
return Text.rich(
TextSpan(style: widget.style, children: spans),
textAlign: widget.textAlign,
maxLines: widget.maxLines,
overflow: widget.overflow ?? TextOverflow.clip,
);
}
}
ポイント 1: TapGestureRecognizer のライフサイクル
TextSpan の recognizer に渡した TapGestureRecognizer は、所有者が dispose() を呼ばないとリークします。InlineSpan 側は recognizer の寿命を管理しません(公式 API でも明記)。
実装上のポイント:
- recognizer はリンクセグメントの数だけ作る必要がある →
_recognizersリストで管理 -
buildの中で recognizer を生成しないこと。再ビルドのたびに新インスタンスを使い捨て、古いインスタンスへの参照が消えるとdisposeできなくなる -
widget.textが変わったら作り直す必要がある →didUpdateWidgetでフックする -
dispose()で全部破棄
_rebuildSegments() 内では既存の recognizer を _disposeRecognizers() で破棄してから新規に作っています。これにより didUpdateWidget 経由で再構築されたケースでも古い recognizer は確実に解放されます。
ポイント 2: スタイル継承
Text.rich 自体に特別な仕組みがあるわけではなく、InlineSpan ツリー全体の継承ルールに従って親 TextSpan のスタイルが子に降りてきます。なので非リンクセグメントの TextSpan は text だけ指定すれば OK で、フォント設定(フォントファミリー、サイズ、fontFeatures など)は全部親から継承されます。
リンクセグメントだけ effectiveLinkStyle を上書きで指定して色+下線を当てる、という構造です。
ポイント 3: effectiveLinkStyle の組み立て
リンク部分のスタイルは linkStyle 指定があればそれ、無ければ style に primary 色と下線を重ねたものを使います。widget.style ?? const TextStyle() のガードで style 未指定時に null.copyWith で落ちないようにしてあるので、呼び出し側は「色だけ変えたければ linkStyle だけ渡す」で済みます。
呼び出し側
API などから取得した動的なテキストに適用するイメージです。
LinkifiedText(
text: description, // 実行時に取得した本文
style: Theme.of(context).textTheme.bodyMedium,
)
これだけで URL 自動検出 → 外部ブラウザ起動が成立します。
テスト
parseLinkSegments に対して 6 ケース書きました(一部抜粋)。
test('URL の末尾に付く句読点・閉じ括弧は URL から除外される', () {
final segments = parseLinkSegments(
'参照(https://example.com/foo)。続き',
);
expect(segments, hasLength(3));
expect(segments[0].isLink, isFalse);
expect(segments[0].text, '参照(');
expect(segments[1].isLink, isTrue);
expect(segments[1].text, 'https://example.com/foo');
expect(segments[2].isLink, isFalse);
expect(segments[2].text, ')。続き');
});
test('複数の URL を含むテキストが正しく分割される', () {
final segments = parseLinkSegments(
'A: https://a.example.com / B: https://b.example.com で終わり',
);
expect(segments.map((s) => s.text).toList(), [
'A: ',
'https://a.example.com',
' / B: ',
'https://b.example.com',
' で終わり',
]);
expect(
segments.map((s) => s.isLink).toList(),
[false, true, false, true, false],
);
});
test('http:// は対象外で通常テキストになる', () {
final segments = parseLinkSegments('安全でない http://example.com です');
expect(segments, hasLength(1));
expect(segments[0].isLink, isFalse);
});
タップ後の launchUrl 呼び出しは url_launcher_platform_interface の setMockMessageHandler 相当でモックすればウィジェットテストもできますが、今回はスコープを絞ってパーサ部分だけ自動テストし、ウィジェット側は実機で手動確認しました。
まとめ
https:// だけリンク化して外部ブラウザに飛ばすだけ、というシンプルな要件なら、Text.rich + TapGestureRecognizer で自前実装するのも十分現実的な選択肢です。
参考
