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

Flutter

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

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

 

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

 

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

 


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

 

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

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

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

 

スポンサーリンク

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

下図のように、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());

// グローバル変数としてGlobalKey型の変数(プロパティ)を定義
GlobalKey globalKeyAppBar = 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> {

  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プロパティに、上で定義したグローバル変数を設定
  3. initStateメソッドの中で、グローバル変数からAppBarの上端位置と高さ(縦幅)の情報を取得
  4. グレーのボックス(Container)のheight(高さ・縦幅)を、「画面の半分の長さー{AppBarの上端位置+AppBarの高さ(縦幅)}」に設定

 

留意点2つ

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

  @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の高さ調整の方法は、こちらの記事を参考にさせていただきました。ありがとうございます!

 

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

 

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

 

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

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

 

 


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

コメント

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