9
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Flutter】シンプルな一画面の状態からタブ遷移まで発展させる道のり【Q&Aまとめー】

Last updated at Posted at 2025-12-17

  

こんにちは~

watnow一回生井上優里です。しし座です。

今回の秋プロでは夏のハッカソンと同じくflutterを使うのですが、まだまだわからないことだらけなのでとりあえずchatGPTに投げてそこから一つ一つの疑問を解消しています。
  

今回ははじめての遷移だったので、とりあえず作っておいたただのhome画面一ページから四つのタブに遷移できるようになるまでの私とGPTの道のりQ&Aをまとめました。

ちなみにこれらの画面はmergeしたらどっかいきました。当日までに舞い戻っていることを願います。
秋プロのネタバレとかになってたら、チームの人ごめんなさい。

  
最初の HomePage 1画面 の状態がこちらです

ホーム0%.png

  
この状態からめざせ遷移!
  

1. 最初の状態:HomePage 1枚だけのアプリ構成

最初に作っていた構成は、かなりシンプルでした

MyApp
 └─ MaterialApp
      └─ HomePage(Stateless)
           ├─ body(UI全部)
           └─ bottomNavigationBar(フッター)

HomePage の中に、

  • タイトル
  • 達成度(0%)
  • サボテン画像(0per.png)
  • フッターのアイコン(SVG)

…すべてを詰め込んでいる状態。

このままでも「1画面アプリ」としては動きますが、

  • Todo のチェック状態を持ちたい
  • 別ページ(Todo・インサイト)を追加したい
  • タブを押して画面を切り替えたい

となると、

「どこで状態を持つのが正解?」

という問題にぶつかりました。


2. 「どこで状態(State)を管理すればいいの?」

やりたいことはこんな感じでした。

  • Todo をチェックしたら状態を更新したい
  • タブを押したら表示ページを切り替えたい
  • 達成度に応じてサボテン画像を変えたい

でも、最初の HomePage は StatelessWidget

Stateless = 「状態を持てない」ウィジェット

なので、そのままでは

  • Todo リスト
  • タブの選択状態
  • 達成度の再計算

といった「変わる値」を扱えません。

ここで一度こう考えます。

HomePage を StatefulWidget にすればいいのでは?

結論:あまりよくない です。

理由は、

  • HomePage はあくまで 1つのページ にすぎない
  • タブや Todo 状態など、アプリ全体で共有したい状態 を持つ場所としては重すぎる
  • 画面構成が複雑になると、「どのページが何を管理してるのか」が破綻しやすい

つまり必要なのは、

「アプリ全体の状態」をまとめて持つ 親ウィジェット

でした。
ここで登場するのが AppState です。

3. AppState を作った理由:状態を一箇所に集める“アプリの中心”を作る

AppState を新しく作り、構成をこう変えました

■ Before(最初の構成)

MyApp(Stateless)
 └─ MaterialApp
      └─ HomePage(Stateless:UIだけ)
           └─ Scaffold
                ├─ body(達成度・画像・レイアウト)
                └─ bottomNavigationBar(フッター)

■ After(AppState を導入した構成)

MyApp(Stateless)
 └─ MaterialApp
      └─ AppState(Stateful:アプリ全体の状態管理)
           └─ Scaffold
                └─ body(_buildCurrentPage() の結果)
                    ├─ HomePage(%と画像パスを受け取るだけ)
                    ├─ TodoPage
                    └─ InsightPage / GroupPage(仮)

AppState が担当するのは:

  • 今どのタブが選ばれているか(_currentIndex
  • Todo のリスト(List<Todo>
  • 達成度(%)の計算
  • サボテン画像ファイル名の決定
  • タブに応じて表示するページの切り替え

一方で HomePage は、

「もらった percentimagePath をそのまま表示するだけ」

という、表示専用の StatelessWidget に戻しました。

  • 状態(State)を持つのは AppState
  • 表示(UI)に集中するのが HomePage / TodoPage

という 役割分担 がはっきりしたタイミングです。


4. StatefulWidget が“2つのクラス”に分かれる理由

AppState を作ると、必ずこの形になります

class AppState extends StatefulWidget {
  const AppState({super.key});

  @override
  State<AppState> createState() => _AppStateState();
}

class _AppStateState extends State<AppState> {
  // 状態(State)と build() はここに書く
}

最初は、

「なんでクラスが2つもあるの?1つでよくない?」

と思っていましたが、最終的に分かったのは、

✅ StatefulWidget(変わらない“外側”)

  • new するときに使う「入り口」
  • ここには 変わらない設定 を置く
  • build() は持たない
  • const AppState() で何度作っても OK

✅ State(変わる“中身”)

  • 実際の状態(_currentIndex / _todos など)を持つ
  • build() を実行するのはこっち
  • setState() で再描画されるのもこっち

StatefulWidget = 「このウィジェットには State が必要ですよ」という表札
State = 「実際の状態と UI を持っている本体」

Flutter は、

UI を効率的に再描画するために「変わらない部分」と「変わる部分」を分けている

ということがここでようやくつながりました。


5. フッター(BottomNavigationBar)を HomePage から AppState にお引越し

最初は HomePage にフッターを書いていましたが、
AppState を導入したところで矛盾に気づきます。

  • フッター(タブバー)は 全画面共通
  • でも HomePage1つの画面

なので、本来フッターがいるべき場所は、

「アプリ全体を管理している親」、つまり AppState

です。

フッターを AppState に移したことで、

  • どのページでも同じフッターが出る
  • タップされたタブ番号を AppState が一元管理できる

ようになり、タブ切り替えの土台 が整いました。


6. BottomNavIcon を別クラスにしました

フッターの中身は最初こんな感じでした

Row(
  children: [
    SvgPicture.asset('assets/icons/icon_group.svg', ...),
    SizedBox(width: 34),
    SvgPicture.asset('assets/icons/icon_home.svg', ...),
    // ...
  ],
)

これだと、4つのアイコンそれぞれに

  • 画像パス
  • サイズ
  • タップ処理

を毎回書くことになるので、次のように部品化しました

class _BottomNavIcon extends StatelessWidget {
  final String assetPath;
  final bool isActive;
  final VoidCallback onTap;

  const _BottomNavIcon({
    super.key,
    required this.assetPath,
    required this.isActive,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    final double size = isActive ? 56 : 50;

    return GestureDetector(
      onTap: onTap,
      child: SvgPicture.asset(
        assetPath,
        width: size,
        height: size,
      ),
    );
  }
}

そして呼び出し側をこう書きかえます

_BottomNavIcon(
  assetPath: 'assets/icons/icon_home.svg',
  isActive: _currentIndex == 1,
  onTap: () => _onTabTapped(1),
),

こうすることで、

  • 見た目のルール(サイズ・タップ処理)は _BottomNavIcon に集約
  • 「どのアイコンを表示するか」「どのタブ番号か」だけを呼び出し側で指定

できるようになりました。


7. assetPath / VoidCallback / isActive の仕組み

ここは初心者的に「言葉が急に増えるポイント」なので、順番に整理します。

7-1. assetPath はどこから来ているの?

呼び出し側:

_BottomNavIcon(
  assetPath: 'assets/icons/icon_home.svg',
  // ...
);

定義側:

class _BottomNavIcon extends StatelessWidget {
  final String assetPath;

  const _BottomNavIcon({
    super.key,
    required this.assetPath,
    // ...
  });
}

流れとしてはシンプルで、

  1. _BottomNavIcon(...) を呼ぶときに 文字列を渡す
  2. その値が、コンストラクタの assetPath 引数に入る
  3. それが final String assetPath; というフィールドに保持される
  4. build() の中で SvgPicture.asset(assetPath, ...) として使われる

つまり、

「コンストラクタで受け取った値を、そのまま中のフィールドに保存して使っている」

という流れになっています。


7-2. VoidCallback(戻り値なしの関数型)とは?

final VoidCallback onTap;

これは、

「引数なし・戻り値なしの関数を入れておくための変数」

という意味です。

例えば、呼び出し側でこう書きます

_BottomNavIcon(
  onTap: () => _onTabTapped(1),
)

すると _BottomNavIcononTap フィールドには
_onTabTapped(1) を実行する無名関数」が入ります。

build() 側では、

GestureDetector(
  onTap: onTap, // ← ここでその関数を渡す
  child: ...
)

としていて、実際にタップされたとき、
Flutter が内部で onTap() を呼び出します。

戻り値なしにする理由は?

もし onTap の型が int Function() のように「戻り値あり」だと、

  • 「タップされたら int を返さないといけない UI 部品」になってしまう
  • UI イベントとしては不自然&想定外
  • Flutter の側が期待する型と合わずコンパイルエラーになる

そのため、UIのタップイベントでは、

「何か処理をするだけで、値は返さない関数」

を表す VoidCallback がよく使われます。


7-3. isActive は何と何を比較しているの?

呼び出し側:

_BottomNavIcon(
  isActive: _currentIndex == 1,
  // ...
);

AppState の中では、まずこういう状態変数があります

class _AppStateState extends State<AppState> {
  int _currentIndex = 1; // 0:グループ, 1:ホーム, 2:リスト, 3:インサイト
  // ...
}
  • _currentIndex = 「今選ばれているタブ番号」

isActive: _currentIndex == 1 は、

「今選ばれているタブ番号(_currentIndex) が
このアイコンが担当する番号(ここでは 1 = ホーム) と同じか?」

を比較して、true / false を返しているだけです。

  • _currentIndex1isActivetrue(選択中)
  • _currentIndex2isActivefalse(非選択)

そして _BottomNavIcon 側では、

final bool isActive;

@override
Widget build(BuildContext context) {
  final double size = isActive ? 56 : 50;
  // ...
}

というように、

isActivetrue なら大きく、false なら少し小さく表示

といった見た目の分岐に使っています。

ここで言っていた「AppState の状態」とは、

  • _currentIndex(今どのタブが選ばれているか)
  • List<Todo>(チェック状況)
  • それらから計算される達成度

などの 「AppState 内にあるフィールドの値」全体 を指しています。


8. Todo クラスの理解

class Todo {
  final String title;
  bool isDone;

  Todo(this.title, {this.isDone = false});
}

これだけ見るとただの2行ですが、
ここから クラスまわりの用語 が一気に整理できました。

8-1. まずは1つインスタンスを作ってみる

final todo = Todo('レポートを1つ終わらせる', isDone: false);

ここで起きていること:

  • Todo(...)
    Todo クラスという「設計図」から、新しい「実物」を作っている
  • todo
    → その実物(インスタンス)
  • title / isDone
    todo が持っているデータ(フィールド/インスタンス変数)

複数あればこうなります

final todos = <Todo>[
  Todo('レポートを1つ終わらせる'),
  Todo('30分勉強する'),
  Todo('課題を1つ提出する'),
];

todosTodo インスタンスのリスト です。

8-2. 用語をまとめて整理してみる

用語 役割
クラス 設計図。「この型のデータは何を持って、どんな振る舞いができるか」を定義
インスタンス 設計図から実際に作られた 1個1個の実物(Todo(...) の結果)
インスタンス変数 インスタンスが持っているデータ(title / isDone
コンストラクタ インスタンスを作るための入口(Todo(...) 呼び出し時に使われる部分)
メソッド インスタンス(またはクラス)に紐づく処理(toggle() など)
ローカル変数 メソッドや関数の中だけで使う一時的な変数(final done = ... など)

Todo クラスにはまだメソッドを足していませんが、
例えば次のようなメソッドも書けます

class Todo {
  final String title;
  bool isDone;

  Todo(this.title, {this.isDone = false});

  void toggle() {
    isDone = !isDone;
  }
}

こうすると、

todo.toggle(); // チェック状態を反転

のように、「Todo 自身が自分の状態を変える」メソッド を持てます。

8-3. Todo は「Model(データの形)」という役割

ここまで整理したうえで見直すと、

class Todo {
  final String title;
  bool isDone;
}

は、

  • 画面(UI)でもない
  • 色やレイアウトのロジックでもない
  • 画面遷移の仕組みでもない

→ ただただ、

「Todo 1件がどんな情報を持っているか」を決めているだけ

です。

このような「データの形だけを表すクラス」は
一般的に Model(モデル) と呼ばれます。

  • Todo = Model(データ)
  • HomePage / TodoPage = View(画面)
  • AppState のように状態+ロジックを持つ部分 = Controller / State管理

…というように 役割分担 が見えてくるようになりました。


9. getter × setState の連動で「UIがどう変わるか」が見えた

AppState では、達成度やサボテン画像を
getter で計算していました

int get _rawPercent { ... }

int get _cactusPercent { ... }

String get _cactusImagePath => 'assets/images/${_cactusPercent}per.png';

ここでの流れはこうです。

  1. ユーザーが Todo のチェックを切り替える
  2. _toggleTodo の中で setState() を呼ぶ
  3. _todos の中身が変わる
  4. build() が再実行される
  5. build() の中で _rawPercent / _cactusPercent / _cactusImagePath の getter がもう一度呼ばれる
  6. 計算結果(%や画像パス)が変わる
  7. それを受け取った HomePage が、新しい%と画像で描画される

getter は、

「今の状態から計算した結果を、その場で返す小さな関数」

として使われていて、
状態と UI のつながりをシンプルに保つための仕組み になっています。


10. 完成!!

video_592466714819035715-QACPuTom1-ezgif.com-video-to-gif-converter.gif
  

todoリストをチェックすると、雑草が別形態に変化するとこまで完成したのですが
そこまで見せてしまうと秋プロのフライングデモ動画になってしまうのでやめときます。

11. 今回の学びのまとめ

  • なぜ HomePage ではダメ?
    → 状態を持てない Stateless だから

  • じゃあどこに状態を置く?
    → アプリ全体を見る AppState(親)に置く

  • なぜ StatefulWidget は 2 クラス必要?
    → 「変わる部分(State)」と「変わらない部分(Widget)」を分けるため

  • じゃあフッターはどこに置く?
    → 全画面共通のものなので、状態を持つ AppState に置く

  • なぜ _BottomNavIcon を作る?
    → タブの UI を部品化して、重複を減らし保守しやすくするため

  • Todo クラスの役割は?
    → Model(データの形)として UI から分離するため

  • getter は?
    → 状態から派生する値(%や画像パス)をきれいに管理するため


分からないことの方が多いですが、バラバラに見えていた用語やコードの断片がある程度理解できたような気がします。

 

ではよい年越しにしましょう、コマウォ

9
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?