go_routerとriverpodを組み合わせる「Flutterやってみたシリーズvol.01」

Date
August 15, 2023

はじめに

image

こんにちは、しくみ製作所の渡辺です。

2022年1月に入社して、最近はアプリ関連の案件だと、ネイティブのコード(ほぼjava + kotlin & swift)の案件と、Flutterの案件のサポート(プルリクをレビューなど)したりしています。

ネイティブの方の案件では動画プレイヤーを扱ったりしていて、なかなか大変です。今回は、断続的にキャッチアップを続けているFlutterに関する記事を書こうと思います。

go_routerとriverpodを組み合わせる

Flutterの中でもgo_routerの動向はかなり気になっており、少し前にやっと状態の保存ができるStatefulShellRouteが登場しました。

しかし、そもそもriverpodとShellRouteの組み合わせで事足りるんじゃ無いかと思い、今回検証してみました。

まずは、一番シンプルにStatefulShellRouteを使用してみます。

コード
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

final GlobalKey<NavigatorState> _rootNavigatorKey =
    GlobalKey<NavigatorState>(debugLabel: 'root');
final GlobalKey<NavigatorState> _sectionANavigatorKey =
    GlobalKey<NavigatorState>(debugLabel: 'sectionANav');

void main() {
  runApp(const App());
}

final _router = GoRouter(
    navigatorKey: _rootNavigatorKey,
    debugLogDiagnostics: true,
    initialLocation: '/a',
    routes: <RouteBase>[
      StatefulShellRoute.indexedStack(
          builder: (context, state, navigationShell) {
            return ScaffoldWithNavBar(navigationShell: navigationShell);
          },
          branches: <StatefulShellBranch>[
            StatefulShellBranch(
                navigatorKey: _sectionANavigatorKey,
                routes: <RouteBase>[
                  GoRoute(
                    path: '/a',
                    builder: (context, state) {
                      return const ScreenA();
                    },
                  ),
                ]),
            StatefulShellBranch(routes: <RouteBase>[
              GoRoute(
                path: '/b',
                builder: (context, state) {
                  return const ScreenB();
                },
              )
            ])
          ])
    ]);

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      routerConfig: _router,
    );
  }
}

class ScaffoldWithNavBar extends StatelessWidget {
  const ScaffoldWithNavBar({
    required this.navigationShell,
    Key? key,
  }) : super(key: key ?? const ValueKey<String>('ScaffoldWithNavBar'));

  final StatefulNavigationShell navigationShell;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Demo'),
      ),
      body: navigationShell,
      bottomNavigationBar: NavigationBar(
        destinations: const [
          NavigationDestination(
            selectedIcon: Icon(Icons.home),
            icon: Icon(Icons.home_outlined),
            label: 'Home',
          ),
          NavigationDestination(
            selectedIcon: Icon(Icons.school),
            icon: Icon(Icons.school_outlined),
            label: 'School',
          ),
        ],
        selectedIndex: navigationShell.currentIndex,
        onDestinationSelected: (int index) {
          switch (index) {
            case 0:
              GoRouter.of(context).go('/a');
              break;
            case 1:
              GoRouter.of(context).go('/b');
              break;
          }
        },
      ),
    );
  }
}

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

  @override
  State<StatefulWidget> createState() => ScreenAState();
}

class ScreenAState extends State<ScreenA> {
  int _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Center(
        child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
      Text('Counter: $_counter'),
      TextButton(
          onPressed: () {
            setState(() {
              _counter++;
            });
          },
          child: const Text('Increment counter')),
    ]));
  }
}

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

  @override
  State<StatefulWidget> createState() => ScreenBState();
}

class ScreenBState extends State<ScreenB> {
  int _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Center(
        child: Column(mainAxisSize: MainAxisSize.min, children: <Widget>[
      Text('Counter: $_counter'),
      TextButton(
          onPressed: () {
            setState(() {
              _counter++;
            });
          },
          child: const Text('Increment counter'))
    ]));
  }
}

しっかりbottomNavigationBar間の移動で、counterの値が保存されています。

本題から少し外れますが、bottomNavigationBarには、NavigationBarを与えています。これは、Material 3に適応した新しいWidgetです。

これをriverpodとShellRouteで書き換えたのが、下記のコードです。

コード
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'main.g.dart';

@Riverpod(keepAlive: true)
class CounterA extends _$CounterA {
  @override
  int build() => 0;

  void increment() => state++;
}

@Riverpod(keepAlive: true)
class CounterB extends _$CounterB {
  @override
  int build() => 0;

  void increment() => state++;
}

void main() {
  runApp(
    const ProviderScope(child: App()),
  );
}

final GoRouter _router = GoRouter(
  initialLocation: '/a',
  debugLogDiagnostics: true,
  routes: <RouteBase>[
    ShellRoute(
      builder: (BuildContext context, GoRouterState state, Widget child) {
        return ScaffoldWithNavBar(child: child);
      },
      routes: <RouteBase>[
        GoRoute(
          path: '/a',
          builder: (BuildContext context, GoRouterState state) {
            return const ScreenA();
          },
        ),
        GoRoute(
          path: '/b',
          builder: (BuildContext context, GoRouterState state) {
            return const ScreenB();
          },
        ),
      ],
    ),
  ],
);

/// Riverpodで書き直すサンプル
class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      routerConfig: _router,
    );
  }
}

class ScaffoldWithNavBar extends StatelessWidget {
  const ScaffoldWithNavBar({
    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: 'A Screen',
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.business),
            label: 'B Screen',
          ),
        ],
        currentIndex: _calculateSelectedIndex(context),
        onTap: (int idx) => _onItemTapped(idx, context),
      ),
    );
  }

  static int _calculateSelectedIndex(BuildContext context) {
    final String location = GoRouterState.of(context).uri.toString();
    if (location.startsWith('/a')) {
      return 0;
    }
    if (location.startsWith('/b')) {
      return 1;
    }
    return 0;
  }

  void _onItemTapped(int index, BuildContext context) {
    switch (index) {
      case 0:
        GoRouter.of(context).go('/a');
        break;
      case 1:
        GoRouter.of(context).go('/b');
        break;
    }
  }
}

class ScreenA extends ConsumerWidget {
  const ScreenA({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          const Text('Screen A'),
          TextButton(
            onPressed: () => ref.read(counterAProvider.notifier).increment(),
            child: const Text('increment'),
          ),
          Text('${ref.watch(counterAProvider)}'),
        ],
      ),
    );
  }
}

class ScreenB extends ConsumerWidget {
  const ScreenB({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          const Text('Screen B'),
          TextButton(
            onPressed: () => ref.read(counterBProvider.notifier).increment(),
            child: const Text('increment'),
          ),
          Text('${ref.watch(counterBProvider)}'),
        ],
      ),
    );
  }
}

※動画は全く同じなので、割愛します。

ここでのポイントは @Riverpod(keepAlive: true)の部分です。

riverpod_generatorが生成するコードはautoDisposeがデフォルトなので、keepAlive: trueにしないと画面を遷移したときにproviderが破棄されるようです。

コードを見ると、StatefulShellRouteの時より長くなってます。また、上記に記載したコードの他にriverpod_generatorが生成したコードもあるので、もうちょっとだけ長くなります。

商用のアプリでは、もっと複雑な状態を管理しないといけないと思います。riverpodのようなパッケージを導入しないのは難しいと思いますので、ちょっとくらいコードが長くなるのはしょうがないかなと思います。

さいごに

今回使ったサンプルコードを下記に置いてあります。

今後もFlutterやモバイルに関する投稿を続けていきたいと思います。

🖋
新着記事