はじめに
こんにちは、しくみ製作所の渡辺です。
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やモバイルに関する投稿を続けていきたいと思います。