Iganinのブログ

日頃の開発で学んだ知見を中心に記事を書いています。

【Flutter】ScrollViewとExpandedを併用してSignIn / SignUp画面 などのレイアウトを作成する方法

TL;DR;

  • LayoutBuiderとIntrinsicHeightを併用する

環境

[] Flutter (Channel stable, 2.2.3, on macOS 11.3.1 20E241 darwin-x64, locale ja-JP)
[] Xcode - develop for iOS and macOS
[] Chrome - develop for the web
[] Android Studio (version 2020.3)
[] IntelliJ IDEA Ultimate Edition (version 2021.1.2)
[] VS Code (version 1.59.1)

モチベーション

添付の画像のようなUIを考える。サインイン画面やサインアップ画面によくある、ロゴ、TextField、ボタンという構成。 画面によってはボタンを画面下部に置くことがあると思う。 サインアップページでは複数項目を入力する必要があることも多く、複数画面に渡って画面下部にボタンを置くという構成にしたいというケースがある。

f:id:Iganin:20210904145721p:plain
ボタンを画面下部に固定したデザイン

画面に十分なスペースがある場合は良いが、TextFieldが複数個配置されたり、画面に画像などを複数置き十分なスペースが確保できなくなると問題に当たる。 通常であればTextFieldにフォーカスが当たりキーボードが表示されると画面の構成要素がキーボード分上部に移動する。 スペースが十分にある場合はこれで良く、多くの場合問題にならない。

スペースが十分に取れていないとこの方法だと画面レイアウトが崩れエラーとなる。 このような崩れを回避するための方法として以下の二つがある。

f:id:Iganin:20210904145739p:plain
余白が十分にあれば問題はない
f:id:Iganin:20210904145817p:plain
余白が不足するとレイアウトエラーとなる

  • (1) Scaffoldの resizeToAvoidBottomInsetをfalseにする
  • (2) SingleChildScrollViewでWrapする

(1)の方法はキーボードの表示によりTextFieldが隠れてしまい入力内容が参照できなくなりUXを損なうという問題が発生する。 多くの場合は(2)の方法で問題を回避するが、本稿の前提となるUIの場合問題が発生する。 本稿のUIのようにボタンを画面下部に固定して表示しようとした場合、余白部分をSpacer()などを使って埋めることが多いと思う。 ただSingleChildScrollViewは内部から高さを決めようとするがSpacerやExpandedでは可能な限りスペースを広げようとするため、 高さが不定となり、画面レイアウトが崩れる。

The following assertion was thrown during paint():
RenderBox was not laid out: RenderRepaintBoundary#f85cc relayoutBoundary=up1 NEEDS-PAINT
'package:flutter/src/rendering/box.dart':
Failed assertion: line 1930 pos 12: 'hasSize'

f:id:Iganin:20210904145851p:plain
TextFieldが隠れてしまい入力値が見えない

ではどのようにすれば、キーボードの表示問題を解決しつつ希望するUIを実現できるのだろうか。

解決策

実はSingleChildScrollViewの公式ドキュメントに解決方法の記載がある。 下記のページの Expanding content to fit the viewportを参照されたい。 api.flutter.dev

もしくは下記のFlutterのIssueもヒントになると思う。

github.com

以下に対応した場合のコードを記載する。

LayoutBuilderによって、親Widgetの画面サイズを取得し、ConstrainedBoxによってminHeightを親Widgetの高さと同一とする。 こうすることで、内部要素が画面サイズいっぱいまで広がるようになる。 ただ、このままでは先程の高さが不定になるという問題は解消しない。

そこでIntrinsicHeightを使用する。 IntrinsicHeightは高さを内部の構成要素の高さの合計値と同一にする。 こちらで決まる高さの制約はConstrainedBoxのheightの制約より弱いため、 内部にExpandedやSpacerがある場合は画面いっぱいまでExpandedやSpacerが拡張される。 この仕組みによって、目標であるレイアウトは達成される。

注意点としてはドキュメントにも記載があるがIntrinsicHeightによるレイアウトコストが高いことがあげられる。 Column内の構成要素はできるだけ少なくすることが望まれる。

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Sample'),
      ),
      body: LayoutBuilder(
        builder: (context, constraints) => SingleChildScrollView(
          child: ConstrainedBox(
            // この部分で画面全体のサイズを高さの最小値としている
            // したがって、内部の構成要素のheightの合計値が画面サイズに満たなくても
            // 画面サイズいっぱいまで内部要素が拡張される
            constraints: BoxConstraints(minHeight: constraints.maxHeight),
            // IntrinsicHeightが構成の要である
            // IntrinsicHeightによって、高さが内部構成要素の合計値となる
            // この制約はConstrainedBoxによる制約より弱いため、
            // ConstrainedBoxの高さの制約によってSpacerが余白分まで広がる
            child: IntrinsicHeight(
              child: Padding(
                padding: const EdgeInsets.all(20),
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.start,
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: <Widget>[
                    Container(
                      color: Colors.amber,
                      height: 250,
                      child: Text(
                        'ロゴ',
                        style: TextStyle(fontSize: 80),
                      ),
                    ),
                    const SizedBox(height: 44),
                    TextField(
                      decoration: InputDecoration(
                        hintText: 'メールアドレス',
                      ),
                    ),
                    const SizedBox(height: 44),
                    TextField(
                      decoration: InputDecoration(
                        hintText: 'パスワード',
                      ),
                    ),
                    const SizedBox(height: 44),
                    const Spacer(),
                    SizedBox(
                        height: 88,
                        child: ElevatedButton(
                            onPressed: () {},
                            child: Text(
                              'ログイン',
                              style: TextStyle(fontSize: 20),
                            ))),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

参考文献