【Flutter】端末に依存しないフレキシブルなレイアウトを作るコツ

Flutterでアプリを作成していて、画面レイアウトを構築するのって楽しいですよね。

夢中になって取り組んで渾身のレイアウトができた時にふと思うこと。
「これって他の端末だったらどうなるんだろう？」

端末を変えてインストールしてみるとオーバーフローエラーが出たり、思うようなレイアウトにならなかったり、、、

こんな経験、ありませんか？

今回は端末に依存しない、フレキシブルなレイアウトを作成するコツについて解説したいと思います。
実例を用意しましたので、参考にしていただければと思います。

結論は、**端末の大きさ × 割合**で調整する、です。

##NGな例##
まずはこちらの画像とコードをご覧ください。

<img width=30% src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1694109/78b19bf6-5156-a571-48bb-4e3a59d40443.png">

```dart
class NGLayout extends StatelessWidget {
  const NGLayout({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text("NG Layout"),
        ),
        body: SafeArea(
          child: Center(
            child: Column(
              children: [
                const SizedBox(
                  height: 64,
                ),
                SizedBox(
                  width: 320,
                  height: 320,
                  child: Image.asset(
                    "images/forQiita.png",
                  ),
                ),
                const SizedBox(
                  height: 96,
                ),
                SizedBox(
                  height: 48,
                  width: 120,
                  child: ElevatedButton(
                    onPressed: () {},
                    child: const Text("Button 1"),
                  ),
                ),
                const SizedBox(
                  height: 48,
                ),
                SizedBox(
                  height: 48,
                  width: 120,
                  child: ElevatedButton(
                    onPressed: () {},
                    child: const Text("Button 2"),
                  ),
                ),
                const SizedBox(
                  height: 48,
                ),
                SizedBox(
                  height: 48,
                  width: 120,
                  child: ElevatedButton(
                    onPressed: () {},
                    child: const Text("Button 3"),
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
```

iPhone 13 Pro Max でレイアウト調整を行なったアプリです。
いい感じにレイアウトできていますよね。

これをiPod touchや、iPad Pro (12.9-inch)にインストールしてみるとどうなるでしょうか？

iPod touch
<img width=30% src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1694109/9a5f1b2a-6407-a43a-37c6-94bec0f24bae.png">

iPad Pro (12.9-inch)
<img width=50% src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1694109/9d7039b7-33d2-f9eb-5d29-bdf89c365143.png">

iPod touch の方は、レイアウトが画面からはみ出る、オーバーフローのエラーが出てしまいました。
iPad Proの方は、エラーは出ていませんが、画像やボタンが画面上部に寄っていて下半分のスペースが無駄になっています。

なぜこのようなエラーやレイアウト崩れがおきてしまうのでしょうか？

##原因##

原因は、高さや幅(heightやWidth)を数値で設定していることにあります。

以下の例をご覧ください。
<img width=70% src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1694109/f1a4375e-3c1e-cccd-e3ec-9ece735f5036.jpeg">

黒い四角が端末、赤い四角が配置したいアイテムだとしましょう。
赤い四角の大きさを100と数値で決めてしまうと、
このように黒い四角の大きさによって見え方が変わってしまいます。

具体的には、左の四角では黒い四角の1/4を赤い四角が埋めているのに対し、
右の四角だと黒い四角の1/25しか埋めておらず、空白の部分が大きく見えてしまいます。

黒い四角の大きさが変わるのに対し赤い四角の大きさが変わらないから、
見え方が変わってしまうわけですね。

今回作成したNGLayoutのコードだと、このように端末の大きさが変わるのに対し、
画像や空白部分の高さを数値で決めてしまっています。
(変わらなくしてしまっています)

```dart
const SizedBox(
  height: 48,//こことか
),
SizedBox(
  height: 48,//こことか
  width: 120,//ここ
  child: ElevatedButton(
    onPressed: () {},
    child: const Text("Button 3"),
  ),
),
```

このためにエラーが出たり、レイアウトの崩れを起こしたわけです。

では一体どうすれば良いでしょうか？

##解決策##
解決策は簡単です。
端末の大きさが変わるのに対し、画像や空白の高さを変えてあげれば良いのです。

具体的には、**端末の大きさに対する割合で大きさを指定**すれば良いです。

こちらの例をご覧ください。

<img width=70% src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1694109/0189cfa9-7788-4e0c-186b-cef0d909d023.jpeg">

赤い四角の大きさを、黒い四角の大きさに対して50%と指定した例です。
黒い四角の大きさに対して赤い四角の大きさが変化していることがわかります。
また、どちらの四角でも黒い四角の1/4を赤い四角が埋めていることとなり、
黒い四角に対する赤い四角の見え方が変わっていません。

このように端末の大きさに対する割合で大きさを指定することによって、
端末に対する見え方を変えずにレイアウトすることが可能です。

端末の大きさの取得は、

```dart
MediaQuery.of(context).size.height, //高さ
MediaQuery.of(context).size.width　//幅
```

で可能です。
これを使ってレイアウト調整した例を見てみましょう。

#OKな例#

上記の解決策を反映してレイアウト調整したアプリの画像とコードは以下の通りです。

iPhone 13 Pro Max

<img width=30% src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1694109/26d4e320-a95a-5d86-287c-55329c97f4f6.png">

```dart
class OKLayout extends StatelessWidget {
  const OKLayout({Key? key}) : super(key: key);

  static const double _pictureTopMarginRatio = 0.1;
  static const double _pictureHeightRatio = 0.3;
  static const double _pictureBottomMarginRatio = 0.1;
  static const double _buttonMarginRatio = 0.075;
  static const double _buttonHeightRatio = 0.05;
  static const double _buttonWidthRatio = 0.4;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text("OK Layout"),
        ),
        body: Builder(builder: (context) { //※1
          double _height = MediaQuery.of(context).size.height;
          double _width = MediaQuery.of(context).size.width;
          return SafeArea(
            child: Center(
              child: Column(
                children: [
                  SizedBox(
                    height: _height * _pictureTopMarginRatio,
                  ),
                  SizedBox(
                    width: _height * _pictureHeightRatio,
                    height: _height * _pictureHeightRatio,
                    child: Image.asset(
                      "images/forQiita.png",
                    ),
                  ),
                  SizedBox(
                    height: _height * _pictureBottomMarginRatio,
                  ),
                  SizedBox(
                    height: _height * _buttonHeightRatio,
                    width: _width * _buttonWidthRatio,
                    child: ElevatedButton(
                      onPressed: () {},
                      child: const Text("Button 1"),
                    ),
                  ),
                  SizedBox(
                    height: _height * _buttonMarginRatio,
                  ),
                  SizedBox(
                    height: _height * _buttonHeightRatio,
                    width: _width * _buttonWidthRatio,
                    child: ElevatedButton(
                      onPressed: () {},
                      child: const Text("Button 2"),
                    ),
                  ),
                  SizedBox(
                    height: _height * _buttonMarginRatio,
                  ),
                  SizedBox(
                    height: _height * _buttonHeightRatio,
                    width: _width * _buttonWidthRatio,
                    child: ElevatedButton(
                      onPressed: () {},
                      child: const Text("Button 3"),
                    ),
                  ),
                ],
              ),
            ),
          );
        }),
      ),
    );
  }
}
```

※1 MaterialApp以下でないとMediaQuery.of(context)が使えないため、Builderを挟んでいます。

これをiPod touchや、iPad Pro (12.9-inch)にインストールしてみると次のようになります。

iPod touch
<img width=30% src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1694109/fa406734-b1ad-6978-15f0-78b01e1d694a.png">

iPad Pro (12.9-inch)
<img width=50% src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1694109/bd6078e4-4e21-7196-f2aa-4145cd00adef.png">

端末の大きさが変わってもエラーやレイアウトの崩れなくレイアウトをすることができました。


##まとめ##

今回は端末に依存しない、フレキシブルなレイアウトを作成するコツについて解説しました。

**端末の大きさ × 割合**で調整することで、端末に依存しないレイアウトを作成可能です。

今回のコードは以下のGitHubにあげています。
ご自由に参照ください。

https://github.com/Umigishi-Aoi/SampleFlutterLayout


レイアウトの調整は地味に時間のかかる部分だと思います。
手戻りないようにするためにも、ぜひ今回紹介した方法を使ってみて下さい！