【Flutter】そのおじさん、オーバーフローしてませんか？

# プロローグ

Flutterの勉強を始めて1ヶ月。
Aさんの元についに初めての案件が！

**「おじさんが痩せたり太ったりするモバイルアプリ作成」**

仕様を見ると、
**「スクロールなしの1画面に文字とボタンとおじさんの画像を表示するだけ」**
らしい。

簡単そうだったので、Aさんは早速作業開始！
意気揚々に作り始めて30分。

Aさん 「できました！」
と自信満々にクライアントに見せる。 

早速 **iPhone13** で確認作業が始まる。

クライアント 「うん、いい感じ！SafeAreaの対応もばっちりだし！！」

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638388/0c09e06c-153b-c905-f3b9-65d65adf642a.gif" width="300">

…でも、 **iPhoneSE 第一世代で確認すると、あららら？**

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638388/bd9513f0-0c24-881f-694d-689599bedc4e.png" width="300">

クライアント  **「おじさんがオーバーフローしちゃってます！」**

さあ大変！！

# 実装したコード

Aさんは自分の実装したコードを見直します。
iPhone13だと一つ一つの要素間のスペースが良い感じだけど、
iPhoneSE 第一世代の方はスペースが大きい気がするので、 `SizedBox` 周りを修正した方が良さそう！

```dart
class _OzisanPageState extends State<OzisanPage> {
  bool _isSlimMan = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(20),
          child: Column(
            children: <Widget>[
              Expanded(
                child: Column(
                  children: [
                    const SizedBox(height: 20), // こことか
                    Text(
                      'おじさんが\n痩せたり太ったりする\nアプリです。',
                      style: Theme.of(context).textTheme.headline5,
                      textAlign: TextAlign.center,
                    ),
                    const SizedBox(height: 35), // こことか
                    const Text(
                      '痩せてるおじさんも、太ってるおじさんも、\n'
                      'どっちもイカしてる。',
                      textAlign: TextAlign.center,
                    ),
                    const SizedBox(height: 120), // こことか
                    Image.asset(
                      _isSlimMan ? 'assets/slim.PNG' : 'assets/fat.PNG',
                      height: 200,
                      fit: BoxFit.fitHeight,
                    ),
                  ],
                ),
              ),
              _buttonSection(),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buttonSection() {
    return SizedBox(
      width: double.infinity,
      child: ElevatedButton(
        onPressed: () => setState(() => _isSlimMan = !_isSlimMan),
        child: Text(_isSlimMan ? 'リバウンドする' : 'ダイエットする'),
      ),
    );
  }
}
```



# スペースの修正

まず、 `Expanded` で動的にスペースを変更するようにします。
`Text` は `Expanded` の真ん中に来るよう `Center` をつけて調整。

```diff_dart
                 child: Column(
                   children: [
-                    const SizedBox(height: 20),
-                    Text(
-                      'おじさんが\n痩せたり太ったりする\nアプリです。',
-                      style: Theme.of(context).textTheme.headline5,
-                      textAlign: TextAlign.center,
+                    Expanded(
+                      child: Center(
+                        child: Text(
+                          'おじさんが\n痩せたり太ったりする\nアプリです。',
+                          style: Theme.of(context).textTheme.headline5,
+                          textAlign: TextAlign.center,
+                        ),
+                      ),
                     ),
-                    const SizedBox(height: 35),
-                    const Text(
-                      '痩せてるおじさんも、太ってるおじさんも、\n'
-                      'どっちもイカしてる。',
-                      textAlign: TextAlign.center,
+                    const Expanded(
+                      child: Center(
+                        child: Text(
+                          '痩せてるおじさんも、太ってるおじさんも、\n'
+                          'どっちもイカしてる。',
+                          textAlign: TextAlign.center,
+                        ),
+                      ),
                     ),
-                    const SizedBox(height: 120),
-                    Image.asset(
-                      _isSlimMan ? 'assets/slim.PNG' : 'assets/fat.PNG',
-                      height: 200,
-                      fit: BoxFit.fitHeight,
+                    Expanded(
+                      child: Image.asset(
+                        _isSlimMan ? 'assets/slim.PNG' : 'assets/fat.PNG',
+                        height: 200,
+                        fit: BoxFit.fitHeight,
+                      ),
                     ),
                   ],
                 ),
               ),
```

するとこんな感じ。

| iPhone13 | iPhoneSE 1st |
| -- | -- |
| ![スクリーンショット 2022-01-30 11.55.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638388/1e147467-7b4c-ec90-732e-8f03886690f5.png) | ![スクリーンショット 2022-01-30 11.54.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638388/11461266-b4f4-41ae-82e1-4910b8f5c66f.png) |


うーん、ちょっとスペースが空きすぎる部分があるので `flex` で調整！
また、空白部分のサイズを微調整するために、
`flex: 1` と同様の効果がありスッキリ書ける `Spacer` ウィジェットを使用。

```diff_dart
           child: Column(
             children: <Widget>[
               Expanded(
                 child: Column(
                   children: [
                     Expanded(
+                      flex: 2,
                       child: Center(
                         child: Text(
                           'おじさんが\n痩せたり太ったりする\nアプリです。',
                           style: Theme.of(context).textTheme.headline5,
                           textAlign: TextAlign.center,
                         ),
                       ),
                     ),
                     const Expanded(
+                      flex: 1,
                       child: Center(
                         child: Text(
                           '痩せてるおじさんも、太ってるおじさんも、\n'
                           'どっちもイカしてる。',
                           textAlign: TextAlign.center,
                         ),
                       ),
                     ),
+                    const Spacer(),
                     Expanded(
+                      flex: 3,
                       child: Image.asset(
                         _isSlimMan ? 'assets/slim.PNG' : 'assets/fat.PNG',
-                        height: 200,
-                        fit: BoxFit.fitHeight,
                       ),
                     ),
+                    const Spacer(),
                   ],
                 ),
               ),
               _buttonSection(),
             ],
           ),
```

するとこんな感じ。

| iPhone13 | iPhoneSE 1st |
| -- | -- |
| ![スクリーンショット 2022-01-30 11.57.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638388/7e287748-fa96-461f-88dd-add3b6a3797f.png) | ![スクリーンショット 2022-01-30 11.58.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638388/62663729-a0f3-8d46-0a6c-a4d8382018ce.png) |


うん、良い感じ！！

# もう一度、クライアントに確認…！

Aさん「今度こそできました！！」

クライアント「どれどれ、見させてもらうよ！」

iPhoneSEで確認。

クライアント **「画面崩れもなく良い感じ！！」**

そしてAさんは無事、初めての案件を終えたのであった……。

クライアント **「……ちょっと待って！このスマホだとヤバいことになってる！」**
喜んだのも束の間、慌てて確認する。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638388/90fcb7fd-7d17-9cfd-8ec0-6abe078f7efc.png" width="300">


確かにヤバいことになってる。
でもなんで？

# 端末の文字の大きさは変えられる

Aさんは気づいた。
**端末の設定から端末自体の文字の大きさを変えると、アプリの文字の大きさも変わってしまう** ことに。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638388/90a3f4e8-e5bb-3e45-1729-d78497e9a4c1.png" width="300">
急いでデスクに戻り対応法を調べてみると、Flutterでは `Text` の `textScaleFactor` というプロパティで大きさを変えないようにできることがわかった。

早速実装！

```diff_dart
   @override
   Widget build(BuildContext context) {
     return Scaffold(
       body: SafeArea(
         child: Padding(
           padding: const EdgeInsets.all(20),
           child: Column(
             children: <Widget>[
               Expanded(
                 child: Column(
                   children: [
                     Expanded(
                       flex: 2,
                       child: Center(
                         child: Text(
                           'おじさんが\n痩せたり太ったりする\nアプリです。',
                           style: Theme.of(context).textTheme.headline5,
                           textAlign: TextAlign.center,
+                          textScaleFactor: 1,
                         ),
                       ),
                     ),
                     const Expanded(
                       flex: 1,
                       child: Center(
                         child: Text(
                           '痩せてるおじさんも、太ってるおじさんも、\n'
                           'どっちもイカしてる。',
                           textAlign: TextAlign.center,
+                          textScaleFactor: 1,
                         ),
                       ),
                     ),
                     const Spacer(),
                     Expanded(
                       flex: 3,
                       child: Image.asset(
                         _isSlimMan ? 'assets/slim.PNG' : 'assets/fat.PNG',
                       ),
                     ),
                     const Spacer(),
                   ],
                 ),
               ),
               _buttonSection(),
             ],
           ),
         ),
       ),
     );
   }
 
   Widget _buttonSection() {
     return SizedBox(
       width: double.infinity,
       child: ElevatedButton(
         onPressed: () => setState(() => _isSlimMan = !_isSlimMan),
-        child: Text(_isSlimMan ? 'リバウンドする' : 'ダイエットする'),
+        child: Text(
+          _isSlimMan ? 'リバウンドする' : 'ダイエットする',
+          textScaleFactor: 1,
+        ),
       ),
     );
   }
```




全ての `Text` に `textScaleFactor: 1` を指定していたので、全体で統一するように修正！

```diff_dart
class MyApp extends StatelessWidget {
   const MyApp({Key? key}) : super(key: key);
   @override
   Widget build(BuildContext context) {
     return MaterialApp(
       theme: ThemeData(
         primarySwatch: Colors.blue,
       ),
       home: const OzisanPage(),
+      builder: (context, child) {
+        // これで全体のTextのサイズを統一できる
+        final mediaQueryData = MediaQuery.of(context);
+        return MediaQuery(
+          data: mediaQueryData.copyWith(textScaleFactor: 1),
+          child: child!,
+        );
+      },
     );
   }
 }
 
 class OzisanPage extends StatefulWidget {
   const OzisanPage({Key? key}) : super(key: key);
 
   @override
   State<OzisanPage> createState() => _OzisanPageState();
 }
 
 class _OzisanPageState extends State<OzisanPage> {
   bool _isSlimMan = false;
 
   @override
   Widget build(BuildContext context) {
     return Scaffold(
       body: SafeArea(
         child: Padding(
           padding: const EdgeInsets.all(20),
           child: Column(
             children: <Widget>[
               Expanded(
                 child: Column(
                   children: [
                     Expanded(
                       flex: 2,
                       child: Center(
                         child: Text(
                           'おじさんが\n痩せたり太ったりする\nアプリです。',
                           style: Theme.of(context).textTheme.headline5,
                           textAlign: TextAlign.center,
-                          textScaleFactor: 1,
                         ),
                       ),
                     ),
                     const Expanded(
                       flex: 1,
                       child: Center(
                         child: Text(
                           '痩せてるおじさんも、太ってるおじさんも、\n'
                           'どっちもイカしてる。',
                           textAlign: TextAlign.center,
-                          textScaleFactor: 1,
                         ),
                       ),
                     ),
                     const Spacer(),
                     Expanded(
                       flex: 3,
                       child: Image.asset(
                         _isSlimMan ? 'assets/slim.PNG' : 'assets/fat.PNG',
                       ),
                     ),
                     const Spacer(),
                   ],
                 ),
               ),
               _buttonSection(),
             ],
           ),
         ),
       ),
     );
   }
 
   Widget _buttonSection() {
     return SizedBox(
       width: double.infinity,
       child: ElevatedButton(
         onPressed: () => setState(() => _isSlimMan = !_isSlimMan),
         child: Text(
           _isSlimMan ? 'リバウンドする' : 'ダイエットする',
-          textScaleFactor: 1,
         ),
       ),
     );
   }
```

するとこんな感じ。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638388/ea991fe0-6221-13e8-7de8-074f16b38baa.png" width="300">

良い感じ！！

### 補足

今回は `textScaleFactor: 1` で固定しましたが、 **できれば端末の文字サイズが大きい場合はアプリの文字サイズも大きくした方が良い** と思います。
ただ、大きくし過ぎると今回のように画面崩れを起こしたり、エリア内にテキストがあまり表示されず見づらくなる場合もあると思います。

その場合は下記のようにアプリ全体の大きくなる範囲指定をするのもありだと思います。

```diff_dart
       builder: (context, child) {
         final mediaQueryData = MediaQuery.of(context);
+        // サイズを1.0~1.5の範囲で固定
+        final constrainedTextScaleFactor =
+            mediaQueryData.textScaleFactor.clamp(1.0, 1.5);
+
         return MediaQuery(
-          data: mediaQueryData.copyWith(textScaleFactor: 1),
+          data: mediaQueryData.copyWith(
+            textScaleFactor: constrainedTextScaleFactor,
+          ),
           child: child!,
         );
       },
```

<br>

ただ、このアプリでは`textScaleFactor: 1.5` を指定してしまうと、テキストがExpandedの範囲内におさまらず、一部の文字が見えなくなってしまいます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638388/43d7c735-88fe-558a-7747-429a74067044.png" width="300">


この場合は `AutoSizeText` というパッケージを使うと、良い感じになります。
auto_size_text: https://pub.dev/packages/auto_size_text

※コメントいただきまして、プラグインを使わずに [FittedBox](https://api.flutter.dev/flutter/widgets/FittedBox-class.html)で表示可能です！

ですが、今回のアプリの場合はこのパッケージを使ったとしても、`textScaleFactor: 1` を指定した時とフォントのサイズはほとんど変わらないかな？といった形になります。（ボタンの文字だけ大きくなった感じ）


| textScaleFactor: 1 | textScaleFactor:1.5 + AutoSizeText |
| -- | -- |
| ![スクリーンショット 2022-01-30 11.58.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638388/a0624d61-1ee1-2a8a-8b5d-5400dcace5a7.png) | ![スクリーンショット 2022-01-30 12.13.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/638388/c9dd348a-3e7a-a680-87c2-c6b21f3f6bd8.png)|

```diff_dart
                 child: Column(
                   children: [
                     Expanded(
                       flex: 2,
                       child: Center(
-                        child: Text(
+                        child: AutoSizeText(
                           'おじさんが\n痩せたり太ったりする\nアプリです。',
                           style: Theme.of(context).textTheme.headline5,
                           textAlign: TextAlign.center,
+                          minFontSize: 0,
+                          stepGranularity: 0.1,
                         ),
                       ),
                     ),
                     const Expanded(
                       flex: 1,
                       child: Center(
-                        child: Text(
+                        child: AutoSizeText(
                           '痩せてるおじさんも、太ってるおじさんも、\n'
                           'どっちもイカしてる。',
                           textAlign: TextAlign.center,
+                          minFontSize: 0,
+                          stepGranularity: 0.1,
                         ),
                       ),
                     ),
                     const Spacer(),
                     Expanded(
                       flex: 3,
                       child: Image.asset(
                         _isSlimMan ? 'assets/slim.PNG' : 'assets/fat.PNG',
                       ),
                     ),
                     const Spacer(),
                   ],
                 ),
               ),
               _buttonSection(),
             ],
           ),
```


もしもこの画面で`textScaleFactor: 1.5` などのサイズに対応する場合は、端末の画面サイズごとにスペースの幅を変えるなど、別途対策をした方が良いかな？と思います。

# クライアントの最終確認

Aさん「すみません、今度こそ…！！」

クライアント「ありがとう、見させてもらうよ！」

そして、クライアントの確認作業が始まる。

クライアント「…うん、文字サイスが大きくても問題ないね **！今度こそ大丈夫だよ、ありがとう！！** 」

こうして、Aさんの初案件は無事幕を閉じたのであった。

# 終わりに

スクロールなしの画面を取り入れることが多いアプリを作っており、どう実装したものかと悩むことが多く、現状の対応策を記事にしてみました。
iOSでは小さい画面での確認のため、iPhoneSE第一世代（iPhone7や8の拡大表示も同じくらいのサイズになる）で問題なく表示されることを毎回確認しています。

これがベストプラクティスかは分からない部分があり、こう対応すべきだよとか、他にはこんな対応法もあるよというコメントをTwitterやこちらの記事に記載していただけると非常に嬉しいです。

（今回はiOSでの確認記事でしたが、Androidはもっと様々な形の端末があると思っており、Androidの方は最低でもこの端末で確認しているよなど教えていただければ嬉しいです）

Twitter: [https://twitter.com/oke331](https://twitter.com/oke331)

今回のアプリのソースコード
https://github.com/oke331/ozisan_overflow

※この記事で使用しているおじさんはiPadで僕が適当に書いたおじさんです。ダウンロードしていただいても構いません。