UIの部品を組み合わせて画面を作る仕組み
筋トレメモの _FreeSessionCard を見てみよう:
class _FreeSessionCard extends StatelessWidget {
final VoidCallback onTap;
const _FreeSessionCard({required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
// ... 見た目の定義 ...
),
);
}
}
| ポイント | 説明 |
|---|---|
extends StatelessWidget | 「状態を持たない Widget」を作る宣言 |
build() | 「このWidgetはこういう見た目です」を返すメソッド |
context | Widget ツリー上の「自分の位置情報」(Topic 2-2 で詳しく) |
筋トレメモの App を見てみよう:
class App extends ConsumerStatefulWidget {
const App({super.key});
@override
ConsumerState<App> createState() => _AppState();
}
class _AppState extends ConsumerState<App> {
int _selectedIndex = 0; // 現在選択中のタブ番号
@override
Widget build(BuildContext context) {
final screens = const [HomeScreen(), HistoryScreen()];
return MaterialApp(
home: Scaffold(
body: screens[_selectedIndex],
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: (index) {
setState(() => _selectedIndex = index);
},
),
),
);
}
}
| StatelessWidget | StatefulWidget | |
|---|---|---|
| 状態 | なし(渡されたデータだけ) | あり(内部に変わる値を持つ) |
| 更新 | 親から新しいデータが来たとき | setState() で自分で更新 |
| クラス数 | 1つ | 2つ(Widget + State) |
| 用途 | 表示だけ | タブ切り替え、フォーム入力 |
_selectedIndex はタブの切り替え状態。ユーザーがタブをタップするたびに setState() で値を更新し、build() が再実行されてUIが変わる。筋トレメモの画面の多くは ConsumerWidget で作られている:
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final routineListAsync = ref.watch(routineListProvider);
// ... UI を構築 ...
}
}
| StatelessWidget | ConsumerWidget | |
|---|---|---|
| build の引数 | (BuildContext context) | (BuildContext context, WidgetRef ref) |
| Provider | 使えない | ref.watch() でデータ取得 |
| 状態管理 | なし | Provider 経由で外部の状態を監視 |
筋トレメモの workout_screen.dart にある _Header を見てみよう:
class _Header extends StatelessWidget {
final ActiveWorkoutState state;
final WidgetRef ref; // ← ref を引数で受け取っている!
const _Header({required this.state, required this.ref});
@override
Widget build(BuildContext context) {
// ref を使って Provider を操作...
ref.read(activeWorkoutProvider.notifier).discardSession();
}
}
これは 動くけど推奨されないパターン。なぜ問題なのか?
| 問題点 | 説明 |
|---|---|
| 再ビルドの無駄 | 親の ConsumerWidget が再ビルドすると、ref の参照が変わり子も全て再ビルドされる |
| 責務の曖昧さ | StatelessWidget なのに Provider を操作している。型シグネチャだけ見ると「状態を使わない Widget」に見える |
| テストしづらい | WidgetRef をモックで用意するのが面倒 |
_Header を正しく書き直すとこうなる:
// ❌ NGパターン: StatelessWidget + ref 手渡し
class _Header extends StatelessWidget {
final WidgetRef ref; // ref を持ち回り
...
}
// ✅ 正しいパターン: ConsumerWidget
class _Header extends ConsumerWidget {
final ActiveWorkoutState state;
const _Header({required this.state});
@override
Widget build(BuildContext context, WidgetRef ref) {
// ref は build の引数から自然に使える
ref.read(activeWorkoutProvider.notifier).discardSession();
}
}
| StatelessWidget + ref手渡し | ConsumerWidget | |
|---|---|---|
| Widget の型 | StatelessWidget(嘘つき) | ConsumerWidget(正直) |
| ref の出所 | コンストラクタ引数 | build メソッドの引数 |
| 再ビルド | 親の ref 更新で巻き添え | 自分が watch した Provider のみ |
| 可読性 | 「なぜ StatelessWidget なのに ref?」 | 「ConsumerWidget だから Provider 使うんだな」 |
WidgetRef ref が追加され、ref.watch() や ref.read() で Provider にアクセスできる。class MyWidget extends StatelessWidget { final WidgetRef ref; ... }Widgetツリーの中の「自分の住所」を表すオブジェクト
build メソッドの第一引数 BuildContext context は、そのWidgetがツリーのどこにいるかを表すオブジェクト。
@override
Widget build(BuildContext context) {
// context を使って「ツリーの上方向」を探索できる
}
| context でできること | 例 |
|---|---|
| テーマを取得 | Theme.of(context) |
| ナビゲーション | Navigator.of(context) |
| 画面サイズ | MediaQuery.of(context) |
| スナックバー表示 | ScaffoldMessenger.of(context) |
.of(context) メソッドは「context の位置からツリーを上に辿って、最も近い○○を見つける」動作。context がないとツリー探索ができない。筋トレメモの実コードで context が使われている場面:
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const RoutineManageScreen(),
),
);
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
)
showDialog(
context: context,
builder: (ctx) => AlertDialog(...),
);
○○.of(context) で「自分の位置からツリーを辿って情報を取得」する。context はUIの世界でのみ意味を持つ道具。非同期処理の後に context を使うときの注意点:
lib/features/home/home_screen.dart L118-128Future<void> _onRoutineTap(...) async {
await ref.read(activeWorkoutProvider.notifier).startSession(data);
if (!context.mounted) return; // ← まだ画面が存在するか確認!
Navigator.of(context).push(...);
}
context.mounted は「ジムがまだ営業中か」を確認するチェック。| 場面 | mounted チェック |
|---|---|
| 同期処理 | 不要(処理中に Widget が消えることはない) |
await の後 | 必須(待っている間に画面が閉じている可能性) |
await の間にユーザーが「戻る」ボタンを押すと、Widget がツリーから消える。その後に Navigator.of(context).push() を呼ぶとクラッシュする。context.mounted で存在確認してから使う。筋トレメモのアーキテクチャを思い出そう:
UI (Widget) → Provider → Repository → DB
context ✅ ref ✅ なし ✅ なし ✅
Repository の引数を見てみよう:
lib/features/workout/workout_repository.dart L19-23class WorkoutRepository {
final AppDatabase _db;
Future<void> saveSession(
ActiveWorkoutState recordState, { // ← ドメインデータだけ!
String? userId,
SyncService? syncService,
}) async { ... }
}
Repository に BuildContext が一切ないことに注目。
| context を Repository に渡す | 正しい設計 | |
|---|---|---|
| コード | repo.save(context, data) | repo.save(data) |
| 問題 | Repository が UI に依存してしまう | Repository は純粋なデータ操作 |
| テスト | テスト時に context をモックする必要 | データだけ渡せばテストできる |
| 再利用 | CLI ツールなど Widget 以外から呼べない | どこからでも呼べる |
BuildContext = Widget ツリー上の「自分の住所」。.of(context) でツリーを上に辿るawait の後は必ず context.mounted をチェックしてから context を使うNavigator.of(context).push(...) の context は何のために使う?.of(context) は「context の位置からツリーを上方向に辿って、最も近い Navigator を探す」操作。context は「自分の位置情報」を提供している。await repo.save(data);
Navigator.of(context).pop();await で待っている間に Widget がツリーから消える可能性がある。if (!context.mounted) return; を入れてから Navigator を使うべき。筋トレメモを構成するUIパーツを5ステップで習得
return Scaffold(
appBar: AppBar(title: const Text('履歴')),
body: sessionsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('エラーが発生しました: $e')),
data: (sessions) { ... },
),
);
| Scaffold のプロパティ | 役割 | 筋トレメモでの使用 |
|---|---|---|
appBar | 画面上部のバー | HistoryScreen の「履歴」タイトル |
body | メインコンテンツ | 全画面で使用 |
bottomNavigationBar | 画面下部のナビ | App のタブ切り替え |
floatingActionButton | 右下のフローティングボタン | (未使用) |
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 24),
Row( // ← 横並び: アプリ名 + 設定ボタン
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(children: [Text('筋トレメモ'), ...]),
GestureDetector(child: Icon(Icons.settings)),
],
),
const CalendarWidget(),
Expanded(child: ListView(...)),
],
)
| Column | Row | |
|---|---|---|
| 方向 | 縦(上→下) | 横(左→右) |
mainAxisAlignment | 縦方向の配置 | 横方向の配置 |
crossAxisAlignment | 横方向の配置 | 縦方向の配置 |
| 注意 | 高さ無限だとエラー | 幅無限だとエラー |
mainAxis = 部品が並ぶ方向、crossAxis = それと直交する方向。Column の main は縦、cross は横。Row はその逆。Expanded で残りスペースを埋める Widget を指定できる。ListView.separated(
itemCount: routineList.length + 1,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, index) {
if (index == routineList.length) {
return _FreeSessionCard(...);
}
return _RoutineCard(data: routineList[index], ...);
},
)
| コンストラクタ | 用途 | 筋トレメモの例 |
|---|---|---|
ListView(children: [...]) | 少数の固定リスト | (未使用) |
ListView.builder | 大量データ(遅延生成) | HistoryScreen の履歴一覧 |
ListView.separated | 区切り付きリスト | HomeScreen のルーティン一覧 |
.builder と .separated は「画面に見えている分だけ Widget を作る」遅延生成方式。1000件のリストでも画面に10件しか見えなければ10件分しか作らないので高速。ListView(children: [])。名前を1人ずつ呼び出すのが ListView.builder。会員が100人なら前者でもいいが、10万人なら後者でないとメモリが足りない。Container(
decoration: BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
),
child: Row(children: [...]),
)
const SizedBox(height: 24), // 24px の縦スペース
const SizedBox(width: 8), // 8px の横スペース
| Container | SizedBox | |
|---|---|---|
| 用途 | 装飾(色、角丸、影、パディング) | サイズ指定のみ |
| パフォーマンス | やや重い(多機能) | 軽い(サイズだけ) |
| 使い分け | 背景色や枠線が必要なとき | スペーサーとして |
SizedBox で統一されている。筋トレメモには両方のパターンがある:
GestureDetector(
onTap: () async { ... },
child: const Icon(Icons.settings, ...),
)
InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () { ... },
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(...),
),
)
| GestureDetector | InkWell | |
|---|---|---|
| リップルエフェクト | なし | あり(Material デザイン準拠) |
| 必要な親 Widget | なし | Material または Card 内 |
| 対応ジェスチャー | タップ、ドラッグ、長押し等すべて | タップ、長押し、ダブルタップ |
| 用途 | カスタムUI、非 Material | Material デザインのボタン |
_SessionCard は Card の中なので InkWell(リップルでフィードバック)。HomeScreen の設定アイコンは独自デザインなので GestureDetector(シンプルにタップだけ検知)。const にできるためパフォーマンスが良い。Container は装飾(色、影、角丸)が必要なときに使う。ListView.builder が ListView(children: [...]) より効率的な理由は?ListView.builder は itemBuilder を必要なときだけ呼び出す(遅延生成)。1000件のリストでも画面に見える10件分しか Widget を作らないのでメモリ効率が良い。画面間の移動を「スタック」で理解する
筋トレメモの画面下部にあるタブ:
lib/app.dart L36-44home: Scaffold(
body: screens[_selectedIndex],
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: (index) {
setState(() => _selectedIndex = index);
},
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'ホーム'),
NavigationDestination(icon: Icon(Icons.history), label: '履歴'),
],
),
)
| タブ切り替え | 画面遷移 (push) |
|---|---|
| 画面は切り替わるだけ | 新しい画面がスタックに積まれる |
| 「戻る」操作なし | 「戻る」で前の画面に戻れる |
setState で index 更新 | Navigator.push |
screens[_selectedIndex] で配列から表示する Widget を選んでいるだけ。Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const WorkoutScreen(),
),
);
push すると画面が「スタック」に積まれる:
MaterialPageRoute はプラットフォームに応じたアニメーション付きの遷移を提供する。Android ならスライドイン、iOS ならスワイプバック対応。ダイアログを閉じる + 元の画面まで戻る例:
lib/features/workout/workout_screen.dart L186-189Navigator.of(ctx).pop(); // ← ダイアログを閉じる
ref.read(activeWorkoutProvider.notifier).discardSession();
Navigator.of(context).popUntil((route) => route.isFirst);
// ↑ 最初の画面(ホーム)まで一気に戻る
popUntil のスタック変化:
| メソッド | 動作 | 筋トレメモの使用場面 |
|---|---|---|
pop() | 一番上の画面を1つ取り除く | ダイアログを閉じる |
popUntil(条件) | 条件を満たすまで複数取り除く | トレーニング中断→ホームへ |
popUntil((route) => route.isFirst) は「最初のルート(ホーム画面)に到達するまでスタックを巻き戻す」。途中の画面を1つずつ pop する必要がない。トレーニング完了時のナビゲーション:
lib/features/workout/workout_screen.dart L450-455Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute(
builder: (_) => WorkoutSummaryScreen(summary: summary),
),
(route) => route.isFirst, // ← ホーム画面だけ残す
);
スタック変化のイメージ:
【Before】
【After: pushAndRemoveUntil】
| push + popUntil(2手) | pushAndRemoveUntil(1手) | |
|---|---|---|
| 操作 | 戻ってから push | 一発で入れ替え |
| アニメーション | 戻る→進むで2回 | 新画面への遷移1回 |
| 用途 | — | 完了画面への遷移 |
pushAndRemoveUntil なら WorkoutScreen を除去しつつ SummaryScreen を積めるので、「戻る」→ホームが自然に実現できる。画面遷移は「本の山」で考える。push = 積む、pop = 取る、pushAndRemoveUntil = 入れ替えて積む。
Navigator.of(context).popUntil((route) => route.isFirst) の動作は?popUntil は条件が true になるルートに到達するまでスタックの上から画面を取り除く。route.isFirst は最初のルート(ルート画面)で true を返すので、ホーム画面まで一気に戻れる。setState で表示する Widget を切り替えるだけ(全ての画面が裏で生きている)。push は Navigator のスタックに新しいルートを追加する操作。戻るボタンが使えるかどうかが大きな違い。