4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flutter で Text 内の URL を自動でリンク化して外部ブラウザで開く

4
Last updated at Posted at 2026-05-21

はじめに

Text ウィジェットで表示している説明文に URL が混ざっているとき、それをタップで外部ブラウザに飛ばしたい、というのはアプリ開発でよく出てくる要望です。

普段は url_launcherTextButton などに紐付けるだけで済みますが、API レスポンスや CMS の本文など、実行時にしか中身が決まらないテキストに URL が混ざっている場合は、URL 部分だけを TextSpan に切り出して TapGestureRecognizer を付ける必要があります。

この記事では、外部パッケージに頼らず RichText 相当の Text.rich だけでリンク化する LinkifiedText ウィジェットを実装する過程と、その途中でハマった「和文との相性問題」「リンクの数だけ TapGestureRecognizer を作るときのリソース管理」あたりを共有します。

完成イメージ

linkified_text_demo.gif

例えば次のようなテキストを渡すと:

ドキュメント(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 から末尾を削るだけでなく、次のセグメントが始まる位置 cursorend = 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 のライフサイクル

TextSpanrecognizer に渡した TapGestureRecognizer は、所有者が dispose() を呼ばないとリークしますInlineSpan 側は recognizer の寿命を管理しません(公式 API でも明記)。

実装上のポイント:

  • recognizer はリンクセグメントの数だけ作る必要がある → _recognizers リストで管理
  • build の中で recognizer を生成しないこと。再ビルドのたびに新インスタンスを使い捨て、古いインスタンスへの参照が消えると dispose できなくなる
  • widget.text が変わったら作り直す必要がある → didUpdateWidget でフックする
  • dispose() で全部破棄

_rebuildSegments() 内では既存の recognizer を _disposeRecognizers() で破棄してから新規に作っています。これにより didUpdateWidget 経由で再構築されたケースでも古い recognizer は確実に解放されます。

ポイント 2: スタイル継承

Text.rich 自体に特別な仕組みがあるわけではなく、InlineSpan ツリー全体の継承ルールに従って親 TextSpan のスタイルが子に降りてきます。なので非リンクセグメントの TextSpantext だけ指定すれば 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_interfacesetMockMessageHandler 相当でモックすればウィジェットテストもできますが、今回はスコープを絞ってパーサ部分だけ自動テストし、ウィジェット側は実機で手動確認しました。

まとめ

https:// だけリンク化して外部ブラウザに飛ばすだけ、というシンプルな要件なら、Text.rich + TapGestureRecognizer で自前実装するのも十分現実的な選択肢です。

参考

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?