🍫

【Flutter】 MediaQuery とは何か

2020/12/19に公開

この記事は Flutter #3 アドベントカレンダー 2020 - Qiita 19 日目の記事です。


Flutter アプリを作っていると、どこかで一度は MediaQuery のお世話になるのではないかと思います。

例えば、Widget のサイズを画面の幅や高さに合わせるために

width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height

と記述することが少なからずあるのではないでしょうか。

この記事では、何気なく「画面サイズを取得する」ために使用することの多い MediaQuery について、 MediaQuery とは何なのか、どのように作られているのか、また他のどのような場面で活用できるのかについて考えていきたいと思います。

参考

https://api.flutter.dev/flutter/widgets/MediaQuery-class.html

公式ドキュメントです。主に画面サイズの取得を例にいろいろ説明されていますが、それ以外の用途や要素についても記載されていますので、読んでみると得るものが多いと思います。

https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/widgets/media_query.dart

ソースコードです。細かい実装などはここから追ってみるといろんな発見があります。実際に処理を追う際はこのファイルを VSCode なり AndroidStudio で開いて IDE の支援を活用しながら追うのが効率的です。

https://zenn.dev/chooyan/articles/77a2ba6b02dd4f

Flutter の「3つのツリー」について以前に書いた記事です。これが理解できていると、この記事や公式ドキュメントの理解が 5 倍くらい早く、正確になると思います。

本文

この記事では、主に以下の切り口で MediaQuery の調査を進めます。

  • MediaQuery は InheritedWidget を継承している
  • MediaQuery は size 以外の情報も保持している
  • 標準の Wiget から読み取れる MediaQuery の活用方法

これに加え、仕組みを理解する上で重要と判断した内容についても触れています。

全体的に長い記事になっていますので、項目ごとに少しずつ読むことをオススメします。

MediaQuery は InheritedWidget のサブクラス

まず公式ドキュメントやソースコードを見ると分かる通り、 MediaQuery は InheritedWidget のサブクラスです。

Inheritance
Object > DiagnosticableTree > Widget > ProxyWidget > InheritedWidget > MediaQuery

InheritedWidget については、以前 【Flutter】InheritedWidget とは何か にも書きましたが、簡単に説明すると 祖先の Widget が保持するデータを 子孫の Widget でも参照できるようにするための Widget です。また、一度 InheritedWidget を参照した Widget(正確にはその Widget が生成する Element)は InheritedWidget(が生成した InheritedElement)によってキャッシュされ、InheritedWidget が保持するデータの変更通知を受け取れるようになる機能も持っています。

この InheritedWidget の基本的な仕組みを利用することで、我々がコーディングする任意の StatelessWidget / StatefulWidget で MediaQuery が参照できるようになっています。

なお、 MediaQuery を利用する際に呼び出す MediaQuery.of は、引数に受け取った context (実態は Element)のツリーを祖先へたどって InheritedElement を探し出す処理をしています。この走査処理は Navigator.of など、祖先をたどって目的の Widget を見つけたい様々な場面で同様のことが行われています。 Flutter の 3 つのツリーの仕組みを理解する上でとても大切な処理となっています。

MediaQuery が生成される場所

MediaQuery は WidgetsApp の 中で作られます。

正確には、 WidgetsApp の State である _WidgetsAppStatebuild()_MediaQueryFromWindow を生成し、 その State である _MediaQueryFromWindowsStatebuild()MediaQuery を return するような流れになっています。

なお、 MaterialApp は、その State である _MaterialAppStatebuild() で返却する Widget に WidgetsApp を含んでいますので、 MaterialApp を使う場合も同様に MediaQuery が利用できます。

先述の通り MeidaQuery は InheritedWidget ですので、 MediaQuery よりツリーの祖先に位置する Widget から参照することはできないのですが、通常は runApp() で WidgetsApp や MaterialApp を渡すと思いますので、そのような問題は発生しないと思います。

データを持っているのは MediaQueryData クラス

ここまで MediaQuery について説明してきましたが、厳密にはデータを保持しているのは MediaQueryData というクラスです。(参考)

final MediaQueryData data;

また、 MediaQuery.of が返却するオブジェクトも MeidaQueryData であることが戻り値からも読み取れます。ソースコードを読むと、 dependOnInheritedWidgetOfExactType() した結果をそのまま返却するのではなく、 .data をつけて MediaQuery が保持している MediaQueryData を返却しているのが読んで分かります。(参考)

static MediaQueryData of(BuildContext context) {
  .. 省略 ..
  return context.dependOnInheritedWidgetOfExactType<MediaQuery>()!.data;
}

そのため、 MediaQuery を使って参照できるデータにどのようなものがあるかを調べる場合は、 MediaQuery のドキュメントだけでなく、 MediaQueryData のドキュメント も参照するようにすると良いでしょう。保持しているデータについてはこちらでより詳しく説明されています。

MediaQueryData が保持するデータ

padding, viewPadding, viewInsets

MediaQueryData のドキュメントを見てみると、よく使われる size の他に、 paddingviewPaddingviewInsets といったデータについて書かれていることが分かります。

MediaQueryData includes three EdgeInsets values: padding, viewPadding, and viewInsets. These values reflect the configuration of the device and are used and optionally consumed by widgets that position content within these insets.

詳しい内容はこの記事では割愛しますが、これらはキーボードやノッチなど、システムが管理する UI パーツによって自アプリの画面幅・高さがどれだけ減っているかを管理している値です。

Widget によっては、例えばキーボードの開閉に合わせて UI の高さを変えなければいけない場合があります。その時にこれらの値が利用される、というわけです。

また先述の通り MediaQuery は InheritedWidget ですので、ユーザーの操作によってキーボードが開閉し、これらの値に変化があった場合はすぐに MediaQuery からその通知を取得し、 Widget をリビルドして高さを調節することができる、というわけです。

アクセシビリティ

MediaQueryData は、 boldTexttextScaleFactor など文字の表示に関わる値、invertColorsdisableAnimations などの画面表示に関わる値など、 OS の「アクセシビリティ」設定で変更できる値を保持しています。

特に textScaleFactor はユーザーの視力によって変更されやすい値です。最終的に Text が表示する文字の大きさは style で指定する fontSize にこの値を乗算した結果で決まりますので、 textScaleFactor が大きくなってもレイアウトが崩れない対策が必要になる場合があります。

参考

/// The number of font pixels for each logical pixel.
///
/// For example, if the text scale factor is 1.5, text will be 50% larger than
/// the specified font size.
///
/// After this is set, you must call [layout] before the next call to [paint].
double get textScaleFactor => _textScaleFactor;

後述しますが、 AppBar の title や Cupertino のダイアログなどは、この textScaleFactor の最大値を設けることで、ユーザーの OS 設定に影響されない工夫がされています。[1]

その他

その他、 MediaQueryData には端末のディスプレイの縦横比を表す devicePixelRatio や、現在の画面の向きを表す orientation、時計を 24 時間表記にしているかどうかを表す alwaysUse24HourFormat などを保持しています。

詳しくは一度ドキュメントを読んでみると良いでしょう。

AppBar と Scaffold から学ぶ MediaQuery の活用方法

MediaQuery は InheritedWidget のサブクラスですので、 Widget ツリー上に別の値を持った MeidaQuery を配置することで、それより子孫の Widget が参照する MediaQuery の値を上書きできるようになっています。[2]

そして、そのような「一部の値を書き換えて上書き」が簡単にできるように、 MediaQuery には MediaQuery.removePadding など padding 関連の値を変更した MediaQueryData を生成するメソッドが、 MediaQueryData には任意のフィールドの値を変更した MediaQueryData を生成する MeidaQueryData.copyWith が用意されています。

Flutter の標準的な Widget でもそのテクニックをうまく使っているものがいくつかあり、その中でも分かりやすい例が AppBar と Scaffold です。ここではこの 2 つの Widget がどのように MediaQuery の上書きを活用しているかを見ていくことで、「MediaQuery の上書き」がどのように活用できるのかを考えていきます。

AppBar の例

AppBar は、 title プロパティに渡した Widget (通常は Text)をタイトルとして AppBar の中央に表示します。

しかし、ユーザーのアクセシビリティの設定によって端末のテキストサイズが大きく変更されてしまうと、この AppBar 内にテキストが入りきらなかったり、 AppBar の高さが増えてしまって body に使う高さが制限されてしまったりと問題が発生します。

そのような不都合を回避するために、 AppBar では、 MediaQueryData の textScalFactor を最大 1.34 で制限することで、ユーザーがどれだけ端末の設定でテキストサイズを変えたとしてもレイアウトが崩れないように工夫されています。(参考)

final MediaQueryData mediaQueryData = MediaQuery.of(context);
title = MediaQuery(
  data: mediaQueryData.copyWith(
    textScaleFactor: math.min(
      mediaQueryData.textScaleFactor,
      _kMaxTitleTextScaleFactor,
    ),
  ),
  child: title,
);

このような「テキストサイズの制限」は、他に DatePickerBottomNavigationBar、また Cupertino のいくつかの Widget など、UI 的にテキストサイズを自由に拡大できないもので使われています。

Scaffold の例

MediaQueryData を上書きする別の例として、 Scaffold では画面上下の padding を変更しています。(参考)

return MediaQuery(
  data: metrics.copyWith(
    padding: metrics.padding.copyWith(
      top: top,
      bottom: bottom,
    ),
  ),
  child: body,
);

これは Scaffold の body の枠の高さが

  • AppBar の有無
  • BottomNavigationBar の有無
  • persistentFooterButtons の有無

によって変化するためです。

このあたりの計算は _ScaffoldLayout.performLayout の中で細かく行われていて、その計算結果を使って topbottom の値が決まり、先ほどの MeidaQueryData.copyWith で利用される、という仕組みです。

このように、特定の Widget の子孫の文字や描画範囲を制限したい場合は、 MediaQueryData の一部の値を変更して複製し、それを持った MediaQuery で子 Widget を囲ってあげる、というテクニックもありますので、知っておくとどこかで役に立つかもしれません。

まとめ

以上、 MediaQuery のドキュメントやソースコード、またそれを使っている Widget のソースコードを読みながら MediaQuery の仕組みと活用方法について調査してみました。

「とりあえず画面サイズを取得したければ MediaQuery.of(context).size を使えばいいよ」というところから MediaQuery を知る人は多いと思います(私もそうでした)が、より詳しく調べてみることで、 MediaQuery がそれ以外の用途でも活用できそう、ということが見えてくるのではないでしょうか。

脚注
  1. 当然、ユーザーは OS の設定で文字サイズを大きくすることで画面上の文字が読みやすくなることを期待しています。 textScaleFactor を上書きすることはその期待を裏切ることにもつながりますので、常に同様の対策をとれば良いというわけではない点に注意してください。 ↩︎

  2. この仕組みの詳細については 【Flutter】InheritedWidget とは何か の脚注2 で少し説明しています。 ↩︎

Discussion