綴Photo を作っていく中で、
「フォントサイズ・余白・コンポーネントサイズ」みたいな
画面全体で揃えたい数字が増えてきた。
最初は親ウィジェットから子に props(=親が子に手渡す設定値。バケツリレーで言う「バケツの中身」)で渡していたんだけど、
ネストが深くなってバケツリレーが辛くなった。
最終的に InheritedWidget で「Of(context) だけで取れる」仕組みを自前で作った。
その記録だ。
なぜ自前で作ろうと思ったか
Flutter には Theme.of(context) という有名な仕組みがあります。
これは「色やフォントテーマをツリーのどこからでも取れる」ようにするための仕掛けです。
最初は素直に Theme を拡張する方向も考えた。
でも、自分が欲しかったのは色やフォントだけじゃなくて、
- フォントサイズの基準値
- 余白の基準値(s/m/l/xl のステップ)
- コンポーネント幅(写真カードの基準幅とか)
- アニメーションの長さ
みたいな**「アプリ独自の物差し」**だった。
これを Theme に詰め込むと、設計が歪む気がした。
「自前で Of(context) 風に取れる物差しが1個欲しい」
それが結論だった。
詰まったところ:props で全部渡すのが辛い
最初は「画面の一番上で物差しを作って、子ウィジェットに props で渡す」をやっていた。
これ自体は前に書いた「データクラスにプラットフォーム別コンストラクタ」の話の延長だった。 データクラスにまとめるところまでは正解だったと思う。
問題は、画面の中でウィジェットのネストが深くなったとき。
TsuzuruScreen(sizes: sizes)
└─ TsuzuruStage(sizes: sizes)
└─ TsuzuruCard(sizes: sizes)
└─ TsuzuruText(sizes: sizes)sizes がただバケツリレーされています。
途中のウィジェットで使ってないのに渡している、ということもよくあった。
「これ、どこかにグローバルに置いて取り出せた方が早いな」と思って調べた結果、 InheritedWidget という Flutter の仕組みに辿り着いた。
実装の方針:トークンのデータクラスを InheritedWidget で流す
最終的に、こういう構造に落ち着いた。 この「親が貼って子孫が取りに来る」動きを、この記事では掲示板(貼る側=Provider/読む側=各画面)と呼ぶことにする。 全体は「作る → 置く → 取り出す → 使う」の4ステップでできています。
このセクションのクラス名は実コードのまま載せている(
AppDimensions/AppDimensionsProvider)。「デザイントークン=画面全体で揃えたい数字のセット」くらいの気持ちで読んでほしい。
// 数字をまとめたデータクラス。これが「配りたい中身」
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;
}選ぶ交差点のコードがこれだ。
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 の内側に置くのがポイント)。
MaterialApp.router(
// ...
builder: (context, child) {
// ここに置くことで MaterialApp の MediaQuery(画面幅)を参照できる
return AppDimensionsProvider(child: child ?? const SizedBox.shrink());
},
)そして子ウィジェットでは
final dimensions = AppDimensions.of(context); // どの画面からでも、この1行で掲示板を読む
final fontSize = dimensions.bodyFontSize;バケツリレーが完全に消えた。
Of(context) がやっていること
AppDimensions.of(context) の中身は、ほぼ
context.dependOnInheritedWidgetOfExactType<_AppDimensionsScope>()の1行に集約されます。
これは「自分のツリー上方向に _AppDimensionsScope を探しに行く」という API です。
見つけたら、そのスコープが持っている dimensions を返します。
さらにこの context は「もし上の dimensions が変わったらリビルド(=その部分の画面をもう一度描き直すこと)してね」という依存登録もします。
updateShouldNotify で「中身が変わった」と返したときだけ、
依存している子孫がリビルドされます。
このあたりの仕掛けを理解すると、Theme.of(context) も急にブラックボックスじゃなくなります。
of(context) は Flutter の共通パターンで、いろんな場面で同じ形が出てきます。
Mobile / Desktop で違う物差しを流す
ウィンドウをリサイズして desktop の幅をまたぐと、Provider が選び直して ツリー全体の物差しが切り替わります。
ウィジェット側に if (isDesktop) が一切登場しません。
これがやりたかった姿だった。
詰まったポイント:updateShouldNotify の重要性
InheritedWidget で1個ハマったのが updateShouldNotify だった。
これは「自分(InheritedWidget)が更新されたとき、子孫をリビルドすべきか」を返すメソッド。
ここを雑に true 固定にすると、毎回全子孫がリビルドされて重くなります。
逆に false 固定にすると、dimensions を変えても子孫が古い値のままになります。
自分の場合は mobile / desktop が static const のプリセット2つなので、ここは楽だった。
const は同じ定義なら同一インスタンスに正準化されるので、
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月時点の自分の理解と実装方針で書いています。アプリは継続開発中なので、現在の実装と細部が違っている可能性があります。