TextFieldに入力された文章に対して、キーワード(文字)検索機能を実装したい。
どうすればいいの?
という方向けの記事(前編)です。
実現したいアウトプットの仕様は、以下のようなイメージです。
- 検索バー(検索ボックス)を設置し、検索ワードを入れると、TextField内でマッチした文字がハイライトされる
- ボタンを押すと、順番にマッチした位置へカーソルが移動し、それに合わせてTextFieldも自動スクロールする
メモアプリに実装しようとしたとき、日本語の情報が見つからず、考えるのに苦労したので、その方法ご紹介したいと思います。
2.(カーソル移動・自動スクロール)の実装はなかなか難しく、コードも長くなってしまったので、本テーマは前編と後編の2回に分けました。
今回は1.の実装方法(検索バーの設置と、マッチした文字のハイライト方法)に関する記事(前編)になります。
後編については、下記記事をご参照ください。こちらに前編・後編を統合した完全版のコードがあります。
自分が調べた限りでは、ハイライトする方法は、大きく以下2つあったので、本記事ではそれぞれの方法をご紹介します。
① 「rich_text_controller」パッケージを使う方法
② 再帰関数を使って検索コードを自作する方法
知識不足ゆえ、間違った記載もあるかもしれず、その際はご指摘いただけると幸いですm(_ _)m
40代からプログラミング(Flutter)を始めて、GooglePlayとAppStoreにアプリを公開しているhalzo appdevです。
作成したアプリはこちら↓ 全てFlutterで開発したアプリです。
方法1:「rich_text_controller」パッケージを使う方法
何か良いパッケージがないかと探していたら、たまたま見つけました。
「pubspec.yaml」ファイルに以下を記載して、「pub get」し、パッケージをインポートします。
dependencies: rich_text_controller: ^1.3.0
コード例の全体像
コード例の全体像になります。
私のような初心者の方向けを想定し、できるだけコード内にコメントで説明をつけました。
また、「main.dart」にそのままそのままコピペいただけば、動くようにしていますので、よろしければお試し下さい。
// クラス名、メソッド名、プロパティ名(変数名)について、自分が作成したもの(名前変更可のもの) // の名前の末尾には、大文字のオー「O」をつけています // ※ライブラリ(パッケージ)で予め決められているもの(名前の変更不可のもの)と、 // 自分で作成したもの(名前の変更可のもの)の区別をしやすくするため import 'package:flutter/material.dart'; // 一部パッケージのソースコードを修正 line 38 import 'package:rich_text_controller/rich_text_controller.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: "Text Search Test", debugShowCheckedModeBanner: false, theme: ThemeData.light(), home: HomeScreenO(), ); } } class HomeScreenO extends StatefulWidget { @override _HomeScreenOState createState() => _HomeScreenOState(); } class _HomeScreenOState extends State<HomeScreenO> { // ここまではお決まりのコード // 検索ワードを入れるプロパティ。初期値は""にしておく String searchWordO = ""; // テキスト本文に設定するTextEditingControllerの拡張クラスのインスタンスを定義 // 後で具体的内容を入れるため、lateで宣言 late RichTextController mainTextControllerO; // 検索ワードを入力するTextFieldに設置する // TextEditingControllerクラスのインスタンスを作成 TextEditingController searchWordControllerO = TextEditingController(); // テキスト本文のTextFieldの高さ double textFieldHeightO = 200; // テキスト本文のTextFieldの左右パディング double paddingO = 10; @override void initState() { // mainTextControllerOを初期設定 // テキスト本文で、searchWordOを含む部分を赤背景・白文字になるよう設定 mainTextControllerO = RichTextController( // stringMap:も設定できるが、完全一致が条件となるため、 // ここでは正規表現での一致判定が可能なpatternMap:を使用 patternMap: { RegExp("$searchWordO"): TextStyle( color: Colors.white, backgroundColor: Colors.redAccent, ), }, ); super.initState(); } @override void dispose() { // 画面遷移時やアプリ終了時にリソースを開放するため // 2つのcontrollerをdispose処理 mainTextControllerO.dispose(); searchWordControllerO.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( // 検索ワード入力ボックスを設置するAppBar appBar: AppBar( title: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.search), Container( // 検索ワードを入力するTextFieldの横幅を設定 width: 200, child: TextFormField( // コントローラーの設置 controller: searchWordControllerO, decoration: InputDecoration( // デフォルトの下線の削除 border: InputBorder.none, // 改行時の行ズレを防ぐため設定 isDense: true, // TextField内の色を塗るため設定 filled: true, // 色はprimaryColor(青)の明るい色を設定 fillColor: Theme.of(context).primaryColorLight, ), // 検索ワード入力完了時の処理 onFieldSubmitted: (wordO) { // 検索マッチ位置の色変更を反映するため、setStateで画面を再描画 setState(() { // 入力した検索ワードをプロパティに格納 searchWordO = searchWordControllerO.text; // テキスト本文を一時的にプロパティに格納 String tempStringO = mainTextControllerO.text; // 入力後の検索ワードに基づいて改めてRichTextControllerのインスタンスを更新 mainTextControllerO = RichTextController(patternMap: { RegExp("$searchWordO"): TextStyle( color: Colors.white, backgroundColor: Colors.redAccent, ), }); // 更新により、インスタンスが初期化されてしまうため、 // コントローラーのテキストに、一時保管していたテキスト本文を代入 mainTextControllerO.text = tempStringO; }); }, ), ), ], ), ), // テキスト本文を入力するTextField部分 // キーボード出現時の画面はみ出しエラーを防ぐため、SingleChildScrollViewでラップ body: SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ Text("↓テキスト本文"), Container( // TextFieldに高さを設定 height: textFieldHeightO, // 背景色には、primaryColor(デフォルト青)の明るい色を設定 color: Theme.of(context).primaryColorLight, child: Scrollbar( child: TextField( // TextEditingControllerの設置 controller: mainTextControllerO, style: TextStyle( // フォントサイズとして、標準のprimaryTextThemeのbodyText1を設定 fontSize: Theme.of(context).primaryTextTheme.bodyText1!.fontSize, ), strutStyle: const StrutStyle( // 1行の高さをフォントサイズに対する倍数値で設定 height: 1.5, // 改行とともにズレるのを防ぐため、行間強制を設定 forceStrutHeight: true, ), // アプリ起動時にフォーカスが当たるよう設定 autofocus: true, // 複数行入力可能に設定 keyboardType: TextInputType.multiline, maxLines: null, decoration: InputDecoration( // パディングは左右にだけ設定 contentPadding: EdgeInsets.fromLTRB(paddingO, 0, paddingO, 0), // 枠の除去、改行時の行ズレ防止、背景色設定 border: InputBorder.none, isDense: true, filled: true, fillColor: Theme.of(context).primaryColorLight, ), ), ), ), ], ), ), ); } }
<仕様の補足>
検索ボックスに検索ワードを入力後、ギーボードの完了ボタン(✓マーク)を押すことで、検索が実行されるようになっています(検索ワードを入れただけだと検索は実行されません)。
一方、検索が実行された状態で、本文のTextFieldに文字を入力していくと、リアルタイムでマッチング結果が更新されます。
検索ボックスの入力結果もリアルタイムで検索結果に反映したい場合は、「onFieldSubmitted」属性ではなく、「onChanged」属性の方に処理を書けば実装可能かと思います。
なお、現時点では、複数キーワードへの検索は対応できていません。。。後日検証したいと思います。
留意点① パッケージのソースコードに一部修正が必要(バージョン1.3.0時点)
本記事の作成時点で、パッケージの最新バージョンは1.3.0ですが、このまま実行すると以下のエラーが出ます。
../../flutter/.pub-cache/hosted/pub.dartlang.org/rich_text_controller-1.3.0/lib/rich_text_controller.dart:38:12: Error: The method 'RichTextController.buildTextSpan' has fewer named arguments than those of overridden method 'TextEditingController.buildTextSpan'.
TextSpan buildTextSpan({TextStyle? style, required bool withComposing}) {
・・・(以下略)・・・
パッケージの中で使っている「buildTextSpan」というメソッド(後述の自作コードの方法でも使いますが)に、必要な引数が足りないよ、ということです。
「buildTextSpan」は、Flutterが標準で持っているメソッドのようですが、実際、自分が後述する自作コード内でもこのメソッドを使っており、FlutterがバージョンUPしたタイミングで同じエラーが出て修正した経験があります。
なので、「rich_text_controller」パッケージもその修正が追いついていないみたいです。
そのため、「RichTextController」の文字の上でF4を押して、パッケージのソースコードへ飛び、38行目の記述を以下のように修正します。
<修正前>
TextSpan buildTextSpan({TextStyle? style, required bool withComposing}) {
↓
<修正後> ※引数に「required BuildContext context,」を加える
TextSpan buildTextSpan({required BuildContext context, TextStyle? style, required bool withComposing}) {
なお、Android Studioだと、パッケージのソースコードを修正しようとすると、確認のダイアログが出るかもしれませんが、「〜anyway」と書いてある項目を選んで実行したら、修正できました(修正した際の画像をキャプチャできず、明確なメッセージを覚えておらず、、m(_ _)m)
留意点② マッチングの判定に使うプロパティは、正規表現の「patternMap:」を使う
RichTextControllerには、特定の文字列に対してスタイルを設定できる「stringMap:」と、特定の正規表現に対してスタイルを設定できる「patternMap:」の2つの属性があります。
初めは、パッケージのExampleのコードを参考に、「stringMap:」を使おうとしたのですが、ワイルドカードの設定方法が分からず、完全一致のとき(テキスト本文と検索ワードが全く同じとき)しか、設定したスタイルを表示できませんでした。
一方、「patternMap:」を使うと、
RegExp("$検索文字列のプロパティ名"): TextStyle( 設定したいスタイルのコード ),
のように書けば、検索文字列の前後に別の文字列があっても、設定したスタイルを表示させることができました。
この辺は使い方をよく分かっていないだけかもしれず、「stringMap:」で機能させる方法もあるのかもしれません。
留意点は以上の2点ぐらいで、後述するコードを自作する方法に比べると、圧倒的に短いコードで済むので、便利です。
ただ、独自の処理を加えたい場合は、パッケージのソースコードに手を加えるのは難解なので、自作する方が良いかもしれません。
方法2:再帰関数を使って検索コードを自作する方法
実際にメモアプリに実装したのは、この方法になります。
パッケージを使わなかった理由は、単純にアプリ作成時点では、「rich_text_controller」の存在を知らなかったためです。。
自作する際は、ネットで見つけた以下の情報を参考にさせていただきました。ありがとうございます!
この例では、固定されたテキストが検索対象なので、これを編集可能なTextFieldにも適用できるよう修正しました。
大まかには、TextEditingControllerの拡張クラスを自作し、TextField内のテキスト本文をTextSpanに分割して、マッチした箇所だけにハイライトのスタイルをつけて、もう一度合体して返す、という感じです。
「rich_text_controller」の一部機能を自作しているに近いと思います。
パッケージは「material.dart」しか使わないので、特段インポートは不要です。
コード例の全体像
コード例の全体像になります。こちらも「main.dart」にそのままコピペいただけば、動くようにしているので、よろしければお試し下さい。
// クラス名、メソッド名、プロパティ名(変数名)について、自分が作成したもの(名前変更可のもの) // の名前の末尾には、大文字のオー「O」をつけています // ※ライブラリ(パッケージ)で予め決められているもの(名前の変更不可のもの)と、 // 自分で作成したもの(名前の変更可のもの)の区別をしやすくするため import 'package:flutter/material.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: "Text Search Test", debugShowCheckedModeBanner: false, theme: ThemeData.light(), home: HomeScreenO(), ); } } class HomeScreenO extends StatefulWidget { @override _HomeScreenOState createState() => _HomeScreenOState(); } class _HomeScreenOState extends State<HomeScreenO> { // ここまではお決まりのコード // 検索ワードを入れるプロパティ。初期値は""にしておく String searchWordO = ""; // テキスト本文に設定するTextEditingControllerの拡張クラスのインスタンスを定義 // 後で具体的内容を入れるため、lateで宣言 late MainTextControllerO mainTextControllerO; // 検索ワードを入力するTextFieldに設置する // TextEditingControllerクラスのインスタンスを作成 TextEditingController searchWordControllerO = TextEditingController(); // テキスト本文のTextFieldの高さ double textFieldHeightO = 200; // テキスト本文のTextFieldの左右パディング double paddingO = 10; @override void initState() { // mainTextControllerOを初期設定 // 引数として検索ワードを渡す mainTextControllerO = MainTextControllerO(searchWordO: searchWordO); super.initState(); } @override void dispose() { // 画面遷移時やアプリ終了時にリソースを開放するため // 2つのcontrollerをdispose処理 mainTextControllerO.dispose(); searchWordControllerO.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( // 検索ワード入力ボックスを設置するAppBar appBar: AppBar( title: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.search), Container( // 検索ワードを入力するTextFieldの横幅を設定 width: 200, child: TextFormField( // コントローラーの設置 controller: searchWordControllerO, decoration: InputDecoration( // デフォルトの下線の削除 border: InputBorder.none, // 改行時の行ズレを防ぐため設定 isDense: true, // TextField内の色を塗るため設定 filled: true, // 色はprimaryColor(青)の明るい色を設定 fillColor: Theme.of(context).primaryColorLight, ), // 検索ワード入力完了時の処理 onFieldSubmitted: (wordO) { // 検索マッチ位置の色変更を反映するため、setStateで画面を再描画 setState(() { // 入力した検索ワードをプロパティに格納 searchWordO = searchWordControllerO.text; // テキスト本文を一時的にプロパティに格納 String tempStringO = mainTextControllerO.text; // 入力後の検索ワードに基づいて改めてMainTextControllerのインスタンスを更新 mainTextControllerO = MainTextControllerO(searchWordO: searchWordO); // 更新により、インスタンスが初期化されてしまうため、 // コントローラーのテキストに、一時保管していたテキスト本文を代入 mainTextControllerO.text = tempStringO; }); }, ), ), ], ), ), // テキスト本文を入力するTextField部分 // キーボード出現時の画面はみ出しエラーを防ぐため、SingleChildScrollViewでラップ body: SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ Text("↓テキスト本文"), Container( // TextFieldに高さを設定 height: textFieldHeightO, // 背景色には、primaryColor(デフォルト青)の明るい色を設定 color: Theme.of(context).primaryColorLight, child: Scrollbar( child: TextField( // TextEditingControllerの設置 controller: mainTextControllerO, style: TextStyle( // フォントサイズとして、標準のprimaryTextThemeのbodyText1を設定 fontSize: Theme.of(context).primaryTextTheme.bodyText1!.fontSize, ), strutStyle: const StrutStyle( // 1行の高さをフォントサイズに対する倍数値で設定 height: 1.5, // 改行とともにズレるのを防ぐため、行間強制を設定 forceStrutHeight: true, ), // アプリ起動時にフォーカスが当たるよう設定 autofocus: true, // 複数行入力可能に設定 keyboardType: TextInputType.multiline, maxLines: null, decoration: InputDecoration( // パディングは左右にだけ設定 contentPadding: EdgeInsets.fromLTRB(paddingO, 0, paddingO, 0), // 枠の除去、改行時の行ズレ防止、背景色設定 border: InputBorder.none, isDense: true, filled: true, fillColor: Theme.of(context).primaryColorLight, ), ), ), ), ], ), ), ); } } // TextEditingControllerの拡張クラスとして、 // 特定の文字列に背景色等のスタイルを設定した上で、 // テキスト本文を再構築するクラスを作成 class MainTextControllerO extends TextEditingController { // 引数で受けるプロパティを宣言 final String searchWordO; // 引数として検索ワードのプロパティを受けとる MainTextControllerO({required this.searchWordO}); @override // buildTextSpanは、編集中のテキストからTextSpanを構築するメソッド // 3つの引数名は変更不可 TextSpan buildTextSpan({required BuildContext context, TextStyle? style, required bool withComposing}) { // このtextはTextEditingControllerのテキスト内容(つまりテキスト本文の内容)を指している // contextは本来不要だが、後段のフォントサイズの設定で「Theme.of(context)」を使うため、引数に設定 return searchMatchO(text, searchWordO, style, context); } TextSpan searchMatchO(String mainTextO, String searchWordO, TextStyle? styleO, BuildContext context) { // テキスト本文を、異なるスタイルを設定するパーツ(TextSpan)に分けてリスト型で格納するプロパティ List<InlineSpan> childrenO = []; /// ケースA 検索ワードが空の場合 // テキスト本文全体を通常のスタイル(黒文字)に設定して、早期リターン if (searchWordO == "") { childrenO.add(TextSpan( style: TextStyle( color: Colors.black, // マッチ箇所のバックグラウンドカラーが維持されるのを防ぐため、 // 適当なバックグラウンドカラーを設定し、透過100%にする // ※単に背景と同色を設定するだけだと、カーソルが見えなくなってしまうため backgroundColor: Colors.white.withOpacity(0.0), // フォントサイズとして、標準のprimaryTextThemeのbodyText1を設定 fontSize: Theme.of(context).primaryTextTheme.bodyText1!.fontSize, ), text: mainTextO)); return TextSpan(style: styleO, children: childrenO); } /// ケースB 検索ワードがあり、本文が検索ワードを含んでいる場合 if (mainTextO.contains(searchWordO)) { /// ケースB① 本文の先頭が検索ワードと一致している場合 // つまり、本文の0番目〜検索ワードの文字数番目までの文字が、検索ワードと一致している場合 if (mainTextO.substring(0, searchWordO.length) == searchWordO) { // その一致部分のスタイルに、赤背景・白文字(マッチ部分だと分かる目立つスタイル)を // 設定して、リスト変数に格納 childrenO.add( TextSpan( style: TextStyle( color: Colors.white, backgroundColor: Colors.redAccent, fontSize: Theme.of(context).primaryTextTheme.bodyText1!.fontSize, ), text: mainTextO.substring(0, searchWordO.length), // 上記text:プロパティに続くテキスト部分を、children:プロパティにリスト型で設定 children: [ // 自分自身(searchMatchOメソッド)を再帰的に呼ぶことで、 // 文末まで繰り返しマッチング処理した結果(返り値)を設定 // ※繰り返しにより複数の返り値が返ってくるため、リスト型になる searchMatchO( // マッチした検索ワードの次の文字から文末までを // 検索対象として引数に入れ直し、マッチング処理 // ※次はケースB①②③, Cの場合がありうる // B②かCになればそこで終了 mainTextO.substring(searchWordO.length), searchWordO, styleO, context, ) ], ), ); /// ケースB② 本文と検索ワードが完全一致のとき(本文が検索ワードを含み、かつ文長が同じ場合のため) // テキスト本文全体を赤背景・白文字(マッチ部分だと分かる目立つスタイル)に設定 // これ以降、マッチング処理する本文はないため、処理終了(searchMatchOメソッド最後のreturnへ) } else if (mainTextO.length == searchWordO.length) { childrenO.add( TextSpan( style: TextStyle( color: Colors.white, backgroundColor: Colors.redAccent, fontSize: Theme.of(context).primaryTextTheme.bodyText1!.fontSize, ), text: mainTextO), ); /// ケースB③ 本文が検索ワードを含んでいるが、本文先頭での一致や完全一致はしていないとき /// つまり本文の後方で一致がある場合 } else { // その本文の先頭〜検索ワードが出てくる手前までの部分のスタイルに、 // 通常のスタイル(黒文字)を設定して、リスト変数に格納 childrenO.add( TextSpan( style: TextStyle( color: Colors.black, // マッチ箇所のバックグラウンドカラーが維持されるのを防ぐため、 // 適当なバックグラウンドカラーを設定し、透過100%にする // ※単に背景と同色を設定するだけだと、カーソルが見えなくなってしまうため backgroundColor: Colors.white.withOpacity(0.0), fontSize: Theme.of(context).primaryTextTheme.bodyText1!.fontSize, ), text: mainTextO.substring( 0, mainTextO.indexOf(searchWordO)), // 上記text:プロパティに続くテキスト部分を、children:プロパティにリスト型で設定 children: [ // 自分自身(searchMatchOメソッド)を再帰的に呼ぶことで、 // 文末まで繰り返しマッチング処理した結果(返り値)を設定 // ※繰り返しにより複数の返り値が返ってくるため、リスト型になる searchMatchO( // 検索ワードが出てくる手前までを除去し、検索ワードの先頭から文末までを // 検索対象として引数に入れ直し、マッチング処理 // ※次は必ず先頭マッチ(B①)か完全マッチ(B②)のいずれかになる // B②になればそこで終了 mainTextO .substring(mainTextO.indexOf(searchWordO)), searchWordO, styleO, context), ], ), ); } /// ケースC 本文が検索文字を含んでいない場合 } else { childrenO.add(TextSpan( style: TextStyle( color: Colors.black, // マッチ箇所のバックグラウンドカラーが維持されるのを防ぐため、 // 適当なバックグラウンドカラーを設定し、透過100%にする // ※単に背景と同色を設定するだけだと、カーソルが見えなくなってしまうため backgroundColor: Colors.white.withOpacity(0.0), fontSize: Theme.of(context).primaryTextTheme.bodyText1!.fontSize, ), text: mainTextO)); } // テキスト本文の全てにスタイルの設定が完了し、それをTextSpan型で返す return TextSpan(style: styleO, children: childrenO); } }
<仕様の補足>
方法1.と全く同じ挙動になるようにしています。
留意点① TextEditingControllerの拡張クラス内で、再帰関数を用いてマッチング処理を繰り返す
参考にしたコードで、この部分を理解するのがとても難しかったです。。
TextField内のテキスト本文を以下の流れで分割し、
マッチング判定
→スタイルの設定
→TextSpanとして合体
を繰り返していきます。
上図の右向きの灰色三角と、左向きの赤・青矢印で行ったり来たりしながら、それぞれスタイルを設定したTextSpanを合体させ、元のテキスト本文を再構築しています。
ケースB②、もしくはケースCになれば、これ以上はマッチングできないので、処理終了となります。
コード例では、マッチング判定を行う「searchMatchO」メソッドの中で、マッチング判定対象のテキスト本文を縮めて再設定し、再度、自分自身(「searchMatchO」メソッド)を呼び出して判定を行っています。
このメソッドを「再帰関数」と呼ぶらしいです。
繰り返し処理(イテレーション)は、forかwhileでしかできないと思っていたので、恥ずかしながら、こういう繰り返し処理の方法もあるのか、と勉強になりました。
留意点② 検索マッチしなかったTextSpanにも背景色をつける必要がある
検索マッチした箇所(TextSpan)に背景色(backgroundColor)をつけた場合、検索マッチしなかった箇所にも背景色をつけないと、検索マッチした箇所の背景色がそのまま維持されてしまいます。
そのため、検索マッチしなかった箇所(コード例の中のケースA、ケースB③、ケースC)についても、背景色(backgroundColor)を設定する必要があります。
但し、TextFieldの背景色と同じ色を指定するだけだと、その背景色がカーソルよりも前面に来てしまい、カーソルが見えなくなってしまうので、
backgroundColor: Colors.white.withOpacity(0.0)
のように、何か適当な色を設定した後に「.withOpacity(0.0)」をつけ、透過100%にするのが良いと思います。
コードは長くなりましたが、自作している分だけロジックをよく理解でき、カスタマイズしやすいメリットがあります。
今回は以上になります。
「後編」では、検索マッチした箇所にカーソルを移動させ、自動でスクロールさせる方法をご説明しています。よろしければ続けてご覧ください。
以上、ご参考になれば幸いです。
最後までお読みいただき、ありがとうございました。
個人アプリ開発で役立ったもの
おすすめの学習教材
\超初心者向けでオススメな元Udemyの講座/
\キャンペーン時を狙えば安価で網羅的な内容が学べる(日本語訳あり)/
\Gitの基礎について無料で学べる/
おすすめの学習書籍
\実用的。image_pickerに関してかなり助けられた/
\Dartの基礎文法を素早くインプットできる/
コメント