綴Photo の勉強ノートに戻る
Flutter技術2026.04.04

Flutter で「画面全体で揃えたい数字」をどこからでも呼び出せるようにした話

FlutterInheritedWidgetデザイントークン綴Photo

綴Photo を作っていく中で、 「フォントサイズ・余白・コンポーネントサイズ」みたいな 画面全体で揃えたい数字が増えてきた。 最初は親ウィジェットから子に props(=親が子に手渡す設定値。バケツリレーで言う「バケツの中身」)で渡していたんだけど、 ネストが深くなってバケツリレーが辛くなった。 最終的に InheritedWidget で「Of(context) だけで取れる」仕組みを自前で作った。 その記録だ。


なぜ自前で作ろうと思ったか

Flutter には Theme.of(context) という有名な仕組みがあります。 これは「色やフォントテーマをツリーのどこからでも取れる」ようにするための仕掛けです。

最初は素直に Theme を拡張する方向も考えた。 でも、自分が欲しかったのは色やフォントだけじゃなくて、

  • フォントサイズの基準値
  • 余白の基準値(s/m/l/xl のステップ)
  • コンポーネント幅(写真カードの基準幅とか)
  • アニメーションの長さ

みたいな**「アプリ独自の物差し」**だった。 これを Theme に詰め込むと、設計が歪む気がした。

「自前で Of(context) 風に取れる物差しが1個欲しい」 それが結論だった。


詰まったところ:props で全部渡すのが辛い

最初は「画面の一番上で物差しを作って、子ウィジェットに props で渡す」をやっていた。

これ自体は前に書いた「データクラスにプラットフォーム別コンストラクタ」の話の延長だった。 データクラスにまとめるところまでは正解だったと思う。

問題は、画面の中でウィジェットのネストが深くなったとき。

dart
TsuzuruScreen(sizes: sizes)
  └─ TsuzuruStage(sizes: sizes)
      └─ TsuzuruCard(sizes: sizes)
          └─ TsuzuruText(sizes: sizes)

sizes がただバケツリレーされています。 途中のウィジェットで使ってないのに渡している、ということもよくあった。

「これ、どこかにグローバルに置いて取り出せた方が早いな」と思って調べた結果、 InheritedWidget という Flutter の仕組みに辿り着いた。



実装の方針:トークンのデータクラスを InheritedWidget で流す

最終的に、こういう構造に落ち着いた。 この「親が貼って子孫が取りに来る」動きを、この記事では掲示板(貼る側=Provider/読む側=各画面)と呼ぶことにする。 全体は「作る → 置く → 取り出す → 使う」の4ステップでできています。

このセクションのクラス名は実コードのまま載せている(AppDimensions / AppDimensionsProvider)。「デザイントークン=画面全体で揃えたい数字のセット」くらいの気持ちで読んでほしい。

数字はコードに焼き込んである。DB にも prefs にもファイルにも保存しない。① 作る(コード内・static const)AppDimensions.mobile本文14・余白16・幅560プリセット。保存しないAppDimensions.desktop本文16・余白24・幅680プリセット。保存しない交差点 AppDimensionsProvider画面幅(MediaQuery・保存しない)で1つ選ぶ。分岐はここだけ② 置く _AppDimensionsScope(掲示板)選んだ1セットをウィジェットツリーに保持するメモリ。アプリを閉じると消える。保存しない③ of(context) が上へ探す④ 各画面 bodyFontSize を読む分岐は持たない。選ぶのは交差点の1箇所だけ
dart
// 数字をまとめたデータクラス。これが「配りたい中身」
class AppDimensions {
  final double bodyFontSize;
  final double cardPadding;
  final double contentMaxWidth;
  // ... ほか
 
  // private な単一コンストラクタ。値は下のプリセットで詰める
  const AppDimensions._({
    required this.bodyFontSize,
    required this.cardPadding,
    required this.contentMaxWidth,
    // ...
  });
 
  // mobile / desktop ぶんを static const のプリセットとして持つ(固定値)
  static const mobile = AppDimensions._(
    bodyFontSize: 14, cardPadding: 16, contentMaxWidth: 560, /* ... */
  );
  static const desktop = AppDimensions._(
    bodyFontSize: 16, cardPadding: 24, contentMaxWidth: 680, /* ... */
  );
 
  // 子孫から「掲示板の中身ちょうだい」と呼ぶための入り口
  static AppDimensions of(BuildContext context) {
    // 自分の真上をのぼって _AppDimensionsScope を探しにいく
    final scope =
        context.dependOnInheritedWidgetOfExactType<_AppDimensionsScope>();
    return scope!.dimensions; // 見つけた掲示板の中身を返す
  }
}
 
// AppDimensions をツリーに流す「掲示板」役。これが InheritedWidget(private にして直接使わせない)
class _AppDimensionsScope extends InheritedWidget {
  final AppDimensions dimensions; // 配りたい中身(数字の一覧表)
 
  const _AppDimensionsScope({
    required this.dimensions,
    required super.child, // この child 以下にぶら下がる画面が「読める範囲」
  });
 
  // dimensions が本当に変わったときだけ、読んでいた子孫を描き直させる
  @override
  bool updateShouldNotify(_AppDimensionsScope old) =>
      dimensions != old.dimensions;
}

選ぶ交差点のコードがこれだ。

dart
class AppDimensionsProvider extends StatelessWidget {
  const AppDimensionsProvider({super.key, required this.child});
  final Widget child;
 
  @override
  Widget build(BuildContext context) {
    // 画面幅から mobile / desktop を自動判定(選ぶのはここ1箇所だけ)
    final dimensions = Breakpoints.isDesktopScreen(context)
        ? AppDimensions.desktop
        : AppDimensions.mobile;
    return _AppDimensionsScope(dimensions: dimensions, child: child);
  }
}

使う側は MaterialApp の builder の中で1回だけ包みます(画面幅=MediaQuery を読むため、MaterialApp の内側に置くのがポイント)。

dart
MaterialApp.router(
  // ...
  builder: (context, child) {
    // ここに置くことで MaterialApp の MediaQuery(画面幅)を参照できる
    return AppDimensionsProvider(child: child ?? const SizedBox.shrink());
  },
)

そして子ウィジェットでは

dart
final dimensions = AppDimensions.of(context); // どの画面からでも、この1行で掲示板を読む
final fontSize = dimensions.bodyFontSize;

バケツリレーが完全に消えた。


Of(context) がやっていること

AppDimensions.of(context) の中身は、ほぼ

dart
context.dependOnInheritedWidgetOfExactType<_AppDimensionsScope>()

の1行に集約されます。 これは「自分のツリー上方向に _AppDimensionsScope を探しに行く」という API です。

見つけたら、そのスコープが持っている dimensions を返します。 さらにこの context は「もし上の dimensions が変わったらリビルド(=その部分の画面をもう一度描き直すこと)してね」という依存登録もします。

updateShouldNotify で「中身が変わった」と返したときだけ、 依存している子孫がリビルドされます。

このあたりの仕掛けを理解すると、Theme.of(context) も急にブラックボックスじゃなくなります。

of(context) は Flutter の共通パターンで、いろんな場面で同じ形が出てきます。

どの掲示板もツリー上のメモリ。保存しない。1個の context は現在地。Theme.of色・フォントの掲示板ツリー上。保存しないMediaQuery.of画面サイズの掲示板ツリー上。保存しないAppDimensions.of自前トークンの掲示板ツリー上。保存しない現在地 contextbuild が受け取る1個を、そのまま各 of に渡す

Mobile / Desktop で違う物差しを流す

ウィンドウをリサイズして desktop の幅をまたぐと、Provider が選び直して ツリー全体の物差しが切り替わります。

ウィジェット側に if (isDesktop) が一切登場しません。 これがやりたかった姿だった。


詰まったポイント:updateShouldNotify の重要性

InheritedWidget で1個ハマったのが updateShouldNotify だった。

これは「自分(InheritedWidget)が更新されたとき、子孫をリビルドすべきか」を返すメソッド。 ここを雑に true 固定にすると、毎回全子孫がリビルドされて重くなります。 逆に false 固定にすると、dimensions を変えても子孫が古い値のままになります。

自分の場合は mobile / desktop が static const のプリセット2つなので、ここは楽だった。 const は同じ定義なら同一インスタンスに正準化されるので、

dart
bool updateShouldNotify(_AppDimensionsScope old) =>
    dimensions != old.dimensions;

の != がそのまま「プリセットが切り替わったときだけ true」になります(== を自前実装しなくていいです)。 もし値を都度 new で作る設計なら、ここは Equatable を入れるか手動で == を実装して 「中身が同じなら等しい」を定義する必要があります。

このパターンはメモしておくと、いろんな場面で使えそうだった。


この記事で書かなかった話

  • AppDimensions の具体的な数字
  • 画面サイズに応じた中間ステップ(タブレットサイズなど)の扱い
  • アクセシビリティ設定との連動

このあたりは別記事に分けたい。


学んだこと

  • 「props のバケツリレーが辛い」と感じたら、InheritedWidget の出番
  • Of(context) は呪文じゃなく、「ツリー上方向にスコープを探す」だけの仕掛け
  • データクラスを InheritedWidget で流すと、ウィジェットから分岐が消えます
  • updateShouldNotify は「中身が変わったときだけ true」が基本

最初 Theme.of(context) を呪文みたいに使っていた頃から、 自分で同じパターンを実装できる側になれたのは、地味に大きな前進だった。

仕組みが分かると、Flutter のコードが急に身近になります。 個人開発でも、「Flutter が用意した既製品」と「自分で組む仕組み」を行き来できると、 書けるアプリの幅がぐっと広がる気がする。


この記事は2026年4月時点の自分の理解と実装方針で書いています。アプリは継続開発中なので、現在の実装と細部が違っている可能性があります。