Flutter TextFieldでキーボード表示に伴って生じるエラー・不具合への対処法

Flutter

縦長のTextFieldを配置すると、キーボードが表示されたときにエラーになったり、TextFieldの上下端が隠れたままで、全ての内容を表示できなかったりする。

 

解決するにはどうしたらいいの?

 

自分が下記のメモアプリを作成しているとき、ぶつかった壁です。

 

試行錯誤して解決したので、サンプルケースを使ってその過程を共有できればと思います。

 


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

 

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

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

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

 

スポンサーリンク

キーボード出現時のエラーはSingleChildScrollViewで対応

サンプルケースとして、上図のように、高さが600pixel、改行可能なTextFieldを配置した場合を考えます。

 

コードは以下のとおりです(できるだけ説明をコード中に入れてあります)。

import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: "Test",
      theme: ThemeData.light(),
      home: SampleScreen(),
    );
  }
}

// ↑ここまでは必ず必要になる決り文句のコード

class SampleScreen extends StatefulWidget {

  @override
  _SampleScreenState createState() => _SampleScreenState();
}

class _SampleScreenState extends State<SampleScreen> {

  @override
  Widget build(BuildContext context) {
    return Scaffold(

      // AppBarのタイトルを記載
      appBar: AppBar(
        title: Text("TestApp"),
      ),

      body: 
        // TextFieldの範囲が分かりやすいように上下左右にPaddingを設定
        Padding(
        padding: const EdgeInsets.all(8.0),

        child: Column(
          children: [

            // TextFieldの上にテキストを表示するWidget(以下、Text Widget)を配置
            Text("↓TextField"),

            // TextFieldに枠を付け、高さ(縦幅)を設定するためにContainerでラップする
            Container(
              decoration: BoxDecoration(
                border: Border.all(color: Colors.grey),
              ),

              // 高さ(縦幅)を600pixelで固定
              height: 600,

              child: TextField(

                // デフォルトで表示される青い下線を削除
                decoration: InputDecoration(
                  border: InputBorder.none,
                ),

                // 改行して複数行入力が可能なように、キーボードに改行ボタンを表示
                keyboardType: TextInputType.multiline,

                // 改行できる行数を無制限に設定
                maxLines: null,

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

 

この状態でTextField内をタップし、キーボードを出現させると、以下のようなエラーが発生します。

======== Exception caught by rendering library ==============
The following assertion was thrown during layout:
A RenderFlex overflowed by 183 pixels on the bottom.

 

キーボードが出現したことで、Text Widget(「↓TextField」を表示するWidget)とTextFieldを画面内に表示しきれなくなったためです。

 

これは、「SingleChildScrollView」で、Widget(描画要素)を包む(ラップする)ことで回避できます。

 

「SingleChild」なので、画面に表示される全Widget(AppBarは除く)を、1個にまとめているWidget(ここではColumnから上にあるWidget)をラップする必要があります。

 

ContainerやTextFieldをラップしても、エラーは解消しないので注意が必要です(Text Widgetが対象から漏れてしまっているため)。

 

追記した部分のコードは以下のとおりです。ここではColumnをラップしていますが、その一つ上のPaddingをラップしても大丈夫です。

      body:
        // ※下のColumnではなくこのPaddingをラップしても可
        Padding(
        padding: const EdgeInsets.all(8.0),

        // ↓ColumnをSingleChildScrollViewでラップする
        child: SingleChildScrollView(

          child: Column(
            children: [
              Text("↓TextField"),
              Container(
                decoration: BoxDecoration(
                  border: Border.all(color: Colors.grey),
                ),
                height: 600,
                child: Scrollbar(
                  child: TextField(
                    decoration: InputDecoration(
                      border: InputBorder.none,
                    ),
                    keyboardType: TextInputType.multiline,
                    maxLines: null,
                  ),
                ),
              ),
            ],
          ),
        ),
      ),

 

これでキーボードが出てもエラーは出なくなりました。

 

SingleChildScrollViewだけでは解決しない問題

これでエラーは解消されますが、TextFiledを扱う上で、別の問題が3つほど発生します。

 

①TextFieldの上にあるWidgetが見えなくなる

 

上図のように、TextField内の改行が増えたとき、カーソルをTextFieldの下部に置いた状態でキーボードを出すと、TextFieldの上にあった「↓TextField」の表示(Text Widget)も上にズレて、見えなくなってしまいます

 

②TextFieldの上端までスクロールできない

 

上図のように、カーソルをTextFieldの下部に置いた状態でキーボードを出すと、TextFieldそのものが上に動いてしまうので、TextField内をスワイプしてスクロールさせても上端まで表示できません

 

上端まで表示させるには、キーボードをしまう必要があり、利便性が低くなります

 

③TextFieldの下端までスクロールできない

 

②の逆で、カーソルをTextFieldの上部に置いた状態でキーボードを出すと、TextFieldは上にスクロールしません。

 

その結果、TextFieldの下部(一部分)がキーボードの裏側に隠れたままとなり、TextField内をスワイプしてスクロールさせても下端まで表示できません

 

下端まで表示させるには、やはりキーボードをしまう必要があり、利便性が低くなります

 

キーボードの縦幅だけTextFieldの高さ(縦幅)を縮める

これらを解消するために、画面サイズを取得できる「MediaQuery.of」メソッドを使い、キーボードの縦幅を把握して、その分だけTextFieldの縦幅を縮めます。

 

修正後のコードは以下のとおりです(主な修正部分にコメントを入れています)。

import 'package:flutter/material.dart';

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

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


class SampleScreen extends StatefulWidget {
  @override
  _SampleScreenState createState() => _SampleScreenState();
}

class _SampleScreenState extends State<SampleScreen> {
  @override
  Widget build(BuildContext context) {

    // 画面の下端位置を把握
    // キーボードが出ているときは、画面下端からキーボード上端までの高さ(縦幅)になる
    var textFieldBottom = MediaQuery.of(context).viewInsets.bottom;

    return Scaffold(
      appBar: AppBar(
        title: Text("TestApp"),
      ),
      body: Padding(
        padding: const EdgeInsets.all(8.0),
        child: SingleChildScrollView(
          child: Column(
            children: [
              Text("↓TextField"),
              Container(
                decoration: BoxDecoration(
                  border: Border.all(color: Colors.grey),
                ),

                // TextFieldの高さ(縦幅)を
                // 固定値の600pixelから、キーボードの高さ(縦幅)を減算して設定
                // ※キーボードが出ていないときは、textFieldBottom = 0
                height: 600 - textFieldBottom,

                // スクロール状況が分かりやすいようにScrollbarを設置
                child: Scrollbar(

                  child: TextField(
                    decoration: InputDecoration(
                      border: InputBorder.none,
                    ),
                    keyboardType: TextInputType.multiline,
                    maxLines: null,
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

 

MediaQueryの使い方は、こちらの記事でも書いていますのでご参考にしてください。

 

これで、上図のように、TextField内を上下にスクロールさせて上端・下端まで表示できるようになりました。

 

ただし、元々のTextFieldの縦幅を、画面サイズによらず600pixel固定にしているため、TextFieldとキーボードの間に余白ができています。

 

逆に、画面の縦幅が短い端末では、600だと大きすぎて、TextFieldの下部がキーボードの裏側に隠れたままになる可能性もあります。

 

そのため、次の方法で画面サイズに応じて、TextFieldの縦幅を最適に設定します。

 

画面サイズ・AppBarの高さに応じて最適配置する

AppBarを表示する場合は、画面サイズ(縦幅)だけでなく、AppBarの高さ(縦幅)も端末によって異なるため、この2点を考慮してTextFieldの高さ(縦幅)を設定します。

 

概略は以下のとおりです。

  1. GlobalKeyをText Widget(「↓TextField」を表示するWidget)のkeyプロパティに設置
  2. initStateメソッドを作り、その中でaddPostFrameCallbackメソッドを使い、Text Widgetの描画後に、GlobalKeyからそのy座標開始位置高さ(縦幅)を取得
  3. buildメソッド内に「MediaQuery.of」メソッドを追加し、画面の縦幅を取得
  4. 以下の計算式で、TextFieldをラップしているContainerの高さ(縦幅)を設定
    画面の縦幅
    ー{Text Widgetのy座標開始位置 + Text Widgetの高さ(縦幅)}
    ー キーボードの縦幅
    ー 
    12.0 ※後述

 

ここでは詳細説明は割愛しますが、画面サイズとAppBarの高さに応じてWidgetを最適配置する方法は、以下の記事で解説していますので、ご参考にしてください。

 

以下に最終的なコードの全体像を掲載します(追加部分を中心にコメントを入れています)。

 

※実は、この方法を用いると、SingleChildScrollViewは不要になるのですが(キーボードの表示・非表示に応じてTextFieldのサイズを変更するので、スクロールで対応する必要がなくなる)、あってもエラーにはならないので、ここでは記載を残しています。

import 'package:flutter/material.dart';

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

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

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

class SampleScreen extends StatefulWidget {
  @override
  _SampleScreenState createState() => _SampleScreenState();
}

class _SampleScreenState extends State<SampleScreen> {

  // Text Widgetの2次元情報を入れるためのRenderBox型の変数を定義
  // null safety対応のためlate(今は値を決定できない)をつける
  late RenderBox textWidget;

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

  // 初期値設定等を行う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をつける
      textWidget = globalKeyTextWidget.currentContext?.findRenderObject() as RenderBox;

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

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

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

    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    var textFieldBottom = MediaQuery.of(context).viewInsets.bottom;

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

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

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

            ),
            Container(
              decoration: BoxDecoration(
                border: Border.all(color: Colors.grey),
              ),

              // 初回描画時点では、GlobalKey未取得のため、計算式から高さを求めると
              // 画面サイズオーバーのエラーになる。これを回避するため、GlobalKeyの取得が
              // 未了の段階(=初回描画時点)では高さを0.0にしておく。
              // 計算式では、TextFieldデフォルトのパディング12.0も引く必要あり。
              height: (textWidgetDy == 0)
                  ? 0.0
                  : screenHeight -
                  (textWidgetDy + textWidgetHeight) -
                  textFieldBottom -
                  12.0,

              child: Scrollbar(
                child: TextField(
                  decoration: InputDecoration(
                    border: InputBorder.none,
                  ),
                  keyboardType: TextInputType.multiline,
                  maxLines: null,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

 

これで、画面サイズに応じて、無駄な余白が生じないTextFieldを設定できました。

注意点としては、最後に12.0を引く必要がある点です。

 

これは、TextFieldにはデフォルトで上部パディングが12.0pixel設定されており、この分も考慮して高さを設定しないと、僅かにTextFieldの下部がキーボードの裏側に隠れてしまうためです。

 

下図は、12.0を引かずにTextFieldの高さを設定した場合です。

 

※なお、TextFieldの設定で、デフォルトの上部パディングをゼロにする方法もあるのですが、別の問題が発生するので、ここでは割愛します。別の記事で説明したいと思います。

 

また、Containerの「height:」を設定するとき、単純に上記4.の計算式だけを書いてしまうと、一時的にエラーが発生します。

 

addPostFrameCallbackメソッドを使っているので、Text WidgetのGlobalKeyを取得する前に、TextFieldの初回描画が行われますが、この時点では{Text Widgetのy座標開始位置 + Text Widgetの高さ(縦幅)}が初期値(つまり「0」)のままなので、TextFieldの高さ(縦幅)が画面サイズを超過してしまうためです。

 

これを回避するため、Text WidgetのGlobalKeyを取得するまでは、TextFieldの高さ(縦幅)を0.0にしておく調整をしています。

 

 

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

 

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

コメント

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