Flutter 画面サイズとAppBarの高さに合わせてWidgetを配置する方法

※当サイトは、アフィリエイト広告を利用しています

Flutter

(2022.6.18更新)

端末が変わると画面サイズやAppBarの高さが変わってしまう

どんな端末でもWidget(描画要素)を思い通りの配置にするにはどうしたらいいの?

 

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

 

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

 


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

 

作成したアプリはこちら↓ 全てFlutterで開発したアプリです。

 

実現したい例(サンプルケース)

下図のように、AppBar(ScaffoldというWidgetを使ったときに、画面上部に標準で表示されるバー)のすぐ下に、Widget(ここではContainerで作ったグレーの四角形とします)を配置し、その下端がスマホ画面の縦幅のちょうど半分までになるようにしたい、というケースを考えます。

 

AppBarの高さは端末によって変わってしまうので、画面の縦幅AppBarの高さの2つを把握する必要があります。

 

※AppBarの高さは標準だと56pixelという情報がありますが、実際に複数の端末で試したところ、高さは様々でした(後でも確認します)。

 

まず、画面の縦幅を把握する

いったんAppBarの存在を無視して、画面の縦幅だけを考えてみます。

 

画面のサイズは、

  • 縦幅: MediaQuery.of(context).size.height
  • 横幅: MediaQuery.of(context).size.width

で把握できます。

 

これを使って、Containerの高さを、画面の縦幅の半分に設定してみます。

 

長くなってしまいますが、そのままコピーして使っていただけるよう、冒頭のimport文から全て記載します。

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> {

  // 画面の縦幅を入れる変数を定義。初期値はnullを防ぐため、0としておく
  double screenHeight = 0;

  @override
  Widget build(BuildContext context) {

    // MediaQueryで画面の縦幅を取得
    screenHeight = MediaQuery.of(context).size.height;

    return  Scaffold(
      appBar: AppBar(

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

      // AppBarの下に設置するグレーのボックス
      body: Container(
        color: Colors.blueGrey,

        // 高さを画面の縦幅の半分に設定
        height: screenHeight/2,

        // 横幅は200ピクセルで固定
        width: 200,
      ),
    );
  }
}

 

実行するとこんな感じで、AppBarがある分だけ、グレーのボックスが真ん中より下にズレてしまっています。次でこれを修正します。

 

なお、下記のように、buildメソッドの前に、初期値設定などを行うinitStateメソッドを作り、その中で、MediaQueryの部分を

  @override
  void initState() {

    // initStateメソッドの中でMediaQuery.ofメソッドを実行するとエラーになる
    screenHeight = MediaQuery.of(context).size.height;

    super.initState();
  }

のように書くと、以下のようなエラーになります。

======== Exception caught by widgets library ================================
The following assertion was thrown building Builder:
dependOnInheritedWidgetOfExactType() or dependOnInheritedElement() was called before _SampleCodeScreenState.initState() completed.

When an inherited widget changes, for example if the value of Theme.of() changes, its dependent widgets are rebuilt. If the dependent widget's reference to the inherited widget is in a constructor or an initState() method, then the rebuilt dependent widget will not reflect the changes in the inherited widget.

Typically references to inherited widgets should occur in widget build() methods. Alternatively, initialization based on inherited widgets can be placed in the didChangeDependencies method, which is called after initState and whenever the dependencies change thereafter.

 

これは、まだbuildメソッドが回っていないので、contextが無いのにもかかわらず、ofメソッドがcontextを使おうとするためです。

 

そのため、MediaQueryの部分は、buildメソッドの中に書く必要があります。

 

次に、AppBarの位置と高さを把握する

グレーのボックスの高さを、画面の縦幅の中央で止めるには、現状の高さからAppBarの高さを引く必要があります。

 

そこで、

  • AppBarの高さ(縦幅)
  • AppBarの縦方向の開始位置(y座標の位置)

を把握します(AppBarが、画面の上端=y座標ゼロ から始まっていない場合は、その分も考慮が必要なため)。

 

具体的には、AppBarにGlobalKeyを設定することで把握します。

 

先にコードの全体を示します(そのままコピーしてご利用ください。上のコードから追加した部分には、説明コメントを入れています)。

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> {

  // GlobalKey型のインスタンスを定義
  GlobalKey globalKeyAppBar = GlobalKey();

  double screenHeight = 0;

  // RenderBox型の変数を定義。Widget(このコードではAppBar)の2次元情報を取得するために必要
  // null safety対応でlate(今は値を決定できない)をつける
  late RenderBox appBarWidget;

  // AppBarのy座標開始位置と高さ(縦幅)を入れるための変数を定義
  // 初期値を設定しないとエラーになるため0.0を設定
  double appBarDy = 0.0;
  double appBarHeight = 0.0;

  // buildメソッドが回る前に初期値を設定するためのinitStateメソッドを導入
  @override
  void initState() {

   // buildメソッドが回り、AppBarの描画終了後に、GlobalKeyの情報を取得するようにするため、
   // addPostFrameCallbackメソッドを実行
   // null safety対応で?(null以外の時のみアクセス)をつける
   WidgetsBinding.instance?.addPostFrameCallback((cb){

      // GlobalKeyを通じてAppBarの2次元情報を取得
      // null safety対応で?と右辺にas RenderBoxをつける
      appBarWidget = globalKeyAppBar.currentContext?.findRenderObject() as RenderBox;

      // 2次元情報からAppBarの縦方向の上端位置(y座標)を取得
      appBarDy = appBarWidget.localToGlobal(Offset.zero).dy;

      // 2次元情報からAppBarの高さ(縦幅)を取得
      appBarHeight = appBarWidget.size.height;

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

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

   });

    super.initState();
  }

  @override
  Widget build(BuildContext context) {

    screenHeight = MediaQuery.of(context).size.height;

    return  Scaffold(
      appBar: AppBar(

        // GlobalKeyをAppBarのkeyプロパティに設定
        key: globalKeyAppBar,

        title: Text("TestApp"),
      ),

      body: Container(
        color: Colors.blueGrey,

        // Container(グレーのボックス)の高さを、
        // 画面縦幅の2分の1から、AppBarの上端位置(基本は0)と高さの合計値を引くことで算出
        height: screenHeight/2 - (appBarDy + appBarHeight),

        width: 200,
      ),

    );
  }
}

 

手順は以下のとおりです。

  1. Widgetの情報を取得するためのGlobalKey型のインスタンスを定義
  2. 位置情報を把握したいWidget(今回の例ではAppBar)のkeyプロパティに、上で定義したGlobalKeyのインスタンスを設定
  3. initStateメソッドの中で、GlobalKeyからAppBarの上端位置と高さ(縦幅)の情報を取得
  4. グレーのボックス(Container)のheight(高さ・縦幅)を、「画面の半分の長さー{AppBarの上端位置+AppBarの高さ(縦幅)}」に設定

 

留意点2つ

1つ目は、initStateの中で、以下のように、単純にGlobalKeyから位置情報を取得するだけだと、エラーになるという点です。

  @override
  void initState() {

      appBarWidget = globalKeyAppBar.currentContext?.findRenderObject() as RenderBox;
      appBarDy = appBarWidget.localToGlobal(Offset.zero).dy;
      appBarHeight = appBarWidget.size.height;
      print("AppBarの上端位置 $appBarDy、AppBarの高さ $appBarHeight");
      setState(() {});

    super.initState();
  }

エラーはこんな感じです。

======== Exception caught by widgets library ============
The following NoSuchMethodError was thrown building Builder:
The method 'findRenderObject' was called on null.
Receiver: null
Tried calling: findRenderObject()

 

画面にもこんな表示が出ます。

 

AppBarは、buildメソッドが回らないと描画されないのに、AppBarの描画が終わらないうちに、その位置情報を把握しようとしたためです。

 

これを回避するために、位置情報を把握する部分を、buildメソッドが終わった後に処理を発動できる

WidgetsBinding.instance.addPostFrameCallback

というメソッドの中に入れ込みます。

 

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

 

もう1つの留意点は、AppBarのy座標の開始位置(appBarDy)と高さ(appBarHeight)には、必ず何らかの初期値を与える必要があるということです。

 

初期値を与えておかないと、このようなエラーになります。

======== Exception caught by widgets library ================================
The following NoSuchMethodError was thrown building SampleScreen(dirty, dependencies: [MediaQuery], state: _SampleScreenState#945e1):
The method '+' was called on null.
Receiver: null
Tried calling: +(null)

 

addPostFrameCallbackメソッドにより、AppBarの描画前に位置情報を把握することは回避できますが、今度は、開始位置(appBarDy)と高さ(appBarHeight)の値が「null」のまま、初回の描画が行われることになります。

 

そのため、値が「null」の変数を使ってConteinarの高さ(height)を計算しようとすることで、エラーになります。

 

どのみち、Flutter2.2以降はnull safetyが標準になりましたので、初期値の設定はしたほうが良いですね。

 

実行結果の例

AndroidとiOSの2パターンの実行結果(例)は、以下のとおりです。

Android Pixel4 の例
iOS iPhone12 mini の例

 

AppBarの高さは、やはり端末によって異なっていて、今回の例ではAndroidが80.0、iOSが106.0になっています。

  

しかし、どちらのケースでも、グレーのボックスの下端は、ちゃんと画面の縦幅の真ん中で止まっています。

 

これで端末固有の画面サイズやAppBarの高さに対応したWidgetのサイズ決定ができました。

 

今回の方法は、縦幅だけでなく、横幅や配置の調整にも使えるため、色々と応用できると思います。

 

補足の考察①(AppBarの高さを決めたら?)

端末によってAppBarの高さが変わってしまうのが問題ならば、

「AppBarの高さを数値で決めてしまえば、GlobalKeyを使わなくて済むんじゃないの?」

というご意見もあるかと思います。

 

AppBarの高さは、PreferredSizeクラスでAppBarをラップする(囲う)ことで設定できます。

 

以下は、今回のAndroidの例と同じ80.0ピクセルに設定した場合です。

    appBar: PreferredSize(
      preferredSize: Size.fromHeight(80.0),
      child: AppBar(
        key: globalKeyAppBar,
        title: Text("TestApp"),
      ),
    ),

 

しかし、実行するとこんな感じになり、AppBarが想定より縦に長くなってしまいます。

 

原因はまだよく理解できていませんが、恐らく画面最上部の電池やWiFiマークのあるゾーンの下からAppBarの高さが設定されてしまうからだと考えています(もし違っていたらぜひ教えて下さい!)。

 

となると、結局、電池やWiFiマークのあるゾーンの高さ(縦幅)が端末によって異なるので、GlobalKeyで位置情報を把握しない限り、正確な調整ができないことになります。

 

そのため、AppBarの高さを数値で決めるというのは解決策にならない、というのが自分の中の結論です。

 

※AppBarの高さ調整の方法は、こちらの記事を参考にさせていただきました。ありがとうございます!

 

補足の考察②(ステータスバー・AppBarの高さを算出するメソッドを使う)

(2022.6.18追記)

画面最上部の電池やWiFiマークのあるゾーン(「ステータスバー」と呼ぶようです)の高さは、以下のメソッドでも算出できます。

 

MediaQuery.of(context).padding.top // 高さ(A)とします

 

また、ステータスバーを除いたAppBarの高さは、以下のメソッドでも算出できます。

 

AppBar().preferredSize.height // 高さ(B)とします

 

※こちらの記事で教えていただきました。ありがとうございます!

 

 

実際にビルドして、上記の高さ(A)(ステータスバーの高さ)+高さ(B)(ステータスバー除いたAppBarの高さ)を計算してみると、前述したAppBarのGlobalKeyから算出した、ステータスバーも含めたAppBarの高さ(AppBarのy座標の開始位置(appBarDy)+高さ(appBarHeight))と、確かに一致します(Android、iOSともに一致しました)。

 

従って、Containerの高さは、画面半分の高さー高さ(A)ー高さ(B)でも設定できることになります。

 

しかし、補足の考察①のように、AppBarの高さをPreferredSizeクラスを用いてデフォルト値から変更した場合でも、高さ(B)の算出結果は、変更に関係なく、デフォルトの56のままになります。

 

そのため、AppBarの高さを変更したときは、高さ(B)に使用する値は、「AppBar().preferredSize.height」の値ではなく、PreferredSizeクラスで設定した値にする必要があり、Containerの高さの設定式を変更する必要が出てきます。

 

一方、既述のAppBarのGlobalKeyを用いる方法であれば、AppBarの高さ変更した場合、変更を考慮した数値になるので、Containerの高さの設定方法は1通りで済むことになります。

 

ただ、AppBarの高さをデフォルト(56pixel)のままとする場合は、GlobalKeyを用いる方法よりも、高さ(A)+高さ(B)の方法で求めた方が、書くコードは少なくて済みそうです。

 

この点は、状況に応じて使い分けるのが良いかと思いました。

 


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

 

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

 

個人アプリ開発で役立ったもの

おすすめの学習教材

超初心者向けでオススメな元Udemyの講座/

 

 \キャンペーン時を狙えば安価で網羅的な内容が学べる(日本語訳あり)/

 

\Gitの基礎について無料で学べる/

 

おすすめの学習書籍

実用的image_pickerに関してかなり助けられた/

 

Dartの基礎文法を素早くインプットできる/


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

コメント

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