【Flutter】画面回転に対応する

<!--
UI/UX
エラー発生時の処理
画面回転・様々な画面サイズ対応
Theme の適切な利用・ダークモードの対応
多言語対応
アニメーションなど
CI/CD
ビルド
テスト
リント
フォーマット
仮のデプロイ環境
-->

# はじめに
画面回転の対応について調べたので、記事にしたいと思います。

実装したのは下記になります。

https://github.com/AppDeveloperMLLB/responsive_app_sample

# 画面回転に対応するために必要なこと
いくつかの参考記事を読んだ結果、以下が必要だという結論に至りました。
- 画面回転に対応するための実装知識
- 画面回転時のデザインの知識

Flutterで画面回転に対応する記事はたくさんあります。ただ、デザインについて言及している記事はあまりみかけなかったです。
回転に対応するだけの実装なら簡単だと思いますが、効果的に回転に対応するのは実装の知識だけではダメだと思いました。
どの画面で入れるのがいいのか？　どういうデザインにすればいいのか？　についての知識が必要だと思います。

デザインの知識を得るためには、普段使っているアプリを回転させてみて、どういった画面になるのか、または回転しないのかを確認してみるのが良いと考えています。
例えば、Youtubeでは以下のような動作になっていました。
- 検索して動画一覧を表示する画面は縦向き固定
- 一覧から動画を選択し再生する画面では、縦横両方をサポート
（スマホで確認したので、タブレットでは違う動作かもしれないです）

この例から私は以下のようなパターンがあるのかなと思いました。
- 動画一覧のような画面では縦向き固定で、横向きに対応する必要はないっぽい
- 動画を表示するような画面では縦と横両方に対応した方が良さそう

私自身まだまだ勉強中ですが、例えば、設定画面ならどうなるのか？、ログイン画面ならどうなるのか？など、いろんなアプリで試してみると良さそうです。また、スマホとタブレットで比較してもいいかもしれません。
こういうのが、まとまっているサイトがあればいいんですけどね。
もし知っている方いれば教えていただけると助かります。

# 画面回転に対応する実装
実装の手順としては以下の順序で行いました。
- 画面毎の向きの対応を決める
- 縦・横両方の対応を入れる画面では、縦と横のデザインを決める
- 実装する

## 画面毎の向きの対応を決める
前提として、実装したアプリは、一覧画面と詳細画面を表示するアプリです。
YouTubeのアプリを参考にし、画面毎の対応は以下にします。
- 一覧画面は縦向き固定
- 詳細画面は縦・横両方対応

## 縦・横両方の対応を入れる画面では、縦と横のデザインを決める
両対応が必要な詳細画面は縦と横で以下のようなデザインにしました。

## 実装する
### 画面毎にサポートする向きを設定する
Flutterでは、`SystemChrome.setPreferredOrientations`でサポートする画面の向きを設定できます。
以下のように使います。
```dart
// 縦向きに変更
SystemChrome.setPreferredOrientations([
  DeviceOrientation.portraitDown,
  DeviceOrientation.portraitUp
]); 
```

デフォルトで縦向き固定にしたい場合は上記をmainで呼んでおくといいと思います。
iPadの場合は別途対応が必要になります。詳細は以下をご参照ください。

https://hiyoko-programming.com/1575/

私がサンプルで実装したアプリでは、一覧画面と詳細画面の遷移のタイミングで設定するようにしています。

### 縦と横でUIを実装する
縦と横で別々にUIを実装するのは非常に簡単で、以下の二つどちらかの方法を使い、画面の方向を検知し、それぞれでUIを実装します。
-　MediaQuery
-　OrientationBuilder

```dart:use_media_query.dart
class UseMediaQueryPage extends StatelessWidget {
  const UseMediaQueryPage({super.key});

  @override
  Widget build(BuildContext context) {
    return MediaQuery.of(context).orientation == Orientation.portrait
        ? Container(
            child: const Text("縦向き"),
          )
        : Container(
            child: const Text("横向き"),
          );
  }
}
```

```dart:use_orientation_builder_page.dart
class UseOrientationBuilderPage extends StatelessWidget {
  const UseOrientationBuilderPage({super.key});

  @override
  Widget build(BuildContext context) {
    return OrientationBuilder(
      builder: (context, orientation) {
        return orientation == Orientation.portrait
            ? Container(
                child: const Text("縦向き"),
              )
            : Container(
                child: const Text("横向き"),
              );
      },
    );
  }
}
```

### 実装のTips
画面の向きに対して実装するだけなら上記の情報で問題ないのですが、参考記事を読んでいて、役に立ちそうなTipsがあったので紹介します。

#### Widgetをバーツ化して、レイアウトに合わせて組みかえられるようにする
Androidアプリの開発では、Fragmentsという再利用可能なコンポーネントを作成し、画面に合わせて組み換えて、レスポンシブなデザインに対応するみたいです。
Flutterでいうなら、再利用可能なWidgetsを作成するという形になると思います。

実際のコードで考えてみましょう。
以下のページを実装しました。
（赤枠と数字は分割して捉えるために記載しています）

![Simulator Screenshot - iPhone 15 - 2023-11-08 at 17.29.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/590e7301-19f8-26da-63c7-6eb1dc6a5651.png)

再利用可能ではない状態とは下記のようなコードです。
```dart
@override
Widget build(BuildContext context) {
  return Padding(
    padding: const EdgeInsets.all(16.0),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.center,
      children: [
        //---- 1の部分 ------
        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(
              "username",
              style: Theme.of(context).textTheme.headlineLarge,
            ),
          ),
        ],
      ),
      //---------------
      //---- 余白 ------
      const SizedBox(
        height: 16,
      ),
      //---------------
      //--- 2の部分 ----
      SizedBox(
        width: width,
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            _createRow(
              context: context,
              label: "ID",
              content: user.id,
            ),
            const Divider(
              thickness: 1,
              height: 8,
              color: Colors.black,
            ),
            const SizedBox(
              height: 8,
            ),
            _createRow(
              context: context,
              label: "email",
              content: user.email,
            ),
            const Divider(
              thickness: 1,
              height: 8,
              color: Colors.black,
            ),
            const SizedBox(
              height: 8,
            ),
            _createRow(
              context: context,
              label: "age",
              content: "${user.age}",
            ),
            const Divider(
              thickness: 1,
              height: 8,
              color: Colors.black,
            ),
          ],
        ),
      ),
    ],
  ),);
}

  Widget _createRow({
    required BuildContext context,
    required String label,
    required String content,
  }) {
    return Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        _createLabel(label, context),
        Flexible(
          child: Align(
            alignment: Alignment.centerRight,
            child: _createText(content, context),
          ),
        ),
      ],
    );
  }

  Widget _createLabel(
    String text,
    BuildContext context,
  ) {
    return Text(
      text,
      maxLines: 3,
      style: Theme.of(context).textTheme.titleLarge,
    );
  }

  Widget _createText(
    String text,
    BuildContext context,
  ) {
    return Padding(
      padding: const EdgeInsets.only(left: 8.0),
      child: Text(
        text,
        style: Theme.of(context).textTheme.titleMedium,
      ),
    );
  }
```

見ての通り、全部べた書きで書いているようなコードです。
再利用可能なコードとは以下のようなコードになります。

```dart
Padding(
  padding: const EdgeInsets.all(16.0),
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.center,
    children: [
      //---- 1の部分 ------
      UserNameView(name: user.name),
      //---------------
      //---- 余白 ------
      const SizedBox(
        height: 16,
      ),
      //---------------
      //---- 2の部分 ------
      UserDetailsView(user: user),
      //---------------
    ],
  ),
)
```

べた書きで長々と書いていた部分を別Widgetに切り出しました。
別Widgetに切り出す単位は、デザインによって変わると思います。
個人的な指標としては、デジタル庁が出している[デザインシステム](https://www.figma.com/file/FOYwmK7GIS0PSHKV6DYed9/Design-System-1.4.0-(Community)?type=design&node-id=6406-6721&mode=design&t=bl67KxWCGkGSSTK4-0)のコンポーネント、テンプレート単位が良いと思ってます。

今回の実装では、横向きのデザインを下記のようにしました。
![Simulator Screenshot - iPhone 15 - 2023-11-09 at 11.12.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/a57499d6-759f-330e-dbe3-955067abc7c6.png)

縦向きの場合は1と2を縦並び、横向きの場合は1と2を横並びにするデザインなので、`UserNameView`と`UserDetailsView`という単位で再利用可能なWidgetとして切り出しています。
そうすることで以下のように縦向きと横向きの実装が、Widgetを配置調整するだけで可能になります。

```dart
OrientationBuilder(
  builder: (context, orientation) {
    return orientation == Orientation.portrait
      // 縦向きは縦ならべ
      ? Padding(
          padding: const EdgeInsets.all(16.0),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.center,
            children: [
              UserNameView(name: user.name),
              const SizedBox(
                height: 16,
              ),
              UserDetailsView(user: user),
            ],
          ),
        )
      // 横向きは横並べ
      : Row(
          children: [
            Expanded(
              flex: 4,
              child: Align(
                alignment: Alignment.centerRight,
                child: UserNameView(
                  name: user.name,
                ),
              ),
            ),
            Expanded(
              flex: 6,
              child: Padding(
                padding: const EdgeInsets.symmetric(
                  horizontal: 32.0
                ),
                child: UserDetailsView(user: user),
              ),
            ),
          ],
        );
  },
),
```

場合によっては、横向きと縦向きで別のWidgetを使わないとだめな場合もあると思いますが、開発速度と工数を減らすことを目的とすると、縦横で同じWidgetとなるようにデザイン段階から考えて、調整することが良いのではないかと思います。

#### device_previewを使う

device_previewを使うと、さまざまなデバイス上の表示を簡単に確認できるみたいです。
以下の記事を見ていただくとわかりやすいかと思います。

https://zenn.dev/flutteruniv_dev/articles/flutter_device_preview



# おわりに
実装にあたっては下記の記事を参考にしました。
画面回転だけではなく、レスポンシブ対応についてもわかりやすい良記事でした。

https://docs.flutter.dev/ui/layout/responsive/adaptive-responsive

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

https://medium.com/flutter-community/build-responsive-uis-in-flutter-fd450bd59158

また上記の記事の中で、Tabletとスマートフォンをどうやって区別したらいいかということに言及していて、iOSとAndroidで指標になりそうなデータを見つけたので共有させていただきます。

[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 など）。






<!--
## レスポンシブ対応
レスポンシブな対応をするために必要な要素は大きく二つあります。
- レスポンシブに対応するための実装の知識
- レスポンシブなデザインの知識


### レスポンシブに対応するための実装の知識
当たり前ですが、どうやったらレスポンシブな実装ができるのか、実装の知識がないと対応ができません。

### レスポンシブなデザインの知識
Flutterでレスポンシブ対応について調べると、実装方法の記事がヒットしがちですが、そもそも、レスポンシブなデザインの知識について知らないと、効果的に実装の知識を活かせないと思います。

例えば、YouTubeのアプリで画面回転について調べたところ、検索して動画を一覧から選択する画面は縦向き固定、一覧から動画をタップして再生画面に遷移したら横と縦両方に対応する動作となっていました。

上記の例のように、どの画面でレスポンシブ対応を入れるか、どのように取り入れるかは、レスポンシブデザインのセオリーについて知らないと入れることができません。

デザインについての知識をつけるためには以下がいいと思っています。
- 実際のアプリを確認する
  普段使っているアプリを横向きで使ってみたり、タブレットで使ってみてデザインの違いを知ると、パターンがわかってくると思います
- デザインの参考になるサイトを確認する
デザインの参考は、[UIPocket](https://ui-pocket.com/apps/)を使用していますが、こちらのサイトにはタブレット向けと、横向きのデザインがないので、いいサイトあれば教えてください

## レスポンシブ対応実践




https://docs.flutter.dev/ui/layout/responsive/adaptive-responsive

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

https://medium.com/flutter-community/build-responsive-uis-in-flutter-fd450bd59158

https://medium.com/flutter-community/making-cross-platform-flutter-landing-page-responsive-7fffe0655970

https://stackoverflow.com/questions/49704497/how-to-make-flutter-app-responsive-according-to-different-screen-size

https://www.youtube.com/watch?v=n6Awpg1MO6M&t=694s

https://youtube.com/watch?v=eikOZzfc0l4&t=11s