Flutter: TextFieldへのキーワード検索機能の実装法【後編】〜検出部分へのカーソル移動と自動スクロール〜

Flutter

TextFieldに入力された文章に対して、キーワード(文字)検索機能を実装したい。

どうすればいいの?

 

という方向けの記事(後編)になります。

 

実現したいアウトプットの仕様は、以下のようなイメージです。

 

  1. 検索ボックスに検索ワードを入れると、TextField内でマッチした文字がハイライトされる
  2. ボタンを押すと、順番にマッチした位置へカーソルが移動し、それに合わせてTextFieldも自動スクロールする

 

このうち、「前編」の記事では、1.の実装方法(マッチした文字のハイライト方法)についてご紹介しました。

 

 

今回は、その続編(後編)として、2.(検索マッチした箇所へのカーソル移動・自動スクロール)の実装方法についてご説明します。

 

本記事で、前編・後編を統合した全コード例を掲載いたしますが、前編部分の詳細説明をご覧になりたい方は、上記記事をご参照ください。

 

本記事における主なポイントは以下になります。

 

  • カーソル移動
    • indexOfメソッドで、テキスト本文中で、検索ワードとマッチした1文字目の位置(文字数値)を取得
    • 残りのテキスト本文に対し、上記処理を繰り返し、マッチした位置を順次、配列変数にストック
    • ボタン押下時に、現在のカーソル位置と配列にストックされたマッチ位置を比較し、カーソル位置を移動
  • 自動スクロール
    • TextPainterクラスLineMetricsクラスを用いて、TextField上端から移動後のカーソル位置までの行数を把握
    • (行数−1)✕1行の高さ で、スクロールさせたい場所のY座標位置(pixel値。TextField内の相対的位置(オフセット値))を取得
    • ScrollControllerのanimateToメソッドを使って、上記位置へ強制スクロール

 

知識不足もあり、フラグを使うなどして、かなり力づくな処理になっています。。

 

もっとスマートな方法があるようでしたら、ぜひご教示くださいm(_ _)m。

 


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

 

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

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

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

 

スポンサーリンク

コード例の全体像

今回は先に作成したコードサンプルの全体像を示します。

 

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

// LineMetricsを使うのに必要 ※pubspec.yamlに記載は不要 **
import 'dart:ui';

import 'package:flutter/material.dart';

// 一部パッケージのソースコードを修正 line 38,39 ※詳細は前編記事をご参照
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> {
// ここまではお決まりのコード

  // 検索ワードを入れるプロパティ。初期値は""にしておく
  // ※自作コードで検索する場合はstaticにする
  String searchWordO = "";

  // 検索ワードとマッチしたテキスト本文の位置(最初の文字の位置)を格納するリスト **
  List<int> searchMatchIndexO = [];

  // インポートしたパッケージのRichTextControllerクラス
  // (テキスト本文に設定するTextEditingControllerの拡張クラス)のインスタンスを定義
  // 後で具体的内容を入れるため、lateで宣言
  late RichTextController mainTextControllerO;

  // テキスト本文のTextFieldのカーソル位置を保存するプロパティ **
  // 初期値は0(TextFieldの先頭)とする **
  int textCursorPositionO = 0;

  // 検索ワードを入力するTextFieldに設置する
  // TextEditingControllerクラスのインスタンスを作成
  TextEditingController searchWordControllerO = TextEditingController();

  // テキスト本文のTextFieldのスクロールを管理するため、**
  // ScrollControllerクラスのインスタンスを作成 **
  ScrollController scrollControllerO = ScrollController();

  // テキスト本文のTextFieldの高さ
  double textFieldHeightO = 200;

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

  // 検索ワードの検出数 **
  int searchMatchNumberO = 0;

  @override
  void initState() {
    // mainTextControllerOを初期設定
    // テキスト本文で、searchWordOを含む部分を赤背景・白文字になるよう設定
    mainTextControllerO = RichTextController(
      // stringMap:も設定できるが、完全一致が条件となるため、
      // ここでは正規表現での一致判定が可能なpatternMap:を使用
      patternMap: {
        RegExp("$searchWordO"): TextStyle(
          color: Colors.white,
          backgroundColor: Colors.redAccent,
        ),
      },
    );
    super.initState();
  }

  @override
  void dispose() {
    // 画面遷移時やアプリ終了時にリソースを開放するため
    // 3つのcontrollerをdispose処理 **
    mainTextControllerO.dispose();
    searchWordControllerO.dispose();
    scrollControllerO.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) {

                  // 検索マッチした位置を格納するリスト・マッチ数のプロパティを初期化 **
                  searchMatchIndexO = [];
                  searchMatchNumberO = 0;

                  // 検索マッチ位置の色変更を反映するため、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;

                    // 検索ワードのマッチ位置(文字数位置)をリストに格納するメソッド **
                    _getSearchMatchIndexO();

                  });
                },
              ),
            ),
          ],
        ),
      ),

      // テキスト本文を入力するTextField部分
      // キーボード出現時の画面はみ出しエラーを防ぐため、SingleChildScrollViewでラップ
      body: SingleChildScrollView(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.start,
          children: [
            Text("↓テキスト本文"),

            Container(
              // TextFieldに高さを設定
              height: textFieldHeightO,

              // 背景色には、primaryColor(デフォルト青)の明るい色を設定
              // ※Containerの方だけに色を付けるとカーソルが一番左端からになってしまう
              //  一方、InputDecorationだけに色を付けると未入力ゾーンに色がつかない
              //  そのため、ContainerとInputDecorationの両方に色を付ける
              color: Theme.of(context).primaryColorLight,
              child: Scrollbar(
                child: TextField(

                  // TextEditingControllerの設置
                  controller: mainTextControllerO,

                  // ScrollControllerの設置 **
                  scrollController: scrollControllerO,

                  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内をタップした所のカーソル位置を保存 **
                  // ※検索マッチ位置に移動する際に、現在のカーソル位置を把握しておく必要があるため必要 **
                  onTap: () {
                    textCursorPositionO = mainTextControllerO.selection.start;
                  },

                  // テキスト本文に変更があったら、検索マッチ箇所のハイライトを更新するため、setStateで画面を再描画 **
                  // かつ、検索マッチ位置を格納するリスト・マッチ数のプロパティを初期化し、マッチ位置保存メソッドを実行 **
                  onChanged: (valueO) {
                    searchMatchIndexO = [];
                    searchMatchNumberO = 0;

                    setState(() {

                      // 検索ワードのマッチ位置をリストに格納するメソッド **
                      _getSearchMatchIndexO();

                    });
                  },
                ),
              ),
            ),


            // 以降のコードは、全て前編記事のコードに追加された部分 **

            // 検索マッチ箇所にカーソル移動とTextFieldのスクロールをするためのボタン設置
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[

                // 左回りのカーソル移動ボタン
                IconButton(
                  icon: Icon(Icons.arrow_left),

                  // ボタン押下時の処理
                  onPressed: () {
                    // 検索マッチ数がゼロの場合は早期リターン(何もしない)
                    if (searchMatchIndexO.length == 0) return;

                    // カーソル位置の変更を再描画するためsetStateで括る
                    setState(() {

                      // ケース①
                      // 現在のカーソル位置が、1つめの検索マッチ位置から左側にあるときは、
                      // 1週させて最後の検索マッチ位置にカーソルを移動
                      if (textCursorPositionO <= searchMatchIndexO[0]) {
                        // 1週して末尾に移動
                        mainTextControllerO.selection =
                            TextSelection.fromPosition(
                              TextPosition(offset: searchMatchIndexO[searchMatchIndexO.length - 1]),
                            );

                        // 現在のカーソル位置を保存するプロパティを更新
                        textCursorPositionO =
                            mainTextControllerO.selection.start;

                        // ケース②
                        // 現在のカーソル位置が、最後の検索マッチ位置より右側にあるときは、
                        // 最後の検索マッチ位置にカーソルを移動
                      } else if (textCursorPositionO >
                          searchMatchIndexO[searchMatchIndexO.length - 1]) {
                        mainTextControllerO.selection =
                            TextSelection.fromPosition(
                              TextPosition(offset: searchMatchIndexO[searchMatchIndexO.length - 1]),
                            );
                        textCursorPositionO =
                            mainTextControllerO.selection.start;

                        // ケース③
                        // 上記以外のケースは、forループを使って、
                        // 現在のカーソル位置が、2つの検索マッチ位置で挟まれている場所を探し、
                        // 見つかったらその左側の検索マッチ位置にカーソルを移動
                      } else {
                        // ループの上限回数は、検索マッチ数-1になる
                        for (int iO = 0; iO < searchMatchIndexO.length - 1; iO++) {
                          if (searchMatchIndexO[iO] < textCursorPositionO &&
                              searchMatchIndexO[iO + 1] >= textCursorPositionO) {
                            mainTextControllerO.selection =
                                TextSelection.fromPosition(
                                 TextPosition(offset: searchMatchIndexO[iO]),
                                );
                          }
                        }

                        // 現在のカーソル位置を保存するプロパティを更新
                        textCursorPositionO =
                            mainTextControllerO.selection.start;
                      }

                      // カーソル移動に合わせて、TextFieldをスクロールさせるメソッドを実行
                      _cursorMoveAndScrollO();

                    });
                  },
                ),

                // 右回りのカーソル移動ボタン
                IconButton(
                  icon: Icon(Icons.arrow_right),

                  // ボタン押下時の処理
                  onPressed: () {
                    // 検索マッチ数がゼロの場合は早期リターン(何もしない)
                    if (searchMatchIndexO.length == 0) return;

                    setState(() {

                      // ケース①
                      // 現在のカーソル位置が、最後の検索マッチ位置から右側にあるときは、
                      // 1週させて1つめの検索マッチ位置にカーソル位置を移動
                      if (textCursorPositionO >=
                          searchMatchIndexO[searchMatchIndexO.length - 1]) {
                        mainTextControllerO.selection =
                            TextSelection.fromPosition(
                              TextPosition(offset: searchMatchIndexO[0]),
                            );
                        textCursorPositionO =
                            mainTextControllerO.selection.start;

                        // ケース②
                        // 現在のカーソル位置が、1つめの検索マッチ位置より左側にあるときは、
                        // 1つめの検索マッチ位置にカーソルを移動
                      } else if (textCursorPositionO < searchMatchIndexO[0]) {
                        mainTextControllerO.selection =
                            TextSelection.fromPosition(
                              TextPosition(offset: searchMatchIndexO[0]),
                            );
                        textCursorPositionO =
                            mainTextControllerO.selection.start;

                        // ケース③
                        // 上記以外のケースは、forループを使って、
                        // 現在のカーソル位置が、2つの検索マッチ位置で挟まれている場所を探し、
                        // 見つかったら右側の検索マッチ位置にカーソルを移動
                      } else {
                        // ループの上限回数は、検索マッチ数-1 ※-1しないと[iO+1]が配列数を超え、エラーになる
                        for (int iO = 0; iO < searchMatchIndexO.length - 1; iO++) {
                          if (searchMatchIndexO[iO] <= textCursorPositionO &&
                              searchMatchIndexO[iO + 1] > textCursorPositionO) {
                            mainTextControllerO.selection =
                                TextSelection.collapsed(offset: searchMatchIndexO[iO + 1]);
                          }
                        }

                        // 現在のカーソル位置を保存するプロパティを更新
                        // ※必ずforループの後に記載(そうしないとループが終了しなくなる)
                        textCursorPositionO =
                            mainTextControllerO.selection.start;
                      }

                      // カーソル移動に合わせて、TextFieldをスクロールさせるメソッドを実行
                      _cursorMoveAndScrollO();

                    });
                  },
                ),
              ],
            ),

            // 検索マッチ数と、カーソル移動後の検索マッチ位置を表示
            Text("検索マッチ数 $searchMatchNumberO / ${searchMatchIndexO.length}"),
          ],
        ),
      ),
    );
  }


  /// 検索ワードのマッチ箇所をリストに格納するメソッド
  void _getSearchMatchIndexO() {

    // テキスト本文の内容を、検索対象の文字列として別プロパティに保管
    String mainTextForSearchO = mainTextControllerO.text;

    // テキスト本文が1文字以上 かつ 検索ワードが1文字以上
    // かつ テキスト本文に検索ワードが1回以上含まれる場合(※)のみ、
    // マッチしたテキスト本文の位置をリストに格納する
    // ※1回も含まれないときは、indexOfの結果は-1になる
    if (mainTextForSearchO.length > 0 &&
        searchWordO.length > 0 &&
        mainTextForSearchO.indexOf(searchWordO) >= 0
    ) {

      // 検索ワードとマッチした最初の1文字目の位置を一時的に保存するプロパティ
      // 初期値は0にしておく
      int subStringStartO = 0;

      // do whileループを抜けるか否か判定するフラグ
      bool enableToGetIndexO = true;

      // テキスト本文の文字検索対象部分を徐々に短くしていき、
      // 検索ワードがマッチしなくなった時点でdo whileループを抜ける
      // ※上のif文の判定により、必ず1つ以上はマッチする
      do {

        // 検索ワードがマッチした最初の1文字目の位置を代入(前回のマッチ位置に加算する形で算出)
        subStringStartO = subStringStartO +
            mainTextForSearchO.indexOf(searchWordO);

        // 上記位置をリスト変数に格納
        searchMatchIndexO.add(subStringStartO);

        // 次の文字検索対象部分の開始位置を設定するため、検索ワードの文字数分だけ加算
        subStringStartO = subStringStartO + searchWordO.length;

        // 上記加算後の開始位置(subStringStartO)が、
        // テキスト本文全体の文字数を超えない場合はループを継続
        if (mainTextControllerO.text.length > subStringStartO) {

          // 検索対象のテキスト本文を、上記開始位置以降になるよう
          // substringで切り出して再設定
          mainTextForSearchO = mainTextControllerO.text
              .substring(subStringStartO);

          // 切り出し後の検索対象部分に、検索ワードがない場合は、
          // フラグを変更してループを抜ける
          (mainTextForSearchO.indexOf(searchWordO) >= 0)
              ? enableToGetIndexO = true
              : enableToGetIndexO = false;


          // 上記加算後の開始位置(subStringStartO)が、
          // テキスト本文全体の文字数を超える場合は、フラグを変更してループを抜ける
        } else {
          enableToGetIndexO = false;
        }

        // フラグがtrueの限り、ループを回す
      } while (enableToGetIndexO);

      // 確認用に検索マッチした箇所を表示
      print("マッチした箇所 $searchMatchIndexO");
    }
  }


  /// カーソル移動に合わせて、TextFieldをスクロールさせるメソッド
  void _cursorMoveAndScrollO() {

    // TextField(テキスト本文)の先頭から現在のカーソル位置までをTextSpan型で抜き出す
    var textSpanO = TextSpan(
        text: mainTextControllerO.text.substring(0, textCursorPositionO));

    // 上記TextSpanに対して、TextPainterクラスとLineMetricsクラスを用いて
    // 画面の横幅(左右のパディングを考慮)に応じた行数を計算
    // TextDirection.ltrは文字を読む方向が左→右という意味
    var textPainterO = TextPainter(
        text: textSpanO, textDirection: TextDirection.ltr);
    textPainterO.layout(
        maxWidth: MediaQuery.of(context).size.width - paddingO * 2);
    List<LineMetrics> linesO = textPainterO.computeLineMetrics();
    int numberOfLines = linesO.length;

    // 確認のため行数を表示
    print("行数 $numberOfLines");

    // 1行の高さ(フォントサイズ×係数) × 行数  から、TextFieldをスクロールさせる位置を算出
    // ※ここで言うスクロールさせる位置とは、Widget内の位置(pixelベースのオフセット値)を指す
    // 但し、スクロール後の位置がTextFieldの縦幅の中央にくるように、計算したスクロール位置から
    // TextFieldの縦幅の半分だけスクロール位置をずらす
    var scrolledPositionO =
        Theme.of(context).primaryTextTheme.bodyText1!.fontSize! * 1.5 *
            (numberOfLines - 1) -
            textFieldHeightO / 2;

    // 計算したスクロール位置が0より小さくなる(TextFieldの上端より上)場合は、
    // 0(上端)で止める
    if (scrolledPositionO < 0) {
      scrolledPositionO = 0;
    }

    // ScrollControllerに上記で計算したスクロール位置を設定し、スクロールを実行
    // ※jumpToメソッドでも移動できるが、animateToメソッドの方がcurveプロパティで移動の挙動を設定できるので便利
    scrollControllerO.animateTo(
      scrolledPositionO,
      duration: Duration(milliseconds: 1),
      curve: Curves.easeOut,
    );

    // 何番目の検索マッチ位置に移動したかを表示するため、
    // マッチ位置を格納しているリストから位置情報を把握(配列番号+1にする必要あり)
    searchMatchNumberO = searchMatchIndexO.indexOf(textCursorPositionO) + 1;
  }

}

 

「main.dart」にそのままコピペいただけば動くようにしてあります。そのため、あえて1つのdartファイルに全て入れ込んでいます。

 

自分のような初心者の方向けに、できるだけコード中に説明のコメントを付しています。

 

検索マッチした箇所をハイライトする方法については、前編の記事で2通りの方法を掲載しましたが、本記事(後編)では、コードが短くて済む「rich_text_controller」パッケージを使ったケースを採用しています。

 

※前編部分の詳細については、こちらの記事をご参照ください。

 

 

246行目までは、前編に追加した箇所のコメント末尾に「**」をつけています。また、247行目以降は、全て前半記事に追加したコードになります。

 

※前編のコードと比較されたい場合は、お手数ですが、下記サイトなどでコード比較をしてチェックいただければ幸いです。

 

 

なお、今回のパート(後編)では特に外部パッケージのインポートは必要ありません。

 

特筆すべきポイントについては、以降の項目でご説明します。

  

スポンサーリンク

カーソル移動処理のポイント:indexOfメソッドを使う

まず、テキスト本文中で、検索ワードとマッチした最初の1文字目の位置(文字数値)を取得します。

 

具体的には、indexOfメソッドを、

 

mainTextForSearchO.indexOf(searchWordO)

※mainTextForSearchO:テキスト本文の文字列変数

※searchWordO:検索ワードの文字列変数

※名称変更可能な自作のメソッド名・プロパティ名には末尾に「O」をつけています

 

のように使うことで、文字列変数mainTextForSearchO内で、文字列変数searchWordOが出てくる最初の1文字目の位置(文字数の番目)を取得できます。

 

 

検索文字列を含まない場合は、indexOfメソッドは「-1」を返すことになっています。そのため、indexOfメソッドは、検索文字列を含んでいるかどうかの判断にも利用できます

 

次に、上記indexOfメソッドで取得した位置+検索ワードの文字数の部分を取り除いた残りのテキスト本文に対して、同様にindexOfメソッドを実行し、順次、マッチした最初の1文字目の位置を配列変数にストックしていきます。

 

 

次に、◀▶ボタン押下時に、現在のカーソル位置と、配列にストックされたマッチ位置を比較し、カーソル位置を移動させます。

 

例えば、左回りの場合(◀ボタン押下時)、現在のカーソル位置に応じて以下3パターンに対応が分かれます。

※右回りの場合(▶ボタン押下時)も考え方は同じです。

 

  • ケース①
    現在のカーソル位置が、1つめの検索マッチ位置から左側にあるときは、1週させて最後の検索マッチ位置にカーソルを移動
  • ケース②
    現在のカーソル位置が、最後の検索マッチ位置より右側にあるときは、最後の検索マッチ位置にカーソルを移動
  • ケース③
    ①②以外の場合は、forループを使って、現在のカーソル位置が、2つの検索マッチ位置で挟まれている場所を探し、見つかったらその左側の検索マッチ位置にカーソルを移動

 

左回りのときの例

 

なお、TextFieldのカーソル位置は、以下のメソッドで強制的に変更することができます。

 

TextEditingControllerのインスタンス.selection = TextSelection.fromPosition(TextPosition(offset: 文字数位置),)

 

スポンサーリンク

自動スクロール処理のポイント

前述のカーソル移動処理により、TextFieldの表示範囲外にカーソルが移った場合、移動先が見えない状態になるので、カーソルの移動とともに、TextFieldをスクロールさせる処理が必要になります。

 

カーソル移動しただけでは、TextField内がスクロールしてくれない。。。

 

※ただ、環境によっては、前述のカーソル移動処理だけでも、自動的にスクロールしてくれるかもしれません。自分も過去、カーソル移動処理だけでスクロールが自動的に行われていたのですが、新しいFlutterのバージョン下ではスクロールしなくなってしまったので、この処理を追加しました。

 

TextPainterクラスとLineMetricsクラスを用いて、TextField内の行数を把握

スクロール自体は、ScrollControllerのインスタンスを使って制御可能なのですが、スクロール先の場所(Widget内の相対的な位置(オフセット値))を、pixel単位で指定する必要があるため、その計算をどうするか悩みました。。

 

検討の結果、検索マッチ位置に移動した後のカーソル位置が、TextField内の何行目にあるか調べ、

1行の高さ✕行数

から、スクロール先の場所(pixel値)を求めることにしました。

 

改行の数から行数を求められれば楽ですが、実際には、文字数が画面の横幅を超えることで次の行に行くこともあり、そう単純ではありません。

 

端末の画面サイズにも依存することになります。

 

そのため、TextPainterクラスLineMetricsクラスを用いて計算をします。

 

この方法は、下記情報を参考にさせていただきました。ありがとうございます!

 

 

まず、TextFieldの先頭から移動後のカーソル位置までの文字列をTextSpanで切り出し、それを用いてTextPainterクラスのインスタンスを作成します。

 

var textSpanO = TextSpan(text: mainTextControllerO.text.substring(0, textCursorPositionO));
var textPainterO = TextPainter(text: textSpanO, textDirection: TextDirection.ltr);

 

 

TextPainterクラスは、TextSpanの持つ文字列を、画面描画するのに必要な情報を保持するクラスだと理解しています。

 

次に、作成したTextPainterのインスタンスに、layoutメソッドを用いて、画面の横幅に応じた、文字の視覚的な配置を設定します。

 

textPainterO.layout(maxWidth: MediaQuery.of(context).size.width - paddingO * 2);

 

layoutメソッドの説明によると、文字列は、引数に設定した「minWidth」の値以上、かつ「maxWidth」の値以下の範囲で、可能な限り最大固有幅に近い幅でレイアウトされるということです。

 

ここではmaxWidthのみ設定することとし、MedioQueryのofメソッドで取得した画面の横幅から、左右に設定したパディングを引いた幅としています。

 

最後に、computeLineMetricsメソッドを用いて、1行ごとの描画に関する様々なパラメーターがセットになったLineMetrics型のデータを、リスト型(配列変数)で取得します。

 

この配列変数の各要素は、1行ごとのデータになるため、配列のデータ数(長さ)を取得すれば、行数を得ることができます。

 

 List<LineMetrics> linesO = textPainterO.computeLineMetrics();
int numberOfLines = linesO.length;

 

ScrollControllerのanimateToメソッドを使って、指定したTextField内のY座標位置へ移動

上記で計算した行数と、1行の高さから、移動させたい先のTextField内でのY座標位置(※)を計算します。

(※)TextField内の縦方向の相対的位置(オフセット値)をpixel値で表したもの

 

 

但し、単純に「行数✕1行あたりの高さ」の場所に移動させると、毎回、移動後のカーソルがTextFieldの上端に表示されてしまい、やや違和感のある挙動になります。

 

また、実際には「(行数−1✕1行の高さ」にしないと、同じ行内でカーソル移動が発生した場合も1行分のスクロールが生じ、カーソルが見えなくなってしまうので、注意が必要です。

 

そのため、下記のように、「(行数−1)✕1行あたりの高さ」からTextFieldの縦幅の2分の1の長さを引くことで、移動後のカーソルが常にTextFieldの中央に表示されるようにしました。

※この辺は好みの問題なので、適宜設定すれば良いと思います。

 

var scrolledPositionO =
   Theme.of(context).primaryTextTheme.bodyText1!.fontSize! * 1.5 *
   (numberOfLines - 1) - textFieldHeightO / 2;

※「Theme.of(context).primaryTextTheme.bodyText1!.fontSize!」は、Flutterで標準設定されている「bodyText1」というスタイルのフォントサイズを指しています。

※1.5は、TextFieldのstrutStyle属性に設定した行間係数(フォントサイズに対する倍数値)です。

 
最後に、スクロールを管理するScrollControllerクラスのインスタンスを作り、TextFieldのscrollController属性に設置し、animateToメソッドを使って、上記で算出した位置へ強制的にスクロールさせます。

 

class _HomeScreenOState extends State<homeScreenO> {
 
ScrollController scrollControllerO = ScrollController();
 
//・・・(略)・・・    
 
TextField(
 scrollController: scrollControllerO,
 
//・・・(略)・・・
 
),
 
//・・・(略)・・・
 
void _cursorMoveAndScrollO() {
 
//・・・(略)・・・
 
    scrollControllerO.animateTo(
      scrolledPositionO,
      duration: Duration(milliseconds: 1),
      curve: Curves.easeOut,
    );
 
//・・・(略)・・・
 
}

 

jumpToメソッドでもスクロールできますが、細かい設定ができず、挙動に違和感があったため、animateToメソッドにしました。

 

animateToメソッドには、curveプロパティがあり、スクロール時の挙動(アニメーション)を設定できます。

 

今回の例では、シンプルな動きになる「Curves.easeOut」を採用しています。

 


 

以上にて、TextFieldへの文字検索機能の実装コード完成となります。

 

TextField内の行数を求める部分が難しかったですが、今回の方法を理解したことで、色々な応用が効きそうです。

 

経験不足ゆえ、効率の悪いコードになっているかと思いますので、おかしい点は是非ご指摘いただけると幸いですm(_ _)m

 

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

 

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

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

 

 


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

コメント

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