Flutter TextFieldにメモ帳のような横罫線を付ける方法(無限の行数・縦スクロールに柔軟に対応)

Flutter

メモアプリを作る際、TextFieldに横罫線を表示する実装をしたので、その方法を共有したいと思います。

 

以下の記事を参考にさせていただきました。

 

 

調べた限り、FlutterのTextFieldに横罫線を表示させる方法を解説しているのはこの記事ぐらいだったので、大変助かりました。ありがとうございます!

 

ただ、こちらの記事は、1行分の罫線の高さを算出するために、TextFieldの最大行数を事前に決めるという方法でした。

 

自分の場合、TextFieldの行数に制限を設けたくなかったので、その部分を試行錯誤して修正しました。

 

また、その他にも、改行時に文字と横罫線がズレないようにするため、様々な処理を追加しています。

 

結果、TextFieldを縦スクロールさせて、行数が無限に増えても横罫線が表示され続ける状況を実現できたので、その内容を共有します(自分のリリースしたメモアプリでもこの方法を採用しています)。

 

要点は以下のとおりです。

 

  • フォントサイズから横罫線の間隔(1行の高さ)を決定する
  • 画面上に表示するTextFieldの範囲(縦幅)から、横罫線の描画本数を算出する
  • GlobalKeyとFocusNodeの2つを使って縦スクロール幅を把握することで、スクロールする度に、追加で横罫線を引く
  • 改行したときに横罫線と文字の関係がズレないように、strutStyleによる1行の高さ設定と、InputDecorationによるisDenseの設定をする

 

よろしければ、ご参考にしてください。

 


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

 

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

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

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

 

スポンサーリンク

最終的なアウトプット(実現したい状況)

 

上図のように、改行してTextFieldがスクロールすると、横罫線もスクロールし、かつ横罫線が途絶えること無く下から表示され続けます。

 

特にスクロール範囲に制限は設けていないので、無限にスクロースしてもこの状況が維持されます。

 

コードの全体像

以下に全コードを掲載します。

 

できるだけ各コード部分にコメントで説明を入れています。 

 

クラス名、メソッド名、プロパティ名(変数名)は、ライブラリ(パッケージ)で予め決められているもの(名前の変更不可のもの)と、自分で作成したもの(名前の変更可のもの)の区別がつきやすいよう、

 

後者(自分で作成したもの)の名前の末尾には、大文字のオー「O」(Originalの略のつもりです)

 

を付けていますので、ご了承ください。

 

※自分がサンプルコードを参考にする際、元々ライブラリに規定されている名前なのか、自作した名前なのか、が分かりやすいと助かるので、そうしてみました。

 

import 'package:flutter/material.dart';

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

// Text Widgetの位置把握のためGlobalKey型のグローバル変数を定義
GlobalKey globalKeyTextWidgetO = GlobalKey();

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

class SampleScreenO extends StatefulWidget {
  @override
  _SampleScreenOState createState() => _SampleScreenOState();
}

class _SampleScreenOState extends State<SampleScreenO> {

  // Text Widgetの2次元情報を入れるためのRenderBox型の変数を定義
  // null safety対応でlateをつける
  late RenderBox textWidgetO;

  // Text WidgetのY座標開始位置と高さ(縦幅)を入れる変数を定義
  // 初回ビルド時のnullエラーを防ぐため、初期値を設定しておく
  double textWidgetDyO = 0;
  double textWidgetHeightO = 0;

  // 画面全体のパディング、フォントサイズ、1行の高さを決める係数を設定
  static const double standardPaddingO = 8.0;
  static const double fontSizeO = 18.0;
  static const double lineSpaceO = 1.2;

  // TextField上にデフォルトで設定される上部パディングの変更値。ここでは0に設定
  static const double contentPaddingO = 0.0;

  // TextFieldに設置するFocusNodeのインスタンス作成
  FocusNode focusNodeTextFieldO = FocusNode();

  // 初期値設定等を行うinitStateメソッドを導入
  @override
  void initState() {

    // Text Widgetが描画された後でないとGlobalKeyの値が取得できずエラーになるため、
    // buildメソッド完了後にGlobalKeyの値取得が行われるよう、
    // addPostFrameCallbackメソッドを使用
    // null safety対応で?(null以外のみアクセス)をつける
    WidgetsBinding.instance?.addPostFrameCallback((cb) {

      // GlobalKeyを通じてText Widgetの2次元情報を取得
      // null safety対応で?と最後にas RenderBoxをつける
      textWidgetO = globalKeyTextWidgetO.currentContext?.findRenderObject() as RenderBox;

      // 2次元情報からText Widgetの縦方向の上端位置(Y座標)と高さ(縦幅)を取得
      textWidgetDyO = textWidgetO.localToGlobal(Offset.zero).dy;
      textWidgetHeightO = textWidgetO.size.height;

      // 確認のため、取得した位置と高さをDebugウィンドウに表示
      print("TextWidgetの上端位置 $textWidgetDyO、TextWidgetの高さ $textWidgetHeightO");

      // Text Widgetの位置と高さを取得後、setStateメソッドで全体を再描画する
      setState(() {});
    });

    super.initState();
  }

  @override
  Widget build(BuildContext context) {

    // 画面全体の縦幅と横幅を取得するためのMediaQuery.ofメソッド
    // ※画面の縦幅・横幅は変化しないためfinalで宣言
    final screenHeightO = MediaQuery.of(context).size.height;
    final screenWidthO = MediaQuery.of(context).size.width;

    // 画面の下端位置(キーボード出現時はキーボードの上端位置)を取得するためのMediaQuery.ofメソッド
    var textFieldBottomO = MediaQuery.of(context).viewInsets.bottom;

    // TextFieldの縦幅を柔軟に設定する計算式
    // 画面全体に設定している下部パディングも引く必要あり
    var textFieldVerticalRangeO=
        screenHeightO
            - (textWidgetDyO + textWidgetHeightO)
            - textFieldBottomO
            - standardPaddingO;

    return Scaffold(
      appBar: AppBar(
        title: Text("TestApp"),
      ),
      body: Padding(
        padding: const EdgeInsets.all(standardPaddingO),
          child: Column(
            children: [
              Text(
                "↓TextField",

                // GlobalKeyをText Widgetのkeyプロパティに設定
                key: globalKeyTextWidgetO,

              ),
              Container(

                // 分かりやすくするために枠線を付けるが、内側に自動で1.0のパディングが発生する点に注意
                decoration: BoxDecoration(
                  border: Border.all(color: Colors.grey),
                ),

                // 初回描画時点では、GlobalKey未取得のため、計算式から高さを求めると
                // 画面サイズオーバーのエラーになる。これを回避するため、GlobalKeyの取得が
                // 未了の段階(=初回描画時点)では高さを0.0にしておく
                height: (textWidgetDyO == 0)
                    ? 0.0
                    : textFieldVerticalRangeO,

                  child: Scrollbar(

                    // スクロール時に罫線も動かすために、SingleChildScrollViewが必要
                    child: SingleChildScrollView(

                      // Stackを使って、罫線を引くCustomPaintの上に、TextFieldを重ね表示する
                      child: Stack(
                        children: <Widget>[

                          // 描画するCustomPaintクラスの呼び出し
                          CustomPaint(

                            // painter属性では、外部に作成したクラスに処理を飛ばす
                            // 画面の座標・幅情報を引数として渡す
                            painter: DisplayRuledLinesO(
                              textFieldVerticalRangeO: textFieldVerticalRangeO,
                              screenWidthO: screenWidthO,
                              focusNodeTextFieldO: focusNodeTextFieldO,
                              textWidgetDyO: textWidgetDyO,
                              textWidgetHeightO: textWidgetHeightO,
                            ),
                          ),

                          TextField(

                            // FocusNodeのインスタンスを設置
                            focusNode: focusNodeTextFieldO,

                            // フォントサイズを設定
                            style: TextStyle(fontSize: fontSizeO),

                            strutStyle: const StrutStyle(

                              // 1行の高さをフォントサイズに対する倍数値で設定
                              height: lineSpaceO,

                              // 改行とともにズレるのを防ぐため、行間強制を設定
                              forceStrutHeight: true,

                            ),

                            autofocus: true,

                            decoration: InputDecoration(

                              // TextFieldの上端から書き始められるように設定
                              isDense: true,

                              border: InputBorder.none,

                              // デフォルトの上部パディング(12.0)を変更するために設定
                              contentPadding:
                              const EdgeInsets.only(top: contentPaddingO),
                            ),

                            keyboardType: TextInputType.multiline,

                            // 行数は上限なしで設定
                            maxLines: null,
                          ),
                        ],
                      ),
                    ),
                  ),
              ),
            ],
          ),
      ),
    );
  }
}


// 横罫線の描画処理をするCustomPainterの拡張クラス
class DisplayRuledLinesO extends CustomPainter {

  final double textFieldVerticalRangeO;
  final double screenWidthO;
  final FocusNode focusNodeTextFieldO;
  final double textWidgetDyO;
  final double textWidgetHeightO;

  // 引数から初期値を受け取るコンストラクタ
  DisplayRuledLinesO({
    required this.textFieldVerticalRangeO,
    required this.screenWidthO,
    required this.focusNodeTextFieldO,
    required this.textWidgetDyO,
    required this.textWidgetHeightO,
  });



  @override
  void paint(Canvas canvas, Size size) {

    // 罫線を描画する範囲(高さ)を計算
    // 見えているTextFieldの高さ(キーボードの出現有無で可変)に、スクロールで上に動いた分を加算
    // これにより、スクロールとともに追加で罫線を描画し、罫線が無くなることを防ぐ
    final drawingVerticalRangeO =

        // TextFieldの縦幅
        textFieldVerticalRangeO
        + (
            // GlobalKeyのY座標位置を加算
            // Containerに枠線設定をすることで発生する上部パディング1.0を加える
            // ※枠線を無くしたときは、この「+1.0」は削除が必要
            // さらに、TextField上部のデフォルトのパディングを変更した値が、0より大きいときは、その加算も必要
            (textWidgetDyO + textWidgetHeightO + 1.0 + _SampleScreenOState.contentPaddingO)

            // FocusNodeのY座標位置(スクロールによる移動をトレースしたTextField上端位置)を減算
            - focusNodeTextFieldO.offset.dy
        );

    // 確認のため、Debugウィンドウに表示
    print("TextFieldの縦幅: $textFieldVerticalRangeO");
    print("GlobalKeyの位置: ${textWidgetDyO + textWidgetHeightO + 1.0 + _SampleScreenOState.contentPaddingO}");
    print("FocusNodeの位置: ${focusNodeTextFieldO.offset.dy}");

    // フォントサイズ×行間係数(strutStyleで規定)で1行の高さを算出し(ズレの蓄積を防ぐため切上げして整数化する)、
    // 上で求めた描画範囲(高さ)を割って、描画本数を計算する(切捨て)
    final ruledLineSpaceO = (_SampleScreenOState.fontSizeO * _SampleScreenOState.lineSpaceO).ceil();
    print("ruledLineSpaceO: $ruledLineSpaceO");
    final int ruledLineNumberO = (drawingVerticalRangeO / ruledLineSpaceO).floor();
    print("ruledLineNumberO: $ruledLineNumberO");

    // 罫線の横幅を計算
    // MediaQueryで把握済のTextFieldの幅から、左右に設定したパディングを引いて計算
    final ruledLineWidthO = screenWidthO - _SampleScreenOState.standardPaddingO * 2;

    // 罫線の描画
    final paint = Paint()
      ..color = Colors.grey
      ..strokeWidth = 0.7;
    for (var i = 1; i <= ruledLineNumberO; i++) {
      canvas.drawLine(

        // Offset(x, y)からOffset(x', y')へ線を引き、それを「ruledLineNumberO」回繰り返す
        // TextField上部のデフォルトのパディングを変更した値が、0より大きいときは、Y座標に加算する必要あり
        Offset(0, ruledLineSpaceO * i + _SampleScreenOState.contentPaddingO),
        Offset(ruledLineWidthO, ruledLineSpaceO * i + _SampleScreenOState.contentPaddingO),
        paint,
      );
    }
  }

  // 一度描画した横罫線の再描画は不要なため、false
  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;

}

 

以降に、ポイントとなる箇所について詳細を説明します。

 

横罫線を引く前の状態について

TextFieldは、キーボードが出現すると、画面の縦幅に応じてTextFieldの縦幅が縮小し、キーボードに隠れないようにする処理を入れており、この状態(主にコード122行目ぐらいまで)を出発点にして、横罫線を引く処理を加えていきます。

 

TextFieldの縦幅調整については、下記記事で詳細説明をしていますので、ご参考にして下さい。

 

 

Stackクラスを使い、横罫線の上にTextFieldを乗せる

コードの124〜145行目あたりです。

 

横罫線は、CustomPaintクラスを使って描画し、その上に重ねるようにしてTextFieldを配置します。

 

painterプロパティでは、DisplayRuledLinesOクラスに処理を飛ばしています。

 

描画範囲を決定するために必要な、以下のプロパティを引数として渡しています。

 

  • textFieldVerticalRangeO: TextFieldを表示する縦幅
  • screenWidthO: 端末画面の横幅
  • focusNodeTextFieldO: TextFieldに設置したFocusNode ※FocusNodeについては後述
  • textWidgetDyO: TextFieldの上部にある「↓TextField」を表示するTextウィジェット上端のY座標値
  • textWidgetHeightO: 「↓TextField」を表示するTextウィジェットの縦幅

 

また、Stackクラスは、SingleChildScrollViewでラップしておきます。これは、TextFieldが縦スクロールしたときに、横罫線も一緒に動くようにするために必要になります。

 

TextFieldの各種プロパティを設定する

 

FocusNodeの設置

コードの44行目、148行目です。

 

FocusNodeは、TextFieldのフォーカスの状態をコントロールするときに使うクラスですが、TextFieldの座標情報を取得する際も使えます。

 

具体的には、TextFieldに設置したFocusNodeのインスタンスに、「.offset.dy」を付けると、TextField上端のY座標が取得できます。

 

ただ、このY座標は、上端位置の移動がトレースされた値になるので、下図のように縦スクロールが発生したときは、隠れて画面上では見えない部分の上端位置になります(後で、この特徴を利用して、スクロール幅を取得します)。

 

 

★注意点

今回のコードでは、Containerにdecoration属性をつけて、枠線を付けていますが、枠線を付けると自動的にContainerの内側に、1.0pixelのパディングが設定されるようです。

 

Dev Toolの画面

 

そのため、FocusNodeで取得できるY座標位置は、このパディング分だけ下にずれた位置になります。

 

1行の高さを設定

コードの156行目です。

 

横罫線を描画するCustomPaintと、文字入力をするTextFieldは、あくまでStackで重ねて表示しているだけなので、文字入力時の行間が一定していないと横罫線とのズレが生じてしまいます。

 

これを避けるために、strutStyle属性で、1行の高さ(行間)を厳密に設定しています。strutStyle属性では、フォントサイズに対する倍数で設定します。

 

strutStyle属性の中で、行間強制を設定

コードの159行目です。

 

TextFieldに設定したフォントサイズに対し、上で設定した1行の高さ(行間)が十分大きい数値になっていないと、下図のように改行とともにズレを生じてしまいます

 

 

※半角文字に合わせて設定した場合、絵文字や全角文字はサイズが大きいため、1行の高さ(行間)が不十分になりがちです。

 

そのため、strutStyle属性の中で、行間を強制的に保持する「forceStrutHeight: true」を設定します。

 

これにより、仮に1行の高さを決める係数がフォントサイズに対して不十分だったとしても、自動的に位置が調整され、改行時のズレを防止してくれます。

 

※但し、係数が1.0より小さい場合はズレが生じるので、必ず1.0以上にする必要があります

 

isDenseの設定

コードの168行目です。

 

decoration属性に、InputDecorationクラスを設置し、inDense属性をtrueにします。

 

isDense属性は、TextFieldの縦方向のスペースを詰めるか否かを設定する属性です。

 

デフォルトではfalseになっており、これをtrueにしないと、TextFieldの上端から書き始めることができません。2回ほど改行すると、書いた文字が上に移動して、ようやく上端に表示されます。

 

isDense: true を設定しなかったときの挙動

 

理由は不明ですが、TextFieldは、後述するデフォルトの上部パディングの有無によらず、最初の1行目は上端から少し下の位置から書き始まるようになっており、改行して2行目に行くと、その前に書いた1行目が若干上に移動する、という挙動を示します。

 

TextFieldに複数行の入力を前提としている場合には、この挙動は明らかに不自然なので、isDenseをtrueにすることで回避します。

 

デフォルトの上部パディング削除

コードの173、174行目です。

 

TextFieldには、デフォルトで上部に12pixelのパディングが設定されており、この値を横罫線の描画範囲に考慮する必要があります。

 

そのため、InputDecorationクラスのcontentPadding属性に、パディング値を明示的に設定しておきます。

 

上記コードでは、contentPaddingOというプロパティ(変数)を使って設定し、値は0.0(つまりパディング無し)にしています。

 

横罫線を引くクラスの作成

横罫線を描画する範囲(縦幅)の算出

コードの224〜234行目です。

 

以下の式で描画する範囲(縦幅)を算出します。

 

横罫線を描画する範囲(縦幅)

= TextFieldの表示範囲(縦幅) + 縦スクロールした範囲(縦幅)※

 

※縦スクロールした範囲(縦幅)= 

 GlobalKeyで取得したTextField上端位置(Y座標)※※

 ー FocusNodeで取得したTextField上端位置(Y座標)

 

※※{GlobalKeyで取得したTextウィジェット上端位置(Y座標)

  + GlobalKeyで取得したTextウィジェットの縦幅

  + 1.0(Container内側のパディング。Containerに枠線を付けない場合は不要)

  + TextField上部のデフォルトのパディング(上記コードでは0.0に変更)}

 

画面上のイメージは以下のとおりです。

 

 

GlobalKeyから取得したTextField上端(画面表示されている部分)のY座標と、FocusNodeから取得したTextField上端(スクロールで上に移動して画面から消えている部分)のY座標の差から、スクロールした縦幅を計算し、それを横罫線の描画範囲に加算しています。

 

FocusNodeを使うことで、上にスクロールして画面から消えている部分のY座標が取得できる点がポイントになります。

 

また、前述のとおり、Containerに枠線を付けたことで、自動的に1.0pixelのパディングが設定されてしまうので、その分も考慮します。

 

以上の設定により、TextFieldがスクロールしても、スクロールした分だけ横罫線を追加で描画することができます。

 

横罫線を引く間隔(1行の高さ)の算出

コードの243行目です。

 

以下の式で、横罫線を引く間隔(1行の高さ)を計算します。

 

横罫線を引く間隔(整数値にする)

= フォントサイズ × 1行の高さを決める倍数

 

注意点は、小数点以下の端数が出ないよう処理する点です(ここでは「.ceil」メソッドで切上げを採用)。

 

端数があると、改行すると徐々に横罫線と文字の関係がズレるためです。

 

 

横罫線を描画する本数の算出

コードの245行目です。

 

以下の式で、画面上に描画する横罫線の本数を算出します。

 

横罫線を描画する範囲(縦幅) ÷ 横罫線を引く間隔

 

ここでは切下げを採用しています。

 

横罫線を描画する範囲(縦幅)は、前述のとおり、TextFieldの表示範囲(縦幅)をベースに算出しています。

 

そのため、キーボードの出現有無に応じて、横罫線を描画する範囲(縦幅)が伸縮するので、横罫線の描画本数も適切に増減します。

 

横罫線の横幅の算出

コードの250行目です。

 

以下の式で、横罫線の横幅を算出します。

 

横計算の横幅

= MediaQueryで取得した画面横幅

  ー 画面全体に設定したパディング × 2(左右の分)

 

横罫線を引く処理

コードの253〜265行目です。

 

下図のようなイメージで始点と終点を指定して、横罫線を描画します。

 

 

 

改行やスクロールが発生しても、横罫線が途切れず表示され、かつ、横罫線と文字の関係がズレないように調整するところがポイントだと思います。

 

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

 

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

 

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

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

 

 


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

コメント

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