Flutter

【Flutter】SingleChildScrollViewとTextFieldを組み合わせたときのバグ?対策

背景

入力フォームの画面にて、キーボードを開いた際に下にあるTextFieldが隠れてしまったので、キーボードを開いた際にスクロールさせるようにしたいと考えました。
よって、SingleChildScrollViewを入れることで対応しようと思ったのですが、ここでFlutterのバグ?らしい挙動を発見しました。

バグ?らしき挙動について

TextFieldに入力した際に、バリデートにてエラーになるとTextFieldのdecorationにエラーメッセージを表示する仕様でした。

キーボードを開いてスクロール可能な状態で、TextFieldに文字を入力した結果、バリデートがエラーとなった時、TextFieldにエラーメッセージが表示されます。この状態で、スクロール位置が最上部の時にキーボードを閉じた際にはその後スクロールできないのですが、最上部じゃなかった際にスクロールできないはずなのにスクロールできるという挙動になります。

発生している画面
import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

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

class _MyHomePageState extends State<MyHomePage> {
  String? errorMessage;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        // Here we take the value from the MyHomePage object that was created by
        // the App.build method, and use it to set our appbar title.
        title: Text(widget.title),
      ),
      body: SingleChildScrollView(
        child: Column(
          children: [
            TextField(
              onChanged: _changeText,
              decoration: InputDecoration(
                errorText: errorMessage,
              ),
            ),
            SizedBox(height: 20),
            TextField(
              onChanged: _changeText,
              decoration: InputDecoration(
                errorText: errorMessage,
              ),
            ),
            SizedBox(height: 20),
            TextField(
              onChanged: _changeText,
              decoration: InputDecoration(
                errorText: errorMessage,
              ),
            ),
            SizedBox(height: 20),
            TextField(
              onChanged: _changeText,
              decoration: InputDecoration(
                errorText: errorMessage,
              ),
            ),
            SizedBox(height: 20),
            TextField(
              onChanged: _changeText,
              decoration: InputDecoration(
                errorText: errorMessage,
              ),
            ),
            SizedBox(height: 20),
            TextField(
              onChanged: _changeText,
              decoration: InputDecoration(
                errorText: errorMessage,
              ),
            ),
            SizedBox(height: 20),
            TextField(
              onChanged: _changeText,
              decoration: InputDecoration(
                errorText: errorMessage,
              ),
            ),
          ],
        ),
      ),
    );
  }

  void _changeText(String message) {
    setState(() {
      errorMessage = "エラー";
    });
  }
}

対策

キーボードを開いている時、

MediaQuery.of(context).viewInsets.bottom

でキーボードのサイズを取得できます。
逆にキーボードを閉じている時は、このサイズが0になります。
これを利用して、キーボードを閉じている時は、SingleChildScrollViewのスクロールを禁止するという対策を行いました。

import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

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

class _MyHomePageState extends State<MyHomePage> {
  String? errorMessage;

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

    final physics = keyboardSize == 0 ? NeverScrollableScrollPhysics() : null;

    return Scaffold(
      appBar: AppBar(
        // Here we take the value from the MyHomePage object that was created by
        // the App.build method, and use it to set our appbar title.
        title: Text(widget.title),
      ),
      body: SingleChildScrollView(
        physics: physics,
        child: Column(
          children: [
            TextField(
              onChanged: _changeText,
              decoration: InputDecoration(
                errorText: errorMessage,
              ),
            ),
            SizedBox(height: 20),
            TextField(
              onChanged: _changeText,
              decoration: InputDecoration(
                errorText: errorMessage,
              ),
            ),
            SizedBox(height: 20),
            TextField(
              onChanged: _changeText,
              decoration: InputDecoration(
                errorText: errorMessage,
              ),
            ),
            SizedBox(height: 20),
            TextField(
              onChanged: _changeText,
              decoration: InputDecoration(
                errorText: errorMessage,
              ),
            ),
            SizedBox(height: 20),
            TextField(
              onChanged: _changeText,
              decoration: InputDecoration(
                errorText: errorMessage,
              ),
            ),
            SizedBox(height: 20),
            TextField(
              onChanged: _changeText,
              decoration: InputDecoration(
                errorText: errorMessage,
              ),
            ),
            SizedBox(height: 20),
            TextField(
              onChanged: _changeText,
              decoration: InputDecoration(
                errorText: errorMessage,
              ),
            ),
          ],
        ),
      ),
    );
  }

  void _changeText(String message) {
    setState(() {
      errorMessage = "エラー";
    });
  }
}

最後に

Flutterのことで何か質問あれば、遠慮なくコメントに記載していただければと思います。
できるだけ回答しようと思います。

-Flutter