[読書会]適応型デザイン設計における、画面サイズの測定

Flutter で、アダプティブな(適応型デザインが要求されるウィジェットには、

ダイアログ(Dialogs)
・ナビゲーション(Navigation)
・カスタムレイアウト(Custom layout)

があります。(このことは、docs.flutter.dev/ui/adaptive-responsive で述べられています)

以下は、その際のアプローチ(抽象化、測定、分岐)のうちの、測定についての話題です。

1.アプリ全体の画面サイズの測定(MediaQuery.of)

例えば、アプリ画面が縦長もしくは小さいスマフォ画面時は、NavigationBar に、
(アプリ画面が)横長(ワイド画面)画面時は、NavigationRail に、切り替えて実装したい、という場合に、
アプリ全体の画面のサイズを随時把握しておく必要があるはずです。

これらの情報を基にして、UI分岐を実装することになります。

以下は、実装例です。

(例)アプリ全体の画面サイズの測定:
import 'package:flutter/material.dart';

//目的地(Destination)クラスの定義
//ここでは、アイコンとラベルを持つデータクラスとして定義
class Destination {
  final String label;
  final IconData icon;

  const Destination({required this.label, required this.icon});
}

//目的地リストを定義(ナビゲーション先として使用)
const List<Destination> destinations = <Destination>[
  Destination(label: 'ホーム', icon: Icons.home),
  Destination(label: 'プロフィール', icon: Icons.person),
  Destination(label: '設定', icon: Icons.settings),
];

//メインのアプリケーションクラス
class AdaptiveNavigationExample extends StatelessWidget {
  const AdaptiveNavigationExample({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    //MediaQueryを使用して、アプリ全体の画面の横幅を取得
    //横幅が600dp以上ならば、NavigationRailを使用
    final bool useNavigationRail
      = MediaQuery.of(context).size.width >= 600;

    return Scaffold(
      body: Row(
        children: [
          //条件に応じてNavigationRailまたはBottomNavigationBarを使用
          if (useNavigationRail)
            NavigationRail(
              //NavigationRailで目的地リストを使用
              destinations: destinations
                  .map((destination) => NavigationRailDestination(
                        icon: Icon(destination.icon), //目的地アイコン
                        label: Text(destination.label), //目的地ラベル
                      ))
                  .toList(),
              selectedIndex: 0, //選択アイテムのIndex(例として0を指定)
              onDestinationSelected: (int index) {
                //目的地が選択された際の処理
              },
            )
          else
            BottomNavigationBar(
              //BottomNavigationBarでも同じ目的地リストを使用
              items: destinations
                  .map((destination) => BottomNavigationBarItem(
                        icon: Icon(destination.icon), //目的地アイコン
                        label: destination.label, // 目的地ラベル
                      ))
                  .toList(),
              currentIndex: 0, //選択アイテムINdex(例として0を指定)
              onTap: (int index) {
                //目的地が選択された際の処理
              },
            ),
          //メインコンテンツ領域
          Expanded(
            child: Center(
              child: Text('選択されたページのコンテンツを表示します'),
            ),
          ),
        ],
      ),
    );
  }
}

void main() {
  runApp(MaterialApp(home: AdaptiveNavigationExample()));
}

このサンプルコードでは、画面サイズに応じてナビゲーションUIを切り替える方法を示しました。
同じ目的地情報を異なるUIコンポーネント(NavigationRailとBottomNavigationBar)で使い回すことで、アプリの柔軟性を高めています。これが「Adaptive & Responsive Design」の要点であり、デバイスに応じて最適なUIを提供するための基本的なパターンです。

このコードを実行すれば、画面の横幅が狭い場合はBottomNavigationBarが、広い場合はNavigationRailが表示されます。

2.MediaQuery.of だけに頼らないこと

上記1ではMediaQuery.of による画面サイズ測定について述べてありますが、それだけだと問題が発生する様です。

単にMediaQuery.of(context).sizeだけに頼るのではなく、LayoutBuilderやレスポンシブデザインのパターンなど、さまざまなアプローチを組み合わせて使うことで、より柔軟で正確なUI設計が可能になる。
これにより、デバイスの多様性やユーザーの操作に適応した、理想的なユーザーエクスペリエンスを提供できるようになる。
という事が述べられています。

3.MediaQuery.of(context) と MediaQuery.sizeOf(context)

後者のMediaQuery.sizeOf(context)は、MediaQuery.of(context).sizeの簡略形で、Flutter 3.7以降で利用可能なAPIです。
どちらの方法も、アプリ全体画面サイズを取得するために使用できます。
sizeOf(context) は、そのままで(アプリ全体の画面サイズを)取得できるが、
  of(context) は、.size プロパティを指定して、( of(context).size として) 取得する必要がある。)

しかし(上記の)アプリ全体の画面サイズ以外を取得するには、
以下の(2)の様に、前者のMediaQuery.of(context)を使用する必要があります。

(1)MediaQuery.sizeOf(context):
アプリ全体の画面サイズだけ簡単に取得したい場合に使います。
(補足)親ウィジェット(例:Expandedウィジェット等)のサイズを計測し、その情報を使ってUIを構築したい場合は、LayoutBuilder を使います。
(2)MediaQuery.of(context):
(アプリ全体の画面サイズに加えて)多くの(以下の)追加情報が必要な場合に使用できます。
MediaQuery.of(context)で取得できる他のプロパティ:

  1. (size: 画面のサイズ(Size)。)
  2. devicePixelRatio: デバイスのピクセル密度(double)。
  3. textScaleFactor: テキストのスケール係数(double)。
  4. platformBrightness: プラットフォームの明るさ(Brightness、例えばBrightness.darkやBrightness.light)。
  5. padding: 画面のパディング(主にステータスバーやセーフエリアのためのパディング)。
  6. viewInsets: キーボードやソフトウェアによる画面の一部が覆われる範囲(EdgeInsets)。
  7. viewPadding: 物理的なセーフエリアのパディング(EdgeInsets)。
  8. orientation: 画面の向き(縦向き、横向き)(Orientation)。
  9. accessibleNavigation: アクセシビリティのナビゲーションが有効かどうか(bool)。
  10. disableAnimations: アニメーションを無効にするかどうか(bool)。
  11. invertColors: 色反転が有効かどうか(bool)。
  12. boldText: テキストが太字に設定されているかどうか(bool)。
  13. navigationMode: ウィジェットの操作方法(タッチモードやキーボード操作モードなど)。

以下の例では、アプリ画面サイズを変更する度にウィジェットが再ビルドされ、その度に画面にサイズ情報を表示します。

(例)MediaQuery.sizeOf(context)をビルドメソッド内で使用すると、そのBuildContextに関連するサイズプロパティが変わるたびに、該当するウィジェットが再構築されます。
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Size Change Demo',
      home: SizeChangeExample(),
    );
  }
}

class SizeChangeExample extends StatelessWidget {
  const SizeChangeExample({super.key});

  @override
  Widget build(BuildContext context) {
    //ウィンドウ全体のサイズを取得
    final size = MediaQuery.sizeOf(context);

    //ビルドが再実行されるたびに、このメッセージがコンソールに表示されます
    if (kDebugMode) {
      print('Widget is being rebuilt. Current size: ${size.width} x ${size.height}');
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('Size Change Example'),
      ),
      body: Center(
        child: Text(
          'Current size: ${size.width.toStringAsFixed(2)} x ${size.height.toStringAsFixed(2)}',
          style: const TextStyle(fontSize: 24),
          textAlign: TextAlign.center,
        ),
      ),
    );
  }
}
//MediaQuery.sizeOfのビルドメソッド内での使用
//MediaQuery.sizeOf(context)をビルドメソッド内で使用すると、そのBuildContextに関連するサイズプロパティが変わるたびに、該当するウィジェットが再構築されます。
//重要なポイント: これにより、アプリのウィンドウサイズが変更されるたびに、ウィジェットが自動的に再ビルドされるため、UIが常に最新の状態に保たれます。

4.ランドスケープ・ポートレートモードの扱い

Material Guidelines(マテリアルデザインガイドライン)は、ユーザーが端末をどの向きで使用しても良いようにアプリを設計することを推奨しています。これは、ユーザーエクスペリエンスを最大化するために、端末を横向き(ランドスケープ)や上下逆さまの縦向き(ポートレート)で使用できるようにするべきだという考えに基づいています。

しかし、特定の理由で縦向きのみに制限したい場合、少なくとも端末を上下逆さまにしたポートレートモードもサポートするようにするべきです。これにより、端末をどのように持ってもアプリが正しく表示されるようになります。

(例)アプリの画面の向きを縦向き(ポートレート)に固定し、上下逆さまもサポートする:
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
  //アプリの画面の向きを縦向き(ポートレート)に固定し、上下逆さまもサポート
  WidgetsFlutterBinding.ensureInitialized();
  SystemChrome.setPreferredOrientations([
    DeviceOrientation.portraitUp,   //通常の縦向き
    DeviceOrientation.portraitDown, //逆さまの縦向き
  ]).then((_) {
    runApp(const MyApp());
  });
}

・・・以下省略

5.親ウィジェットのサイズ測定(LayoutBuilder)

LayoutBuilderは、MediaQuery.sizeOfと似た目的を達成しますが、いくつかの違いがあります。

MediaQuery.sizeOfがアプリ全体のウィンドウサイズを提供するのに対し、LayoutBuilderは親ウィジェットからのレイアウト制約(BoxConstraintsオブジェクト)を提供します。これにより、LayoutBuilderをウィジェットツリーのどこに配置するかによって、特定の位置でのサイズ情報を取得することができます。

また、LayoutBuilderはSizeオブジェクトではなく、BoxConstraintsオブジェクトを返します。これにより、コンテンツに対して有効な幅と高さの範囲(最小値と最大値)が提供されるため、固定されたサイズではなく、柔軟なサイズ調整が可能になります。これは、カスタムウィジェットを作成する際に特に便利です。

例えば、カスタムウィジェットを作成する場合、そのウィジェットに割り当てられたスペースに基づいてサイズを決定したいとき、LayoutBuilderを使用します。

以下は、LayoutBuilderを使って親ウィジェットからのレイアウト制約に基づいてウィジェットを配置する例です。

Dart
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'LayoutBuilder Example',
      home: const Scaffold(
        body: LayoutBuilderExample(),
      ),
    );
  }
}

class LayoutBuilderExample extends StatelessWidget {
  const LayoutBuilderExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('LayoutBuilder 例:'),
      ),
      body: Center(
        child: Container(
          color: Colors.blue[50],
          //LayoutBuilderで、親ウィジェットのサイズに基づいてレイアウトを決定
          child: LayoutBuilder(
            builder: (BuildContext context, BoxConstraints constraints) {
              //ビルド再実行の度に、このメッセージをコンソールに表示します
              if (kDebugMode) {
                print(
                 'LayoutBuilder再構成後:
                  maxW = ${constraints.maxWidth}, 
                  maxH = ${constraints.maxHeight}'
                );
              }
              
              return Container(
                color: Colors.blue,
                alignment: Alignment.center,
                width: constraints.maxWidth * 0.5, //親Widget幅の50%
                height: constraints.maxHeight * 0.5, //親Widget高の50%
                child: Text(
                  'Container size: 
                   ${constraints.maxWidth * 0.5} x 
                   ${constraints.maxHeight * 0.5}',
                  style: const TextStyle(
                   color: Colors.white, fontSize: 16),
                  textAlign: TextAlign.center,
                ),
              );
            },
          ),
        ),
      ),
    );
  }
}

この様に、LayoutBuilderは、ウィジェットが親ウィジェットから与えられたレイアウト制約に基づいて、動的にサイズを調整するのに適したウィジェットです。MediaQuery.sizeOfがアプリ全体のウィンドウサイズを取得するのに対し、LayoutBuilderはウィジェットツリーの特定の場所でのレイアウト制約を取得し、柔軟なレイアウト調整を可能にします。これにより、カスタムウィジェットの作成や複雑なレイアウトの実装が容易になります。

コメントを残す