【Flutter】スマホとタブレットのレスポンシブ対応

# はじめに
スマホとタブレットに対応するためのレスポンシブ対応にトライしてみたいと思います

# 対応前
対応前のコードは下記にあります。
https://github.com/AppDeveloperMLLB/responsive_app_sample/tree/feat/support_orientation

上記は、[【Flutter】画面回転に対応する](https://qiita.com/MLLB/items/d2fdc69ce375597abcb5)で実装したコードになります。
スマートフォンで縦横の画面に対応しましたが、タブレットで表示すると以下のような表示になります。

### 一覧画面
タブレット縦
![Simulator Screenshot - iPad Pro (12.9-inch) (6th generation) - 2023-11-09 at 21.16.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/3aa8eb25-64ab-4889-3450-654c92689f74.png)
タブレット横
![Simulator Screenshot - iPad Pro (12.9-inch) (6th generation) - 2023-11-09 at 21.16.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/756134ec-9b86-dda2-ee2d-ad49445304ea.png)

### 詳細画面
タブレット縦
![Simulator Screenshot - iPad Pro (12.9-inch) (6th generation) - 2023-11-09 at 21.16.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/556d461b-240a-cd06-4120-617775ffaeaa.png)
タブレット横
![Simulator Screenshot - iPad Pro (12.9-inch) (6th generation) - 2023-11-09 at 21.16.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/f2d38e34-515c-c290-e923-330de718ae23.png)

スマホだと違和感のない表示でしたが、タブレットだと以下が気になりました。
- 文字が小さい
- 余白が目立つ
- 横長になりすぎる箇所がある

それぞれ対応していきたいと思います。

# タブレット対応
上記の気になる点を踏まえ、以下の対応をしていきます。
- 文字の大きさの対応
- レイアウトの対応


## 文字の大きさ対応
いくつかの方法があるみたいです。

参考記事

https://prolongservices.medium.com/flutter-font-size-based-on-screen-size-auto-size-text-3-ways-f25a68732b1e

https://www.blup.in/blog/how-to-adjust-flutter-font-size-based-on-screen-size-top-3-methods



上記の記事を参考にすると以下の方法がありました。
- auto_size_textパッケージを使う
- FittedBox
- LayoutBuilderを使う
- MediaQueryを使う
- flutter_screenutilパッケージを使う

個人的に良さそうだと思った`MediaQueryを使う方法`と`flutter_screenutilパッケージを使う方法`について説明したいと思います。

#### MediaQuery
MediaQueryを使う方法は、表示領域の割合でフォントサイズを決める方法です。
以下のような形で、画面の幅に応じてフォントを決めるという方法です。
```dart
double screenWidth = MediaQuery.of(context).size.width;
double large = screenWidth * 0.06;
double medium = screenWidth * 0.04;
double small = screenWidth * 0.02;

final largeText = Text("large", style: TextStyle(fontSize: large),);
final mediumText = Text("medium", style: TextStyle(fontSize: medium),);
final smallText = Text("small", style: TextStyle(fontSize: small),);
```

Listの文字にmediumを設定すると下記のようになりました。

![Simulator Screenshot - iPad Pro (12.9-inch) (6th generation) - 2023-11-10 at 21.22.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/891251cc-dae8-1a75-f40c-46d699a4d9c7.png)

![Simulator Screenshot - iPad Pro (12.9-inch) (6th generation) - 2023-11-10 at 21.22.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/117512f3-15f8-eed9-188d-d6b35094595c.png)

スマートフォンでもレイアウト崩れはなかったです。

![Simulator Screenshot - iPhone 15 - 2023-11-10 at 21.35.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/287648b9-7283-38b1-5475-5bfca8b441a1.png)


こちらを取り入れる際は、コード例のように、デザイン時点でテキストの大きさの種類を定義して、画面幅に対してそれぞれいくつにするかを決めておく必要がありそうです。

#### flutter_screenutil

[flutter_screenutil](https://pub.dev/packages/flutter_screenutil)を導入して、下記のようにするとレスポンシブ対応ができます。
```dart
return ScreenUtilInit(
  // 基準となるデバイスサイズ
  designSize: const Size(360, 690),
  // デバイスの解像度に合わせて文字サイズが小さくなりすぎることを防ぐ
  minTextAdapt: true,
  // スプリットスクリーンーモードに対応するか
  splitScreenMode: true,
  builder: (context, child) {
    return child!;
  },
  child: MaterialApp(
    title: 'Responsive App Sample',
    theme: ThemeData(
    colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      useMaterial3: false,
    ),
    home: const ListPage(),
  ),
);
```

使うときは以下のように、フォントサイズの指定時に.spをつけるだけです。
```dart
Text(
  "text",
  style: TextStyle(
    fontSize: 20.sp,
  ),
)
```

![Simulator Screenshot - iPhone 15 - 2023-11-10 at 21.40.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/2b064ea9-e817-449c-c770-0051bc7fdacb.png)

![Simulator Screenshot - iPad Pro (12.9-inch) (6th generation) - 2023-11-10 at 21.49.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/6ac23289-c290-4520-5e58-92153b97cd05.png)

![Simulator Screenshot - iPad Pro (12.9-inch) (6th generation) - 2023-11-10 at 21.49.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/65e3da20-3190-b344-5fed-44702f3892e4.png)


このパッケージの利点は、高さと幅を指定もレスポンシブ対応できることです。
フォントと同じく、高さには.h、幅には.wをつけると、デバイスの解像度に合わせて調整してくれます。

```dart
SizedBox(
  width: 100.w
  height: 100.h
)
```

このパッケージを使うとGoldenTestを実装する際に注意が必要みたいです。

https://qiita.com/tai_miya/items/55e96f8acd881a729a4b

## レイアウトの対応
レイアウトの対応の対策は以下の二つがあると思います。
- 余白や大きさをレスポンシブに対応する
- デザインをスマホとタブレットで変える

### 余白や大きさをレスポンシブに対応する
スマートフォンように大きさや余白を調整しているので、下の画像のように余白の大小があったり、Widgetのサイズが小さすぎたりします。

![Simulator Screenshot - iPad Pro (12.9-inch) (6th generation) - 2023-11-09 at 21.16.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/556d461b-240a-cd06-4120-617775ffaeaa.png)

ユーザーの画像を表示する紫の円とその横の名前のコードは下記になっています。
```dart
  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        Container(
          decoration: BoxDecoration(
            color: Colors.deepPurple.shade300,
            shape: BoxShape.circle,
          ),
          width: 72,
          height: 72,
          child: const Center(
            child: Text(
              "Image",
              style: TextStyle(
                fontWeight: FontWeight.w500,
                color: Colors.white,
              ),
            ),
          ),
        ),
        const SizedBox(width: 16),
        Flexible(
          child: Text(
            name,
            style: Theme.of(context).textTheme.headlineLarge,
          ),
        ),
      ],
    );
  }
```

余白や大きさをレスポンシブに対応する方法も二つありますので以下に記載します。


#### 割合で対応
レスポンシブな対応をする場合、`width: 72`や`height: 72`のような数値による指定はレイアウト崩れの原因になります。
なので、割合で指定するように変更しました。

```dart
@override
  Widget build(BuildContext context) {
    // スマートフォンで小さくなりすぎる場合があったので、72を最小値としている
    final imageWidth = max(MediaQuery.of(context).size.width * 0.1, 72.0);
    final padding = MediaQuery.of(context).size.width * 0.025;
    // スマートフォンで小さくなりすぎる場合があったので、14を最小値としている
    final fontSize = max(MediaQuery.of(context).size.width * 0.02, 14.0);
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        Container(
          decoration: BoxDecoration(
            color: Colors.deepPurple.shade300,
            shape: BoxShape.circle,
          ),
          width: imageWidth,
          height: imageWidth,
          child: Center(
            child: Text(
              "Image",
              style: TextStyle(
                fontWeight: FontWeight.w500,
                color: Colors.white,
                fontSize: fontSize,
              ),
            ),
          ),
        ),
        SizedBox(
          width: padding,
        ),
        Flexible(
          child: Text(
            name,
            style: Theme.of(context).textTheme.headlineLarge,
          ),
        ),
      ],
    );
  }
```
スマホ
![Simulator Screenshot - iPhone 15 - 2023-11-11 at 11.55.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/8cd72605-3c9f-6935-573c-17a561b89a68.png)

![Simulator Screenshot - iPhone 15 - 2023-11-11 at 11.55.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/06a44b00-d5d6-4d36-eced-faa54fe4899d.png)

タブレット
![Simulator Screenshot - iPad Pro (12.9-inch) (6th generation) - 2023-11-11 at 08.58.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/ecb8e654-3767-131e-aff1-f5734a130584.png)

![Simulator Screenshot - iPad Pro (12.9-inch) (6th generation) - 2023-11-11 at 08.58.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/e7162f0b-f028-c4d2-a074-1983f07597b5.png)

これでユーザーの画像と名前を表示する部分に関しては、調整ができました。
（ID、email、ageの部分はまだ変わってません）

また今回は使用しませんでしたが、別の対応としては、`Flexible`(または`Expanded`)のflexで指定するという方法もありかと思います。

#### flutter_screenutilで対応
flutter_screenutilは、フォントサイズだけでなく、高さと幅に対するレスポンシブ対応も可能です。
高さには.hをつけて、幅には.wをつければレスポンシブに対応できます。

```dart
@override
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        Container(
          decoration: BoxDecoration(
            color: Colors.deepPurple.shade300,
            shape: BoxShape.circle,
          ),
          width: 72.w,
          // 高さの場合本来は72.hとするが、同じ大きさとしたいので72.wとしている
          height: 72.w,
          child: Center(
            child: Text(
              "Image",
              style: TextStyle(
                fontWeight: FontWeight.w500,
                color: Colors.white,
                fontSize: 16.sp,
              ),
            ),
          ),
        ),
        SizedBox(
          // 横幅の場合は.w, 高さの場合は.hをつける
          width: 16.w,
        ),
        Flexible(
          child: Text(
            name,
            style: Theme.of(context).textTheme.headlineLarge,
          ),
        ),
      ],
    );
  }
```

スマホ
![Simulator Screen Shot - iPhone 14 Pro Max - 2023-11-14 at 15.28.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/785dc5c5-4760-ec6c-65b9-61538bf884ee.png)

![Simulator Screen Shot - iPhone 14 Pro Max - 2023-11-14 at 15.28.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/4e2bbfd4-8677-95cd-2432-9f72b97b3bd3.png)

タブレット
![Simulator Screenshot - iPad Pro (12.9-inch) (6th generation) - 2023-11-14 at 15.51.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/2b5e5862-733f-3a0d-140e-19f9802c2a0d.png)

![Simulator Screenshot - iPad Pro (12.9-inch) (6th generation) - 2023-11-14 at 15.51.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/560f35e7-d642-9677-983c-fea2de5983c1.png)

### デザインをスマホとタブレットで変える
スマホとタブレットでデザインを別にする対応もレスポンシブの対策として有効かと思います。

今回のデザインは以下で実装します。
スマホ : リスト画面 →　詳細画面の二画面の実装

![s.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/c66750a4-fe02-fca5-d5f1-91a667e4d25e.gif)


タブレット : 左側にリスト、右側に詳細表示の一画面の実装

![th.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/6e7af88f-a58a-d8e4-df0f-ed2cf5b5d37e.gif)

以下の記事を参考にしました。

https://medium.com/flutter-community/developing-for-multiple-screen-sizes-and-orientations-in-flutter-fragments-in-flutter-a4c51b849434

#### タブレットかスマホかの判定
タブレットかスマホかの判定は横幅かアスペクト比で判定するみたいです。

iOSとAndoridのタブレットの大きさを調べると下記のようになっていたので、横幅が600以上ならタブレットと判定して問題なさそうでした。

| device | size | aspect |
| ---- | ---- | ---- |
| iPad Pro 12.9-inch (2nd generation) | 1024 x 1366 | 0.74 |
| iPad Pro 10.5-inch | 1112 x 834 | 1.33 |
| iPad Pro (12.9-inch) | 1024 x 1366 | 0.74 |
| iPad Pro (9.7-inch) | 768 x 1024 | 0.75 |
| iPad Air 2 | 768 x 1024 | 0.75 |
| iPad Mini 4 | 768 x 1024 | 0.75 |
| 7インチタブレット | 600x1024 | 0.58 |
| 10インチタブレット | 720x1280 | 0.56 |
| 10インチタブレット | 800x1280 | 0.625 |

下記が参考記事です。
[iOSのデバイスごとのサイズ](https://developer.apple.com/library/archive/documentation/DeviceInformation/Reference/iOSDeviceCompatibility/Displays/Displays.html)
[Androidのデバイスごとのサイズ](https://developer.android.com/training/multiscreen/screensizes?hl=ja)
上記の記事内の引用

> 他の最小幅の値と、一般的な画面サイズの対応を次に示します。
320dp: 通常のスマートフォンの画面（240x320 ldpi、320x480 mdpi、480x800 hdpi など）。
480dp: 5 インチ未満の大画面スマートフォン（480x800 mdpi）。
600dp: 7 インチ タブレット（600x1024 mdpi）。
720dp: 10 インチ タブレット（720x1280 mdpi、800x1280 mdpi など）。

下記のように判定する部分を実装しました。
```dart
extension BuildContextExtension on BuildContext {
  bool isTablet() {
    const tabletWidth = 600;
    //  縦の場合は横幅で、横の場合は高さでタブレットか判定する
    return MediaQuery.orientationOf(this) == Orientation.portrait
        ? MediaQuery.sizeOf(this).width > tabletWidth
        : MediaQuery.sizeOf(this).height > tabletWidth;
  }
}
```

#### タブレットとスマホの画面を出し分ける部分の実装

参照記事を真似した形になります。

```dart
class MasterDetailPage extends StatefulWidget {
  const MasterDetailPage({super.key});

  @override
  State<MasterDetailPage> createState() => _MasterDetailPageState();
}

class _MasterDetailPageState extends State<MasterDetailPage> {
  User selectedValue = userList[0];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("UserList"),
      ),
      body: OrientationBuilder(
        builder: (context, orientation) {
          return Row(
            children: <Widget>[
              Expanded(
                flex: 2,
                child: UserListView(
                  userList: userList,
                  onTap: (value) {
                    // 一覧をタップした時の動作
                    if (context.isTablet()) {
                      // タブレットの場合は詳細の表示を更新する
                      setState(() {
                        selectedValue = value;
                      });
                    } else {
                      // スマホの場合は詳細画面に遷移する
                      Navigator.push(
                        context,
                        MaterialPageRoute(
                          builder: (context) {
                            return DetailsPage(
                              user: value,
                            );
                          },
                        ),
                      );
                    }
                  },
                ),
              ),
              if (context.isTablet())
                // タブレットの場合は詳細を横に表示
                Expanded(
                  flex: 8,
                  child: Padding(
                    padding: EdgeInsets.symmetric(horizontal: 16.w),
                    child: UserDetails(
                      user: selectedValue,
                    ),
                  ),
                )
            ],
          );
        },
      ),
    );
  }
}
```

ユーザー名の部分が表示が崩れていて、微調整が必要ですが以下のような感じになってます。

タブレット

![tw.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/922cf9ce-056e-ba26-3565-5497db8f8c96.gif)

![th.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/6e7af88f-a58a-d8e4-df0f-ed2cf5b5d37e.gif)

スマホ

![sh.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/1ee62141-2056-5a4f-32a1-ce6456eb62e0.gif)

![sw.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/537f955a-e8a0-f88a-851d-4439e52029c9.gif)

# おわりに
`MasterDetailPage`の実装は、個人的にはわかりづらいので別の実装の方が良さそうな気がしてます。
下記みたいな実装の方がわかりやすいかなと思いました。

```dart
context.isTablet() ? _buildTabletPage() : _buildSmartphonePage();
```

または、GoRouterを使っている場合は画面遷移の定義をスマホ用とタブレット用に分けて、起動時に判定して、遷移の定義を使い分けるとかでも良さそうです（試してないのでできるかは不明です）

この記事のコード全体は、以下のブランチにPushしたので参考になれば幸いです。
https://github.com/AppDeveloperMLLB/responsive_app_sample/tree/feat/support_tablet