BottomNavigationBar를 유지하는 거의 모든 방법을 찾아보고 적용해보았다. 대부분의 경우 특정 상황에서 문제가 발생하게되고, 최종적으로 테스트한 모든 상황에서 문제가 발생하지않는 방법을 찾아냈기 때문에 정리해보고자 한다.
1. IndexedStack + Navigator
Tab끼리의 상태 유지를 위해 IndexedStack을 사용하고, 하나의 Tab에서 Routing 시 BottomNavigation을 유지하기 위해 Navigator를 사용하는 방법이다.
[코드]
import 'dart:math';
import 'package:flutter/material.dart';
void main() => runApp(const TestApp());
class TestApp extends StatelessWidget {
const TestApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return const MaterialApp(
title: 'Flutter Demo',
home: Home(),
);
}
}
class Home extends StatefulWidget {
const Home({Key? key}) : super(key: key);
@override
State<Home> createState() => _HomeState();
}
class _HomeState extends State<Home> {
final _pages = const [Page1(), Page2(), Page3()];
final _navigatorKeyList = List.generate(3, (index) => GlobalKey<NavigatorState>());
int _currentIndex = 0;
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
return !(await _navigatorKeyList[_currentIndex].currentState!.maybePop());
},
child: Scaffold(
body: IndexedStack(
index: _currentIndex,
children: _pages.map((page) {
int index = _pages.indexOf(page);
return Navigator(
key: _navigatorKeyList[index],
onGenerateRoute: (_) {
return MaterialPageRoute(builder: (context) => page);
},
);
}).toList(),
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: '페이지 1',
),
BottomNavigationBarItem(
icon: Icon(Icons.calendar_today_outlined),
label: '페이지 2',
),
BottomNavigationBarItem(
icon: Icon(Icons.mail),
label: '페이지 3',
),
],
onTap: (index) => setState(() => _currentIndex = index),
),
),
);
}
}
class Page1 extends StatelessWidget {
const Page1({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Page 1')),
endDrawer: Builder(builder: (context) {
int randomValue = Random(DateTime.now().millisecondsSinceEpoch).nextInt(1232131);
return Drawer(child: Text(randomValue.toString()));
}),
body: Center(
child: FutureBuilder(
future: Future.delayed(const Duration(seconds: 2)),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return TextButton(
child: const Text('Next page'),
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(builder: (context) => const Page4()));
},
);
} else {
return const Center(child: CircularProgressIndicator());
}
},
),
),
);
}
}
class Page2 extends StatelessWidget {
const Page2({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Page 2')),
body: const Center(
child: Text('Page 2'),
),
);
}
}
class Page3 extends StatelessWidget {
const Page3({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Page 3')),
body: const Center(
child: Text('Page 3'),
),
);
}
}
class Page4 extends StatelessWidget {
const Page4({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Page4')),
endDrawer: Builder(builder: (context) {
int randomValue = Random(DateTime.now().millisecondsSinceEpoch).nextInt(1232131);
return Drawer(child: Text(randomValue.toString()));
}),
body: FutureBuilder(
future: Future.delayed(const Duration(seconds: 2)),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return const Center(child: Text('Page 4'));
} else {
return const Center(child: CircularProgressIndicator());
}
},
),
);
}
}
대부분의 경우에 큰 문제 없이 잘 작동한다.
Navigator.of(context).push(Material~~~~);
를 통해 Tab 내부에서 routing을 할 수 있고,
Navigator.of(context, rootNavigator: true).push(Material~~~~);
를 통해 rootNavigator에서 routing하여 BottomNavigationBar를 안 보이게 할 수도 있다.
한 가지 문제점은 Stack으로 쌓여있는데, Widget Inspector를 통해 디버깅이 불가능하다.
위 이미지에서 알 수 있듯이, Page3에서의 widget을 클릭하면 Page2의 위젯이 클릭이 된다. 페이지가 복잡해질 경우 각 페이지내부 widget의 외곽선(?)들이 난잡하게 그려져있어 보기가 매우 불편하다. 그리고 AppBar의 BackButton이 자동으로 생성되지 않아 모든 AppBar에서 BackButton을 수동으로 넣어줘야한다는 불편함이 있다.
2. Persistent_bottom_nav_bar 패키지(CupertinoApp + CupertinoScaffold)
내부적으로 Cupertino widget들을 사용하여 구현한 패키지로, 실제 stackoverflow에 검색 시 cupertino를 사용하라는 답변이 많다.
하지만, 상당히 문제가 많은 방식으로, 조금만 앱이 복잡해져도 사용이 불가능하다.
[코드]
Page1과 Page 4의 Scaffold의 body에서 FutureBuilder를 통해 네트워크에서 데이터를 받아오는 간단한 시나리오이다. Page1에서는 drawer를 열어도 아무 문제가 없다. 하지만, Page1에서 이동한 Page4에서 drawer를 열고 닫을 경우 Page4가 rebuild되어 다시 로딩하게 된다.
[이미지]
FutureBuilder에서 future를 1번만 호출하게 하는 방식으로 구현하여도, 결국 'rebuild'가 되기 때문에 소용이 없다. Page4가 const인 경우에는 rebuild되지 않기 때문에 하나의 Tab에서 routign되는 모든 widget이 const여야 정상적으로 동작시킬 수 있다. drawer나 bottomsheet같은 widget을 사용하지 않으면 문제가 발생하지않는다.
3. PageView + Navigator + AutomaticKeepAliveClientMixin
Page 라우팅을 위해 PageView widget을, tab을 옮겨도 상태가 유지되도록 만들기 위해 AutomaticKeepAliveClientMixin을, 하나의 tab 내부에서 page를 BottomNavgationBar를 유지하면서 routing하기 위해 Navigator widget을 사용하는 방법이다.
[코드]
import 'dart:math';
import 'package:flutter/material.dart';
void main() => runApp(const TestApp());
class TestApp extends StatelessWidget {
const TestApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return const MaterialApp(
title: 'Flutter Demo',
home: Home(),
);
}
}
class Home extends StatefulWidget {
const Home({Key? key}) : super(key: key);
@override
State<Home> createState() => _HomeState();
}
class _HomeState extends State<Home> {
final _pages = const [Page1(), Page2(), Page3()];
final _navigatorKeyList = List.generate(3, (index) => GlobalKey<NavigatorState>());
int _currentIndex = 0;
final _pageViewController = PageController();
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
return !(await _navigatorKeyList[_currentIndex].currentState!.maybePop());
},
child: Scaffold(
body: PageView(
physics: const NeverScrollableScrollPhysics(),
controller: _pageViewController,
children: _pages.map((page) {
int index = _pages.indexOf(page);
return CustomNavigator(page: page, navigatorKey: _navigatorKeyList[index]);
}).toList(),
onPageChanged: (index) => setState(() => _currentIndex = index),
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: '페이지 1',
),
BottomNavigationBarItem(
icon: Icon(Icons.calendar_today_outlined),
label: '페이지 2',
),
BottomNavigationBarItem(
icon: Icon(Icons.mail),
label: '페이지 3',
),
],
onTap: (index) => _pageViewController.jumpToPage(index),
),
),
);
}
}
class Page1 extends StatelessWidget {
const Page1({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Page 1'),
centerTitle: true,
),
endDrawer: Builder(builder: (context) {
int randomValue = Random(DateTime.now().millisecondsSinceEpoch).nextInt(1232131);
return Drawer(child: Text(randomValue.toString()));
}),
body: Center(
child: FutureBuilder(
future: Future.delayed(const Duration(seconds: 2)),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return TextButton(
child: const Text('Next page'),
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(builder: (context) => const Page4()));
},
);
} else {
return const Center(child: CircularProgressIndicator());
}
},
),
),
);
}
}
class Page2 extends StatelessWidget {
const Page2({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Page 2'),
centerTitle: true,
),
body: const Center(
child: Text('Page 2'),
),
);
}
}
class Page3 extends StatelessWidget {
const Page3({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Page 3'),
centerTitle: true,
),
body: const Center(
child: Text('Page 3'),
),
);
}
}
class Page4 extends StatelessWidget {
const Page4({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Page4'),
centerTitle: true,
),
endDrawer: Drawer(child: Text('1'.toString())),
body: FutureBuilder(
future: Future.delayed(const Duration(seconds: 2)),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return const Center(child: Text('Page 4'));
} else {
return const Center(child: CircularProgressIndicator());
}
},
),
);
}
}
class CustomNavigator extends StatefulWidget {
final Widget page;
final Key navigatorKey;
const CustomNavigator({Key? key, required this.page, required this.navigatorKey}) : super(key: key);
@override
_CustomNavigatorState createState() => _CustomNavigatorState();
}
class _CustomNavigatorState extends State<CustomNavigator> with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return Navigator(
key: widget.navigatorKey,
onGenerateRoute: (_) => MaterialPageRoute(builder: (context) => widget.page),
);
}
}
1번의 방식에서 IndexedStack을 없애기 위해 PageView과 AutomaticKeepAliveClientMinin을 사용한 방법이다. CustomNavigator를 따로 정의하여 사용해야한다는 단점이 있지만, 앞서 지적된 문제들(Inspector에 widget이 겹쳐지는 문제, rebuild되는 문제)들이 해결된다.
그런데, 가장 큰 문제점은 tab내부에서 routing하여 새로운 페이지를 열었을 경우, 다른 tab으로 이동 시 다시 rebuild된다. 사실상 사용이 불가능하다.
4. DefaultTabController + TabBarView + TabBar + Navigator + AutomaticKeepAliveClientMixin
가장 완벽한 방법이다. 현재까지는 이렇다 할 문제를 찾지 못 했다. 다소 불필요해 보이는 widget들이 많이 추가되는게 유일한 단점이다.
[코드]
import 'dart:math';
import 'package:flutter/material.dart';
void main() => runApp(const TestApp());
class TestApp extends StatelessWidget {
const TestApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return const MaterialApp(
title: 'Flutter Demo',
home: Home(),
);
}
}
class Home extends StatefulWidget {
const Home({Key? key}) : super(key: key);
@override
State<Home> createState() => _HomeState();
}
class _HomeState extends State<Home> {
final _pages = const [Page1(), Page2(), Page3()];
final _navigatorKeyList = List.generate(3, (index) => GlobalKey<NavigatorState>());
int _currentIndex = 0;
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () async {
return !(await _navigatorKeyList[_currentIndex].currentState!.maybePop());
},
child: DefaultTabController(
length: 3,
child: Scaffold(
body: TabBarView(
children: _pages.map(
(page) {
int index = _pages.indexOf(page);
return CustomNavigator(
page: page,
navigatorKey: _navigatorKeyList[index],
);
},
).toList(),
),
bottomNavigationBar: TabBar(
isScrollable: false,
indicatorPadding: const EdgeInsets.only(bottom: 74),
automaticIndicatorColorAdjustment: true,
onTap: (index) => setState(() {
_currentIndex = index;
}),
tabs: const [
Tab(
icon: Icon(
Icons.home,
),
text: '플라토',
),
Tab(
icon: Icon(
Icons.calendar_today,
),
text: '캘린더',
),
Tab(
icon: Icon(
Icons.email,
),
text: '쪽지',
),
],
),
),
),
);
}
}
class Page1 extends StatelessWidget {
const Page1({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Page 1'),
centerTitle: true,
),
body: Center(
child: FutureBuilder(
future: Future.delayed(const Duration(seconds: 2)),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return TextButton(
child: const Text('Next page'),
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (context) => const Page4()));
},
);
} else {
return const Center(child: CircularProgressIndicator());
}
},
),
),
);
}
}
class Page2 extends StatelessWidget {
const Page2({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Page 2'),
centerTitle: true,
),
body: const Center(
child: Text('Page 2'),
),
);
}
}
class Page3 extends StatelessWidget {
const Page3({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Page 3'),
centerTitle: true,
),
body: const Center(
child: Text('Page 3'),
),
);
}
}
class Page4 extends StatelessWidget {
const Page4({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Page4'),
centerTitle: true,
),
body: FutureBuilder(
future: Future.delayed(const Duration(seconds: 2)),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return const Text('Page4');
} else {
return const Center(child: CircularProgressIndicator());
}
},
),
);
}
}
class CustomNavigator extends StatefulWidget {
final Widget page;
final Key navigatorKey;
const CustomNavigator({Key? key, required this.page, required this.navigatorKey}) : super(key: key);
@override
_CustomNavigatorState createState() => _CustomNavigatorState();
}
class _CustomNavigatorState extends State<CustomNavigator> with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return Navigator(
key: widget.navigatorKey,
onGenerateRoute: (_) => MaterialPageRoute(builder: (context) => widget.page),
);
}
}