Flutter TextField内でURLのハイパーリンクを表示させる方法

Flutter

TextField内にURLを入力したら、自動でハイパーリンクが設定されるようにしたい(URL部分をタップしたらブラウザが立ち上がるようにしたい)。どうすればいいの?

 

という方向けの記事です。

 

自分のメモアプリに実装しようと調べたとき、Textなどの静的なウィジェットに対する解説記事は複数見つかりました。

 

しかし、TextFieldのように、入力(表示)結果が動的に変わるウェジェットに適用した例は、少なくとも日本語では見つかりませんでした。

 

最終的に、以下の記事を見つけ、参考にさせていただいたのですが、理解するのに苦労したので、具体的な仕組みと利用方法について、学んだことを共有したいと思います。

 

 

大まかな結論は以下のとおりです。

 

  • ブラウザ起動のため、url_launcherパッケージを導入する
  • 正規表現の定義に用いるRegExpクラスを使い、URL判定をする
  • TextEditingControllerの拡張クラスを作り、「splitMapJoin」メソッドと「TapGestureRecognizer」クラスを使い、URL表記部分にタップイベントを設定する

 


40代からプログラミング(Flutter)を始めて、GooglePlayAppStoreに初アプリを公開したhalzo appdevです。

 

作成したアプリはこちら↓

シンプルメモ帳「BasicMemo」 – 文字数カウント、ワンタッチ入力、タグ管理等の便利機能を搭載

Google Play で手に入れよう
Download on the App Store

 

スポンサーリンク

サンプルコードとアウトプットイメージ

先に作成したサンプルコードの全体像と、アウトプットイメージを掲載します。

 

 

コード中には、できる限りコメントで説明を入れています。

 

// クラス名、メソッド名、プロパティ名(変数名)について、筆者が作成したもの(名前変更可のもの)
// の名前の末尾には、大文字のオー「O」をつけています
// ※ライブラリ(パッケージ)で予め決められているもの(名前の変更不可のもの)と、
//  自分で作成したもの(名前の変更可のもの)の区別をしやすくするため

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: "Url hyperlink Test",
      debugShowCheckedModeBanner: false,
      theme: ThemeData.light(),
      home: HomeScreenO(),
    );
  }
}

class HomeScreenO extends StatefulWidget {
  @override
  _HomeScreenOState createState() => _HomeScreenOState();
}

class _HomeScreenOState extends State<HomeScreenO> {
// ここまではお決まりのコード

  // ハイパーリンク用のスタイル ※青字にアンダーライン
  TextStyle hyperLinkStyleO =
      TextStyle(color: Colors.blue, decoration: TextDecoration.underline);

  // TextEditingControllerの拡張クラスのインスタンスを作成
  // 引数にハイパーリンク用のスタイルを設定
  // ※上で宣言したhyperLinkStyleOをここで使えるようにlateで宣言
  late LinkedTextEditingControllerO controllerO =
      LinkedTextEditingControllerO(linkStyleO: hyperLinkStyleO);

  // テキスト本文のTextFieldの高さ
  double textFieldHeightO = 200;
  // テキスト本文のTextFieldの左右パディング
  double paddingO = 10;

  @override
  void initState() {
    super.initState();
  }

  @override
  void dispose() {
    // ウィジェットの破棄とともに、コントローラーも破棄する
    controllerO.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        // AppBar
        appBar: AppBar(),

        // テキスト本文を入力するTextField部分
        // キーボード出現時の画面はみ出しエラーを防ぐため、SingleChildScrollViewでラップ
        body: SingleChildScrollView(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.start,
            children: [
              Text("↓TextField"),
              Container(
                // TextFieldに高さを設定
                height: textFieldHeightO,
           
                // 背景色には、primaryColor(デフォルト青)の明るい色を設定
                color: Theme.of(context).primaryColorLight,
                child: Scrollbar(
                  child: TextField(

                    // 上で定義したTextEditingControllerの拡張クラスのインスタンスを設置
                    controller: controllerO,

                    style: TextStyle(
                      // フォントサイズはprimaryTextThemeのbodyText1を設置
                      fontSize: Theme.of(context)
                          .primaryTextTheme
                          .bodyText1!
                          .fontSize,
                    ),

                    // アプリ起動時にフォーカスが当たるよう設定
                    autofocus: true,

                    // 複数行入力可能に設定
                    keyboardType: TextInputType.multiline,
                    maxLines: null,

                    decoration: InputDecoration(

                      // 枠の除去
                      border: InputBorder.none,

                      // 背景色設定
                      filled: true,
                      fillColor: Theme.of(context).primaryColorLight,
                    ),
                  ),
                ),
              ),
            ],
          ),
        ),
      );
  }
}

// ハイパーリンクできるようにするためのTextEditingControllerの拡張クラス
class LinkedTextEditingControllerO extends TextEditingController {
  final RegExp linkRegexpO;
  final TextStyle linkStyleO;
  final Function(String matchO) onTapO;

  // URLを正規表現で表したインスタンス
  // ※staticにしないと、後段の代入処理でエラーになる
  static RegExp _defaultRegExpO = RegExp(

      // URLを表す正規表現
      r'https?://([\w-]+\.)+[\w-]+(/[\w-./?%&=#]*)?',

      // 大文字・小文字を区別しないため、falseにする
      caseSensitive: false,

      // 「.」は、文末の「.」も含めて任意の一文字を表すものとするため、trueにする
      dotAll: true,
  );

  // 指定されたURLのウェブページを起動するメソッド
  // ※staticにしないと、後段の代入処理でエラーになる
    static void _defaultOnLaunchO(String fullUrlO) async {

    // デバイス上のアプリで起動可能なURLか否かを確認し、OKならブラウザを起動
    if (await canLaunch(fullUrlO)) {
      await launch(fullUrlO);
    }
  }

  // コンストラクタによる引数の受け取り
  LinkedTextEditingControllerO({
    // TextFieldのテキスト内容(TextEditingControllerのtextプロパティ)を入れるプロパティを定義
    String? textO,
    // URL表記とのマッチングを判定する正規表現のプロパティを定義
    RegExp? regexp,
    // URL部分の表示スタイルを引数として受け取る
    required this.linkStyleO,
    // 上で作成したstaticメソッドを引数として受け取り、onTapOメソッドに代入する
    this.onTapO = _defaultOnLaunchO,
  })
  // regexpがnullなら(本コードではnull)、上で作成した正規表現のインスタンスを代入
      : linkRegexpO = regexp ?? _defaultRegExpO,
  // TextFieldのテキスト内容(TextEditingControllerのtextプロパティ)を引き継ぐ
        super(text: textO);

  // buildTextSpanは、TextEditingControllerの中にあるメソッド
  // ※この「style」は名称変更できてしまうが、変更不可(変更するとエラーになる)
  @override
  TextSpan buildTextSpan(
      {BuildContext? context, TextStyle? style, bool? withComposing}) {

    // 分割したテキスト(TextSpan型)をリスト型で格納するプロパティ
    List<TextSpan> childrenO = [];

    // TextEditingControllerの持つtextプロパティ(テキスト内容)を、
    // 条件(URL表記の正規表現)にマッチする箇所とマッチしない箇所に分割し、
    // それぞれに処理を加えた上で、再結合するメソッド
    text.splitMapJoin(

      // マッチ判定をする正規表現
      linkRegexpO,

      // マッチした部分に、URL表示用のスタイルとタップイベントを設定したTextSpanを作り、
      // それを上で用意したリスト型プロパティに追加するメソッドを設置
      onMatch: (Match matchO) {

        childrenO.add(
          TextSpan(

            // マッチした文字列を指定
            // ※Match型のプロパティに[0]をつけると、マッチした箇所の全体の文字列を取得できる
            text: matchO[0],

            // その部分に与えるテキストスタイルを指定
            style: linkStyleO,

            // その部分にイベント(タップ等したら何かが起こる処理)を設定
            recognizer:

            // TapGestureRecognizer()のonTapメソッドに、メソッド「() => onTap(match[0]!)」を代入
            // TapGestureRecognizer().onTapは、返り値なしのメソッド(void)だが、
            // 実行結果を引数recognizerとして渡すため、ダブルドット..にしている
              TapGestureRecognizer()..onTap = () => onTapO(matchO[0]!),
          ),
        );

        // onMatchには、返り値必須のメソッドを設置する必要あり
        // 本サンプルコードでは、childrenOの配列を通じて、スタイル・タップイベント設定後の
        // 文字列を取得するので、実質的には返り値を使用しない
        // そのため、適当な返り値として""を返しておく
        return "";
      },

      // マッチしなかった部分には、通常のスタイルを設定したTextSpanを
      // 上で用意したリスト型プロパティに追加するメソッドを設置
      onNonMatch: (String spanO) {

        // この「style」は名称変更できてしまうが、エラーになるので変更不可
        childrenO.add(TextSpan(text: spanO, style: style));

        // onMatchと同じ。返り値は使わないため""を設定
        return "";
      },
    );

    // マッチした部分・しなかった部分を結合したTextSpanを、
    // buildTextSpanメソッドの返り値として返す
    // この「style」は名称変更できてしまうが、エラーになるので変更不可
    return TextSpan(style: style, children: childrenO);
  }
}

 

以下に要点(自分が理解するのに苦労したポイント)をご説明します。

 

なお、116行目までは、主にTextFieldを表示するコードのため、それ以降を中心に記載します。

 

スポンサーリンク

「url_launcher」をインポートし、「AndroidManifest.xml」と「info.plist」に追記

URL部分をタップしたときに、ブラウザを起動するためのパッケージを導入します。

 

 

「pubspec.yaml」ファイルに以下を記載して、「pub get」し、パッケージをインポートします。

 

dependencies:
  url_launcher: ^6.0.14

 

Androidの対応

「url_launcher」を起動させるために、パッケージのReadmeに従って、「AndroidManifest.xml」ファイルに、下記の記述を追記します。

 

    <queries>
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="https" />
        </intent>
    </queries>

 

追記する場所は、manifestタグの中(<manifest・・・> から </manifest>の間)であれば、どこでも大丈夫です。

 

これを追記しないと、URL部分をタップしてブラウザを起動することができませんでした。

 

ただ、「https」を指定しているので、「http」のアドレスにはアクセスできないのでは?と思いましたが、試したところ問題なく動作しました。

 

なお、上記方法ではなく、以下を追記する方法で動作可能と書かれている情報もあります。

 

    <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"/>

 

ただ、以下のイシューを見ると、Googleは今後「QUERY_ALL_PACKAGES」でのアクセスに制限を入れる可能性があるとのことなので、採用しない方が無難そうです。

 

 

iOSの対応

こちらもパッケージのReadmeに従って、「info.plist」ファイルに、下記の記述を追記します。

 

    <key>LSApplicationQueriesSchemes</key>
    <array>
      <string>https</string>
      <string>http</string>
    </array>

 

追記する場所は、「info.plist」の上から4行目あたり(何行目かは人によって若干違うかもしれません)にある<dict>と、下から2行目にある</dict>の間であれば、どこでも大丈夫です。

 

スポンサーリンク

URL表記を識別するための正規表現(RegExp)を作る

以下の部分では、正規表現を規定するRegExpクラスを使い、URL表記を識別する正規表現を定義しています。

 

  static RegExp _defaultRegExpO = RegExp(

    // URLを表す正規表現
    r'https?://([\w-]+\.)+[\w-]+(/[\w-./?%&=#]*)?',

    // 大文字・小文字を区別しないため、falseにする
    caseSensitive: false,

    // 「.」は、文末の「.」も含めて任意の一文字を表すものとするため、trueにする
    dotAll: true,
  );

 

URLを表す正規表現は、調べると以下のように何通りかパターンがありました。

 

① 出典

r'((?:(https?:\/\/)?)(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,5}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*))'

② 出典

r'https?://([\w-]+\.)+[\w-]+(/[\w-./?%&=]*)?$'

③ 出典

r'https?://[\w/:%#\$&\?\(\)~\.=\+\-]+'

 

このうち、個人的には、上記②に「#」を加えた下記記載が、シンプルかつ分かりやすいと思い(「#」もURLに含まれていることがあるため)、本サンプルコードでも採用しています。

 

r'https?://([\w-]+\.)+[\w-]+(/[\w-./?%&=#]*)?$'

 

本記事では正規表現の詳しい説明は割愛しますが、上記記載を採用した場合は、以下の条件を満たせばマッチしたと判定されます。

 

  • 「https://」もしくは「http://」で始まり、
  • その後に、英数字またはハイフン「-」が1文字上あった後に、
  • ピリオド「.」があり、その後に、
  • 英数字またはハイフン「-」が1文字以上ある

 

例えば、「https://www.yahoo.co.jp」を書こうとした場合、「https://www.y」まで書いた時点でマッチしたと判定されます。

 

また、この後にスラッシュ「/」が来て、それ以降に、英数字、ハイフン、ピリオド、スラッシュ、?、%、&、=、#のどれかが来る間はマッチしたと判定され、これら以外の文字(空白含む)が来た時点で、マッチ部分は終了になります。

  

正規表現については、上記②の出典でもある、こちらの記事が大変分かりやすく、勉強になりました。ありがとうございます!

 

 

splitMapJoinメソッドを使い、マッチ箇所・非マッチ箇所に分けて処理を記述

動的なTextFieldのテキストを対象に、URLの正規表現とのマッチング処理を行うため、まず、TextEditingControllerの拡張クラス(ここでは「LinkedTextEditingControllerO」)を作ります。

 

その中で、TextEditingControllerクラス内に用意されているbuildTextSpanメソッドをoverride(承継)する形で使用します。

 

// ハイパーリンクできるようにするためのTextEditingControllerの拡張クラス
class LinkedTextEditingControllerO extends TextEditingController {

・・・・・・ 略 ・・・・・・

  // buildTextSpanは、TextEditingControllerの中にあるメソッド
  @override
  TextSpan buildTextSpan(
      {BuildContext? context, TextStyle? style, bool? withComposing}) {
      ・・・・・・ 略 ・・・・・・

 

buildTextSpanメソッドは、テキストに任意の表示やイベントを付加できるメソッドだと理解しています。

 

    List<TextSpan> childrenO = [];

    text.splitMapJoin(
      linkRegexpO,
      onMatch: (Match matchO) {
        childrenO.add(
          TextSpan(
            text: matchO[0],
            style: linkStyleO,
            recognizer:
            TapGestureRecognizer()..onTap = () => onTapO(matchO[0]!),
          ),
        );
        return "";
      },

      onNonMatch: (String spanO) {
        childrenO.add(TextSpan(text: spanO, style: style));
        return "";
      },
    );

 

このメソッドの中で、splitMapJoinメソッドを使い、1つ目の引数に設定した正規表現(ここでは「linkRegexpO」)とマッチする箇所(onMatch)・マッチしない箇所(onNonMatch)に分けて、処理を記述します。

  

TapGestureRecognizerクラスのonTapメソッドを使い、マッチ箇所にタップイベントを設定

          TextSpan(
            text: matchO[0],
            style: linkStyleO,
            recognizer: TapGestureRecognizer()..onTap = () => onTapO(matchO[0]!),
          ),

 

マッチした箇所には、URLであることが分かる表示スタイル(青字・下線)を設定し、さらに、recognizerプロパティに、TapGestureRecognizer()クラスのonTapメソッドを設置することで、タップイベントを設定します。

 

TapGestureRecognizer()クラスを使うには、「gestures.dart」をインポートする必要がありますが、Flutterに標準で入っているライブラリのため、TapGestureRecognizer()の所で「option+Enter」(MacのAndroid Studioの場合)を押せば、そのままインポートできます。

 

onTapメソッドには、最終的に、タップするとブラウザが起動する処理が書かれた_defaultOnLaunchOメソッド(後述)が代入されます。

 

なお、onTapメソッドは、TapGestureRecognizer()クラスが元々持っているメソッドです。

 

自分で作成したメソッド・プロパティ名の末尾にはOを付けているので、onTapOメソッドとは別のメソッドになります(onTapOメソッドをonTapメソッドに代入するので、最終的には同一になります。そして、onTapOメソッドには、コンストラクタの所で_defaultOnLaunchOが代入されます)。

 

TapGestureRecognizer()..onTap

 

この部分でなぜ「ダブルドット」を使うのか、理解に苦労したため、補足します。

 

recognizerプロパティには、GestureRecognizer?型の返り値を返すメソッドを設定する必要がありますが、onTapはvoid(返り値なし)のメソッドのため、普通にシングルドット「.」でつなぐと、返り値がなく、recognizerプロパティに設定できません。

 

しかし、ダブルドット「..」でつなぐと、実行結果自体を返り値として返すことができるため、設定できるようになります。

 

canLaunchメソッドで判定後、launchメソッドでブラウザを起動

  static void _defaultOnLaunchO(String fullUrlO) async {
    if (await canLaunch(fullUrlO)) {
      await launch(fullUrlO);
    }
  }

 

_defaultOnLaunchOメソッドは、TextField内のURL表記をタップした際に、ブラウザを起動させる処理です。

 

canLaunchメソッドで、デバイスにインストールされているブラウザで起動可能かを判定し、OKならlaunchメソッドでブラウザを起動します。

 

いずれも上記でインポートした「url_launcher」パッケージにあるメソッドです。

 

補足

iOSシミュレーターでのみエラー(実機は問題なし)

本サンプルコードは、Android実機、Androidエミュレーター、iOS実機(iPhone8)では問題なく動作したのですが、なぜかiOSシミュレーター(iPhone8とiPhone11 ProMaxでテスト)だけは、画面に以下のようなエラーが出ました。

 

iOSシミュレーターのみエラーになる

 

ただ、コンソールには何もエラーが表示されません。

 

エラーのとおり「editble.dart」ファイルの2376行を見たところ、確かに「assert(readOnly && !obscureText);」とあるので、このassertに引っかかったようです。

 

ただ、特段、TextFieldのプロパティに、readOnlyもobscureTextも設定していないので、しばらくググってみたのですが、結局、同様のエラー情報は見つからず、原因は不明なままです。

 

とりあえず実機では問題なく動作しているので、後日また検証したいと思います。

 

splitMapJoinメソッドは、文字検索の一致判定にも使えそう

以前、下記記事で、TextFieldに文字検索機能を実装する方法として、「rich_text_controller」パッケージを使う方法と、再帰関数を使ってコードを自作する方法を紹介しました。

 

 

しかし、再帰関数を使ったコードは、今回用いたspritMapJoinメソッドを使うともっと簡単に書けるとわかりました。

 

実際、「rich_text_controller」パッケージのソースコードを見てみると、この「spritMapJoin」メソッドが使われていました。

 


以上、ご参考になれば幸いです。

 

最後までお読みいただき、ありがとうございました。

 

\ Flutterの学習で役立ったコンテンツ・書籍 /

Udemy 【ゼロからのFlutter】iOS/Androidアプリを”いっぺんに”開発入門・初級編<みんプロ式>

 

 


Dart入門 – Dartの要点をつかむためのクイックツアー

コメント

タイトルとURLをコピーしました