【Flutter】【go_router】色々な遷移の実装方法まとめ - 基本のページ遷移から状態を保持するタブまで

Flutterのgo_routerを使って色々な遷移の実装する方法についてまとめました。

go_router 14.1.0

はじめに

この記事ではFlutterの画面遷移ライブラリ go_router でいろんな遷移方法を実現する方法についてまとめます。

go_router は Flutter でURLベースの画面遷移を実現するパッケージです。

pub.dev

セットアップ

まず go_router を以下の通りインストールします。

flutter pub add go_router

同じ階層の画面に遷移する

それではまず簡単な画面遷移を行います。
画面は階層化することでスタックして「戻る」ことができますが、まずは同階層の画面へ遷移する方法から見ていきます。

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

void main() => runApp(const MyApp());

// GoRouterクラスでルート(画面遷移の構造)を設定
final GoRouter _router = GoRouter(
  routes: [
    // ’/'と'/screen_b'の2つのルートを設定
    // トップレベルのパスはスラッシュから始める必要がある
    GoRoute(path: '/', builder: (context, state) => const ScreenA()),
    GoRoute(path: '/screen_b', builder: (context, state) => const ScreenB()),
  ],
);

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    // MaterialApp.routerを使用してGoRouterを使用する
    // routerConfigにルート設定(上記で作った_router)を渡す
    return MaterialApp.router(routerConfig: _router, title: 'Go Router Sample');
  }
}

// サンプル画面: ScreenA
class ScreenA extends StatelessWidget {
  const ScreenA({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
          title: const Text('Screen A'), backgroundColor: Colors.blueAccent),
      body: Center(
        child: ElevatedButton(
          // context.goでルートのパスを指定して遷移
          onPressed: () => context.go('/screen_b'),
          child: const Text('Go To Screen B'),
        ),
      ),
    );
  }
}

// サンプル画面: ScreenB
class ScreenB extends StatelessWidget {
  const ScreenB({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
          title: const Text('Screen B'), backgroundColor: Colors.greenAccent),
      body: Center(
        child: ElevatedButton(
          onPressed: () => context.go('/'),
          child: const Text('Go To Screen A'),
        ),
      ),
    );
  }
}

説明はコメントに記載しました。

  • GoRouter クラスでルート(画面遷移の構造)を定義する
  • MaterialApp.router を使って各ウィジェットでgo_routerを使えるようにする
  • context.go(’パス’)で遷移

というのが基本的な使い方になります。

実行結果は下図の通りです。

実行結果

ちなみに上記のコードでは/ というパスのルートを定義していますが、デフォルトではこの名前のルートが初期画面になります。
明示的に初期画面を指定する場合には、以下のようにGoRouterのコンストラクタ引数である initialLocation にパスを指定します。

final GoRouter _router = GoRouter(
  // 初期表示画面のパスを指定
  initialLocation: '/screen_a',
  routes: [
    GoRoute(path: '/screen_a', builder: (context, state) => const ScreenA()),
    GoRoute(path: '/screen_b', builder: (context, state) => const ScreenB()),
  ],
);

子階層の画面に遷移する

前節のように同階層に遷移する場合、画面がスタックに積まれず、AppBarに戻るボタンが表示されません。
もし画面をスタックに積んで戻れるようにしたい場合には、以下のようにGoRouteのコンストラクタのroutes引数に子画面のGoRouteを与えます。
戻る処理自体は Pop メソッドで行います。

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

void main() => runApp(const MyApp());

final GoRouter _router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const ScreenA(),
      routes: [
        // '/'の子ルートとして'screen_b'を定義
        GoRoute(
          // 子ルートのpathにはスラッシュをつけない
          path: 'screen_b',
          builder: (context, state) => const ScreenB(),
        ),
      ],
    ),
  ],
);

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(routerConfig: _router, title: 'Go Router Sample');
  }
}

// サンプル画面: ScreenA
class ScreenA extends StatelessWidget {
  const ScreenA({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
          title: const Text('Screen A'), backgroundColor: Colors.blueAccent),
      body: Center(
        child: ElevatedButton(
          // パスは各ルートのパスをスラッシュで繋げたものを指定
          onPressed: () => context.go('/screen_b'),
          child: const Text('Go To Screen B'),
        ),
      ),
    );
  }
}

// サンプル画面: ScreenB
class ScreenB extends StatelessWidget {
  const ScreenB({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
          title: const Text('Screen B'), backgroundColor: Colors.greenAccent),
      body: Center(
        child: ElevatedButton(
          // Popメソッドで親階層に戻る
          onPressed: () => context.pop(),
          child: const Text('Pop'),
        ),
      ),
    );
  }
}

コメントにも記載しましたが、子ルートを指定した場合には、遷移時には各階層のpath引数を連結したものをパスとして指定します。
例えばより深い階層であれば /screen_b/screenc のようになります。

ちなみに1段階下の画面を飛ばして2階層下の画面に一気に遷移することもできます。
ただしPopは単純に親階層に戻るだけなので、この場合の戻り先は遷移時に飛ばした「1段階下の画面」になります。

実行結果は下図の通りです。

実行結果

画面の一部の領域で遷移を行う

次に、画面の一部領域だけで画面遷移が発生するケースを考えます。
具体的には下図のようにBottomNavigationBarが存在し、それ以外の領域(コンテンツ領域と呼びます)で画面が切り替わるケースです。

一部だけ切り替わる

このようなケースでは以下のようにShellRouteクラスを使って階層構造を表現します。

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

void main() => runApp(const MyApp());

final GoRouter _router = GoRouter(
  initialLocation: '/screen_a',
  routes: [
    // 画面の一部の領域で遷移を行うにはShellRouteを使う
    ShellRoute(
      builder: (BuildContext context, GoRouterState state, Widget child) {
        // コンテンツ画面をラップする画面を返す
        // コンテンツ画面はchildとして渡される
        return RootScreen(child: child);
      },
      // コンテンツ画面はShellRouteの子として設定する
      routes: <RouteBase>[
        GoRoute(
          path: '/screen_a',
          builder: (BuildContext context, GoRouterState state) {
            return const ScreenA();
          },
        ),
        GoRoute(
          path: '/screen_b',
          builder: (BuildContext context, GoRouterState state) {
            return const ScreenB();
          },
        ),
      ],
    ),
  ],
);

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(routerConfig: _router, title: 'Go Router Sample');
  }
}

// BottomNavigationBarを持つルート画面
// コンストラクタで渡すchildがコンテンツ領域に表示される
class RootScreen extends StatelessWidget {
  const RootScreen({required this.child, super.key});

  final Widget child;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: child,
      bottomNavigationBar: BottomNavigationBar(
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(
            icon: Icon(Icons.home),
            label: 'Screen A',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.settings),
            label: 'Screen B',
          ),
        ],
        currentIndex: _getCurrentIndex(context),
        onTap: (int index) => _onItemTapped(index, context),
      ),
    );
  }

  // 現在のBottomNavigationBarのインデックスを取得
  static int _getCurrentIndex(BuildContext context) {
    // GoRouterのパスから現在の画面を判定
    final String location = GoRouterState.of(context).uri.path;
    if (location.startsWith('/screen_a')) {
      return 0;
    }
    if (location.startsWith('/screen_b')) {
      return 1;
    }
    throw Exception('Unknown location: $location');
  }

  // BottomNavigationBarのタップ時の処理
  void _onItemTapped(int index, BuildContext context) {
    // インデックスに応じて画面遷移
    switch (index) {
      case 0:
        GoRouter.of(context).go('/screen_a');
      case 1:
        GoRouter.of(context).go('/screen_b');
      default:
        throw Exception('Unknown index: $index');
    }
  }
}

// コンテンツ画面: ScreenA
class ScreenA extends StatelessWidget {
  const ScreenA({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
          title: const Text('Screen A'), backgroundColor: Colors.blueAccent),
      body: const SizedBox(),
    );
  }
}

// コンテンツ画面: ScreenB
class ScreenB extends StatelessWidget {
  const ScreenB({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
          title: const Text('Screen B'), backgroundColor: Colors.greenAccent),
      body: const SizedBox(),
    );
  }
}

これを実行すると下図の結果が得られます。

実行結果

画面の一部の領域から子に遷移する

次に、前節で作ったコンテンツ領域から、さらに子画面に遷移する実装を行います。
以下は、Screen Aから詳細画面に遷移できるようにしたコードです。

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

void main() => runApp(const MyApp());

final GoRouter _router = GoRouter(
  initialLocation: '/screen_a',
  routes: [
    ShellRoute(
      builder: (BuildContext context, GoRouterState state, Widget child) {
        return RootScreen(child: child);
      },
      routes: <RouteBase>[
        GoRoute(
          path: '/screen_a',
          builder: (BuildContext context, GoRouterState state) {
            return const ScreenA();
          },
          // screen_aに子ルートを追加
          routes: <RouteBase>[
            GoRoute(
              path: 'details',
              builder: (BuildContext context, GoRouterState state) {
                return const DetailsScreen(label: 'Details of Screen A');
              },
            ),
          ],
        ),
        GoRoute(
          path: '/screen_b',
          builder: (BuildContext context, GoRouterState state) {
            return const ScreenB();
          },
        ),
      ],
    ),
  ],
);

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(routerConfig: _router, title: 'Go Router Sample');
  }
}

class RootScreen extends StatelessWidget {
  const RootScreen({required this.child, super.key});

  final Widget child;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: child,
      bottomNavigationBar: BottomNavigationBar(
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(
            icon: Icon(Icons.home),
            label: 'Screen A',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.settings),
            label: 'Screen B',
          ),
        ],
        currentIndex: _getCurrentIndex(context),
        onTap: (int index) => _onItemTapped(index, context),
      ),
    );
  }

  static int _getCurrentIndex(BuildContext context) {
    final String location = GoRouterState.of(context).uri.path;
    if (location.startsWith('/screen_a')) {
      return 0;
    }
    if (location.startsWith('/screen_b')) {
      return 1;
    }
    throw Exception('Unknown location: $location');
  }

  void _onItemTapped(int index, BuildContext context) {
    switch (index) {
      case 0:
        GoRouter.of(context).go('/screen_a');
      case 1:
        GoRouter.of(context).go('/screen_b');
      default:
        throw Exception('Unknown index: $index');
    }
  }
}

// コンテンツ画面: ScreenA
class ScreenA extends StatelessWidget {
  const ScreenA({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
          title: const Text('Screen A'), backgroundColor: Colors.blueAccent),
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            const Text('Screen A'),
            TextButton(
              onPressed: () {
                // ScreenAの詳細画面に遷移
                GoRouter.of(context).go('/screen_a/details');
              },
              child: const Text('View A details'),
            ),
          ],
        ),
      ),
    );
  }
}

// コンテンツ画面: ScreenB
class ScreenB extends StatelessWidget {
  const ScreenB({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
          title: const Text('Screen B'), backgroundColor: Colors.greenAccent),
      body: const SizedBox(),
    );
  }
}

// コンテンツ画面: Details
class DetailsScreen extends StatelessWidget {
  const DetailsScreen({required this.label, super.key});

  final String label;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
          title: const Text('Details Screen'),
          backgroundColor: Colors.redAccent),
      body: Center(child: Text(label)),
    );
  }
}

実行結果は以下のとおりです。

実行結果

タブを切り替えても状態がリセットされないようにする

さて、前節でタブ内で子画面に遷移できるようになりましたが、前節の実装ではタブを切り替えるとそのタブのトップルートに遷移してしまいます。
実際のユースケースを想定すると、タブ遷移前の状態を保っておいて欲しいものです。

これを行うには以下のように StatefulShellRoute を使う必要があります。
これは内部的にはタブごとに別々の Navigator を使っており、タブごとの状態を保持することができます。

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

void main() => runApp(const MyApp());

final GoRouter _router = GoRouter(
  initialLocation: '/screen_a',
  routes: [
    // ShellRouteではなくStatefulShellRouteを使う
    StatefulShellRoute.indexedStack(
      builder: (BuildContext context, GoRouterState state,
          StatefulNavigationShell navigationShell) {
        // RootScreenはStatefulNavigationShellを受け取るように変更
        // StatefulNavigationShellが状態を保ったまま他タブに切り替える機能を持つ
        return RootScreen(navigationShell: navigationShell);
      },
      branches: [
        // タブごとにStatefulShellBranchを定義
        StatefulShellBranch(
          routes: <RouteBase>[
            // タブ内のルートを定義
            GoRoute(
              path: '/screen_a',
              builder: (BuildContext context, GoRouterState state) {
                return const ScreenA();
              },
              routes: [
                GoRoute(
                  path: 'details',
                  builder: (BuildContext context, GoRouterState state) {
                    return const DetailsScreen(label: 'Details of Screen A');
                  },
                ),
              ],
            ),
          ],
        ),
        // 2個目のタブを定義
        StatefulShellBranch(
          routes: [
            GoRoute(
              path: '/screen_b',
              builder: (BuildContext context, GoRouterState state) {
                return const ScreenB();
              },
            ),
          ],
        ),
      ],
    ),
  ],
);

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(routerConfig: _router, title: 'Go Router Sample');
  }
}

class RootScreen extends StatelessWidget {
  // StatefulNavigationShellを受け取るように変更
  const RootScreen({required this.navigationShell, super.key});

  final StatefulNavigationShell navigationShell;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      // bodyにはnavigationShellを渡す
      body: navigationShell,
      bottomNavigationBar: BottomNavigationBar(
        items: const <BottomNavigationBarItem>[
          BottomNavigationBarItem(
            icon: Icon(Icons.home),
            label: 'Screen A',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.settings),
            label: 'Screen B',
          ),
        ],
        // タブインデックスはnavigationShellから取得できる
        currentIndex: navigationShell.currentIndex,
        onTap: (int index) => _onItemTapped(index, context),
      ),
    );
  }

  // タップされた時の処理
  void _onItemTapped(int index, BuildContext context) {
    // タブ間の遷移はこのように行う
    navigationShell.goBranch(
      index,
      // 第二引数にtrueを渡すと、タブのトップルートに遷移する
      // つまり以下のようにすると、非アクティブなタブ(現在のタブとは別のタブ)をクリック
      // した場合には状態を保ったままそのタブに遷移し、アクティブなタブ(現在のタブ)を
      // クリックした場合にはトップのルートに戻るという挙動になる
      initialLocation: index == navigationShell.currentIndex,
    );
  }
}

// コンテンツ画面: ScreenA
class ScreenA extends StatelessWidget {
  const ScreenA({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
          title: const Text('Screen A'), backgroundColor: Colors.blueAccent),
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            const Text('Screen A'),
            TextButton(
              onPressed: () {
                // タブ内での遷移の仕方はここまでのやり方と同様
                GoRouter.of(context).go('/screen_a/details');
              },
              child: const Text('View A details'),
            ),
          ],
        ),
      ),
    );
  }
}

// コンテンツ画面: ScreenB
class ScreenB extends StatelessWidget {
  const ScreenB({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
          title: const Text('Screen B'), backgroundColor: Colors.greenAccent),
      body: const SizedBox(),
    );
  }
}

// コンテンツ画面: Details
class DetailsScreen extends StatelessWidget {
  const DetailsScreen({required this.label, super.key});

  final String label;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
          title: const Text('Details Screen'),
          backgroundColor: Colors.redAccent),
      body: Center(child: Text(label)),
    );
  }
}

少々変更点が多いですが、説明はコメントに記載したとおりです。

これを実行すると以下の結果が得られます。

実行結果

タブ切り替え時に状態が保持されていることを確認できました。

参考

pub.dev

github.com