Lv.2 Flutter基礎
0 / 4
TOPIC 2-1

Widgetツリー

UIの部品を組み合わせて画面を作る仕組み

0%
STEP 1 / 5

StatelessWidget: 最もシンプルなWidget

筋トレの「種目カード」を想像してほしい。名前と重量が印刷された紙のカード。自分では変化しない。これが StatelessWidget。

筋トレメモの _FreeSessionCard を見てみよう:

lib/features/home/home_screen.dart L271-305
class _FreeSessionCard extends StatelessWidget {
  final VoidCallback onTap;
  const _FreeSessionCard({required this.onTap});

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Container(
        // ... 見た目の定義 ...
      ),
    );
  }
}
「_FreeSessionCard は StatelessWidget を継承し、build メソッドで UI を返す」
ポイント説明
extends StatelessWidget「状態を持たない Widget」を作る宣言
build()「このWidgetはこういう見た目です」を返すメソッド
contextWidget ツリー上の「自分の位置情報」(Topic 2-2 で詳しく)
StatelessWidget は「自分の中に変わるデータを持たない」Widget。渡されたデータを表示するだけ。ボタンの見た目、テキスト、アイコンなど、変化しないUIパーツに使う。
STEP 2 / 5

StatefulWidget: 自分で状態を管理するWidget

ジムのレップカウンター(数取器)を想像してほしい。ボタンを押すたびに数字が変わる。カウンター自身が「今何回か」という状態を持っている。これが StatefulWidget。

筋トレメモの App を見てみよう:

lib/app.dart L10-49
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);
          },
        ),
      ),
    );
  }
}
StatelessWidgetStatefulWidget
状態なし(渡されたデータだけ)あり(内部に変わる値を持つ)
更新親から新しいデータが来たときsetState() で自分で更新
クラス数1つ2つ(Widget + State)
用途表示だけタブ切り替え、フォーム入力
_selectedIndex はタブの切り替え状態。ユーザーがタブをタップするたびに setState() で値を更新し、build() が再実行されてUIが変わる。
STEP 3 / 5

ConsumerWidget: Riverpod を使えるWidget

筋トレメモの画面の多くは ConsumerWidget で作られている:

lib/features/home/home_screen.dart L17-19
class HomeScreen extends ConsumerWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final routineListAsync = ref.watch(routineListProvider);
    // ... UI を構築 ...
  }
}
「HomeScreen は ConsumerWidget を継承。build の引数に ref が追加され、Provider からデータを取得できる」
StatelessWidgetConsumerWidget
build の引数(BuildContext context)(BuildContext context, WidgetRef ref)
Provider使えないref.watch() でデータ取得
状態管理なしProvider 経由で外部の状態を監視
ConsumerWidget は StatelessWidget + Riverpod アクセス。「自分では状態を持たないが、Provider のデータを使ってUIを作る」Widget。筋トレメモのほとんどの画面がこのパターン。
StatelessWidget = 紙のメニュー表、ConsumerWidget = タブレット注文端末。見た目を表示するだけなのは同じだが、ConsumerWidget は裏のシステム(Provider)からリアルタイムのデータを取得できる。
STEP 4 / 5

NGパターン: ref を引数で手渡し 弱点

筋トレメモの workout_screen.dart にある _Header を見てみよう:

lib/features/workout/workout_screen.dart L89-93
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 をモックで用意するのが面倒
StatelessWidget に ref を渡すと「表面は Stateless だけど中身は Provider に依存している」というチグハグな状態になる。Widget の種類を見ただけで責務が分からなくなる。
STEP 5 / 5

正しいパターン: ConsumerWidget にする 弱点

_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 使うんだな」
筋トレに例えると: NGパターンは「自分のロッカーの鍵を他人に預けて取ってもらう」方式。正しいパターンは「自分の鍵で自分のロッカーを開ける」方式。自分で管理する方が安全で分かりやすい。
判断基準はシンプル: Provider を使うなら ConsumerWidget(または ConsumerStatefulWidget)。使わないなら StatelessWidget。ref を引数で渡す必要があるなら、それは Widget の種類が間違っているサイン。

2-1 まとめ

  • StatelessWidget = 状態を持たない表示用パーツ(_FreeSessionCard)
  • StatefulWidget = 自分で状態を管理する(App の _selectedIndex)
  • ConsumerWidget = Provider 経由でデータを使う(HomeScreen)
  • ref を引数で手渡しするのは NG。Provider を使うなら ConsumerWidget にする
Q1. Provider のデータを使いたい Widget を作るとき、正しい親クラスは?
ConsumerWidget を継承すると、build メソッドに WidgetRef ref が追加され、ref.watch()ref.read() で Provider にアクセスできる。
Q2. 以下のコードの問題点は何?
class MyWidget extends StatelessWidget { final WidgetRef ref; ... }
StatelessWidget に ref を手渡しすると、型シグネチャが嘘つきになる。ConsumerWidget にすれば build の引数から ref が自然に使える。再ビルドの効率も改善する。
Q3. App が ConsumerStatefulWidget なのはなぜ?
App は (1) _selectedIndex を setState で管理し、(2) initState で ref.read(authRepositoryProvider) を呼んでいる。setState + ref の両方が必要なので ConsumerStatefulWidget。
TOPIC 2-2

BuildContext

Widgetツリーの中の「自分の住所」を表すオブジェクト

0%
STEP 1 / 4

context は「住所」

ジムの中で「更衣室はどこ?」と聞くとき、あなたの現在位置(context)によって案内が変わる。1階にいれば「右の廊下を進んで」、2階にいれば「エレベーターで1階に降りて」。context = 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 がないとツリー探索ができない。
STEP 2 / 4

context を使う3つの場面

筋トレメモの実コードで context が使われている場面:

1. ナビゲーション

lib/features/home/home_screen.dart L60-65
await Navigator.of(context).push(
  MaterialPageRoute(
    builder: (_) => const RoutineManageScreen(),
  ),
);

2. テーマ取得

lib/features/history/history_screen.dart L73
Theme.of(context).textTheme.titleMedium?.copyWith(
  fontWeight: FontWeight.bold,
)

3. ダイアログ・スナックバー表示

lib/features/workout/workout_screen.dart L161-162
showDialog(
  context: context,
  builder: (ctx) => AlertDialog(...),
);
パターンは全て同じ: ○○.of(context) で「自分の位置からツリーを辿って情報を取得」する。context はUIの世界でのみ意味を持つ道具。
STEP 3 / 4

context.mounted チェック

非同期処理の後に context を使うときの注意点:

lib/features/home/home_screen.dart L118-128
Future<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 で存在確認してから使う。
STEP 4 / 4

レイヤー境界: context は Repository に渡してはダメ 弱点

筋トレメモのアーキテクチャを思い出そう:

UI (Widget)  →  Provider  →  Repository  →  DB
  context ✅      ref ✅       なし ✅      なし ✅

Repository の引数を見てみよう:

lib/features/workout/workout_repository.dart L19-23
class 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 以外から呼べないどこからでも呼べる
context は「UI の世界の道具」。Navigator やテーマは画面がないと意味がない。Repository は「データの世界」なので context は不要。この境界を守ることで、Repository をテストやCLIから再利用できる。
ジムの受付に「更衣室の鍵」を渡してもトレーニング記録には関係ない。記録に必要なのは「何キロ×何回やったか」というデータだけ。context = 更衣室の鍵、Repository = トレーニング記録ノート。

2-2 まとめ

  • BuildContext = Widget ツリー上の「自分の住所」。.of(context) でツリーを上に辿る
  • await の後は必ず context.mounted をチェックしてから context を使う
  • context はUI層の道具。Repository に渡してはダメ(レイヤー境界を守る)
Q1. Navigator.of(context).push(...)context は何のために使う?
.of(context) は「context の位置からツリーを上方向に辿って、最も近い Navigator を探す」操作。context は「自分の位置情報」を提供している。
Q2. 以下のコードの問題点は?
await repo.save(data);
Navigator.of(context).pop();
await で待っている間に Widget がツリーから消える可能性がある。if (!context.mounted) return; を入れてから Navigator を使うべき。
Q3. Repository のメソッドに BuildContext を渡すのが NG な理由は?
context はUI層(Widget ツリー)の道具。Repository はデータ層なので、UI に依存してはならない。依存すると、テスト時に Widget 環境を用意する必要が出てきて、テストが困難になる。
TOPIC 2-3

よく使うWidget

筋トレメモを構成するUIパーツを5ステップで習得

0%
STEP 1 / 5

Scaffold: 画面の骨組み

Scaffold はジムの「建物の骨組み」。壁(body)、天井のサイン(AppBar)、床の案内板(bottomNavigationBar)を配置する枠組み。
lib/features/history/history_screen.dart L16-34
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右下のフローティングボタン(未使用)
Scaffold がないと AppBar やスナックバーが動作しない。Material Design の画面を作るときの「必須の土台」。
STEP 2 / 5

Column / Row: 縦並び・横並び

Column = バーベルのプレートを縦に積む(上から下へ)。Row = ダンベルラックに横に並べる(左から右へ)。
lib/features/home/home_screen.dart L28-75
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(...)),
  ],
)
ColumnRow
方向縦(上→下)横(左→右)
mainAxisAlignment縦方向の配置横方向の配置
crossAxisAlignment横方向の配置縦方向の配置
注意高さ無限だとエラー幅無限だとエラー
mainAxis = 部品が並ぶ方向、crossAxis = それと直交する方向。Column の main は縦、cross は横。Row はその逆。Expanded で残りスペースを埋める Widget を指定できる。
STEP 3 / 5

ListView: スクロールできるリスト

lib/features/home/home_screen.dart L90-107
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万人なら後者でないとメモリが足りない。
STEP 4 / 5

Container / SizedBox: サイズと装飾

lib/features/home/home_screen.dart L174-193
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 の横スペース
ContainerSizedBox
用途装飾(色、角丸、影、パディング)サイズ指定のみ
パフォーマンスやや重い(多機能)軽い(サイズだけ)
使い分け背景色や枠線が必要なときスペーサーとして
「ただの余白」に Container を使うのはオーバースペック。SizedBox は const にできるのでパフォーマンスも良い。筋トレメモでは余白は全て SizedBox で統一されている。
STEP 5 / 5

GestureDetector vs InkWell 弱点

筋トレメモには両方のパターンがある:

GestureDetector(リップルなし)

lib/features/home/home_screen.dart L58-72
GestureDetector(
  onTap: () async { ... },
  child: const Icon(Icons.settings, ...),
)

InkWell(リップルあり)

lib/features/history/history_screen.dart L53-56
InkWell(
  borderRadius: BorderRadius.circular(12),
  onTap: () { ... },
  child: Padding(
    padding: const EdgeInsets.all(16),
    child: Column(...),
  ),
)
GestureDetectorInkWell
リップルエフェクトなしあり(Material デザイン準拠)
必要な親 WidgetなしMaterial または Card 内
対応ジェスチャータップ、ドラッグ、長押し等すべてタップ、長押し、ダブルタップ
用途カスタムUI、非 MaterialMaterial デザインのボタン
筋トレメモの使い分け: HistoryScreen の _SessionCardCard の中なので InkWell(リップルでフィードバック)。HomeScreen の設定アイコンは独自デザインなので GestureDetector(シンプルにタップだけ検知)。
GestureDetector = 素手で押すボタン(何も起きないけど確実に反応)。InkWell = 水面をタッチする(波紋が広がって「押した」ことが視覚的に分かる)。Material デザインでは「押した感」が大事なので InkWell を優先する。

2-3 まとめ

  • Scaffold = 画面の土台。body, appBar, bottomNavigationBar を配置する
  • Column/Row = 縦並び/横並び。mainAxis と crossAxis の違いを理解する
  • ListView.builder/.separated = 大量データに対応する遅延生成リスト
  • Container = 装飾、SizedBox = スペーサー(余白は SizedBox が軽量)
  • InkWell = リップルあり(Material準拠)、GestureDetector = リップルなし(汎用)
Q1. 余白を作るとき、SizedBox と Container のどちらが推奨される?
SizedBox はサイズ指定だけのシンプルな Widget で、const にできるためパフォーマンスが良い。Container は装飾(色、影、角丸)が必要なときに使う。
Q2. Card の中でタップ可能なエリアを作るとき、リップルエフェクトを出すには?
InkWell は Material デザインのリップル(波紋)エフェクトを提供する。Card(Material の子)の中で使うのが定番パターン。GestureDetector にはリップルがない。
Q3. ListView.builderListView(children: [...]) より効率的な理由は?
ListView.builder は itemBuilder を必要なときだけ呼び出す(遅延生成)。1000件のリストでも画面に見える10件分しか Widget を作らないのでメモリ効率が良い。
TOPIC 2-4

ナビゲーション

画面間の移動を「スタック」で理解する

0%
STEP 1 / 4

NavigationBar: タブ切り替え

筋トレメモの画面下部にあるタブ:

lib/app.dart L36-44
home: 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: '履歴'),
    ],
  ),
)
タブ切り替えはジムのフロア移動。1階(ホーム)と2階(履歴)をエレベーターで行き来するイメージ。どちらのフロアも常に存在していて、表示を切り替えるだけ。
タブ切り替え画面遷移 (push)
画面は切り替わるだけ新しい画面がスタックに積まれる
「戻る」操作なし「戻る」で前の画面に戻れる
setState で index 更新Navigator.push
NavigationBar はスタック操作ではなく「表示の切り替え」。screens[_selectedIndex] で配列から表示する Widget を選んでいるだけ。
STEP 2 / 4

push: 新しい画面を「積む」

lib/features/home/home_screen.dart L125-128
Navigator.of(context).push(
  MaterialPageRoute(
    builder: (_) => const WorkoutScreen(),
  ),
);
「context の位置から Navigator を見つけて、WorkoutScreen を新しい画面として積む」

push すると画面が「スタック」に積まれる:

WorkoutScreen ← 新しく push された画面(最前面)
↑ push
HomeScreen ← 元の画面(裏に隠れる)
App (MaterialApp)
本の上に新しい本を積むイメージ。一番上の本(WorkoutScreen)だけが見える。下の本(HomeScreen)は隠れているが消えていない。
MaterialPageRoute はプラットフォームに応じたアニメーション付きの遷移を提供する。Android ならスライドイン、iOS ならスワイプバック対応。
STEP 3 / 4

pop / popUntil: 画面を「取り除く」

ダイアログを閉じる + 元の画面まで戻る例:

lib/features/workout/workout_screen.dart L186-189
Navigator.of(ctx).pop();  // ← ダイアログを閉じる
ref.read(activeWorkoutProvider.notifier).discardSession();
Navigator.of(context).popUntil((route) => route.isFirst);
// ↑ 最初の画面(ホーム)まで一気に戻る

popUntil のスタック変化:

AlertDialog ← pop() で消える
WorkoutScreen ← popUntil で消える
HomeScreen ← route.isFirst で止まる
App (MaterialApp)
メソッド動作筋トレメモの使用場面
pop()一番上の画面を1つ取り除くダイアログを閉じる
popUntil(条件)条件を満たすまで複数取り除くトレーニング中断→ホームへ
popUntil((route) => route.isFirst) は「最初のルート(ホーム画面)に到達するまでスタックを巻き戻す」。途中の画面を1つずつ pop する必要がない。
STEP 4 / 4

pushAndRemoveUntil: 入れ替えて積む

トレーニング完了時のナビゲーション:

lib/features/workout/workout_screen.dart L450-455
Navigator.of(context).pushAndRemoveUntil(
  MaterialPageRoute(
    builder: (_) => WorkoutSummaryScreen(summary: summary),
  ),
  (route) => route.isFirst,  // ← ホーム画面だけ残す
);

スタック変化のイメージ:

【Before】

WorkoutScreen
HomeScreen
App

【After: pushAndRemoveUntil】

WorkoutSummaryScreen ← 新しく push
HomeScreen ← isFirst で残る
App
push + popUntil(2手)pushAndRemoveUntil(1手)
操作戻ってから push一発で入れ替え
アニメーション戻る→進むで2回新画面への遷移1回
用途完了画面への遷移
トレーニング完了後に「戻る」を押したら WorkoutScreen ではなくホームに戻りたい。pushAndRemoveUntil なら WorkoutScreen を除去しつつ SummaryScreen を積めるので、「戻る」→ホームが自然に実現できる。
筋トレ完了後、トレーニング中の記録用紙(WorkoutScreen)を片付けて、完了報告書(SummaryScreen)をデスクに置く。デスクの下にはホーム画面が残っているので、報告書を読み終わればホームに戻れる。

2-4 まとめ

  • NavigationBar = タブ切り替え(スタック操作なし、表示の切り替えだけ)
  • push = 新しい画面をスタックに積む
  • pop / popUntil = スタックから画面を取り除く(1つ / 条件まで)
  • pushAndRemoveUntil = 途中の画面を除去しつつ新しい画面を積む

画面遷移は「本の山」で考える。push = 積む、pop = 取る、pushAndRemoveUntil = 入れ替えて積む。

Q1. Navigator.of(context).popUntil((route) => route.isFirst) の動作は?
popUntil は条件が true になるルートに到達するまでスタックの上から画面を取り除く。route.isFirst は最初のルート(ルート画面)で true を返すので、ホーム画面まで一気に戻れる。
Q2. トレーニング完了後に pushAndRemoveUntil を使う理由は?
pushAndRemoveUntil は「SummaryScreen を push しつつ、途中の WorkoutScreen を除去」する。結果、SummaryScreen で「戻る」を押すとホームに直接戻る。ユーザーが完了済みの WorkoutScreen に戻る必要がないため、この動線が自然。
Q3. NavigationBar によるタブ切り替えと push による画面遷移の違いは?
NavigationBar は setState で表示する Widget を切り替えるだけ(全ての画面が裏で生きている)。push は Navigator のスタックに新しいルートを追加する操作。戻るボタンが使えるかどうかが大きな違い。

Lv.2 完了!

Flutter の基礎を学びました。次は Lv.3 Riverpod で状態管理を深堀りします。

Lv.3 Riverpod へ進む →