[Flutter] Navigator, 그게 뭔데?

Cody Yun
Flutter Seoul
Published in
28 min readJun 3, 2024

플러터에서 화면 이동을 구현할 때 Navigator 클래스를 사용합니다. 자주 보이는 플러터의 화면 이동 예제들은 Navigator.of(BuildContext)를 통해 화면 이동을 처리합니다. 네비게이션 동작을 확인할 수 있는 간단한 프로젝트를 구현해 봅시다.

간단한 네비게이션

네비게이션 동작을 확인할 수 있는 간단한 프로젝트를 구현해 봅시다. StatelessWidget을 확장한 FirstPage와 SecondPage를 구현합니다. 두 위젯의 중앙에 ElevatedButton을 배치하고, 버튼이 터치됐을 때 FirstPage에서는 Navigator.of(context)로 가져온 Navigator 객체로 pushNamed 메소드를 호출하며 ‘second’를 전달하고, SecondPage에서는 pop 메소드를 호출합니다.

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

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('First Page'),
),
body: Center(
child: ElevatedButton(
onPressed: () => Navigator.of(context).pushNamed('second'),
child: const Text('Go to Second Page'),
),
),
);
}
}

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

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Second Page'),
),
body: Center(
child: ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('back to First Page'),
),
),
);
}
}

main.dart에서는 MaterialApp을 생성할 때 home에 FirstPage 객체의 인스턴스를 전달하고, routes에는 이름 기반의 라우팅을 하기 위해 ‘second’를 key로하고 SecondPage를 생성하는 함수를 지정합니다.

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

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

@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
appBarTheme: const AppBarTheme(
backgroundColor: Colors.indigoAccent,
foregroundColor: Colors.white,
),
),
home: const FirstPage(),
routes: {
'second': (context) => const SecondPage(),
},
);
}
}

실행을 하면 First Page가 표시되고, First Page의 ‘Go to Second Page’ 클릭 시 Navigator.of(context).pushNamed(‘second’)에 의해 Second Page로 이동합니다. Second Page에서는 ‘Back to First Page’ 버튼이나 AppBar의 뒤로가기 버튼을 클릭 했을 때 First Page로 이동합니다.

Navigator.of(context).pushNamed(‘second’)를 Navigator.pushNamed(context, ‘second’)로 변경하고, Navigator.of(context).pop()은 Navigator.pop(context)로 변경한 뒤 동작을 확인해 봅시다.

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

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('First Page'),
),
body: Center(
child: ElevatedButton(
//onPressed: () => Navigator.of(context).pushNamed('second'),
onPressed: () => Navigator.pushNamed(context, 'second'),
child: const Text('Go to Second Page'),
),
),
);
}
}

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

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Second Page'),
),
body: Center(
child: ElevatedButton(
//onPressed: () => Navigator.of(context).pop(),
onPressed: () => Navigator.pop(context),
child: const Text('back to First Page'),
),
),
);
}
}

Navigator.of를 통해 가져온 Navigator 객체의 인스턴스로 pushNamed와 pop을 호출하던 코드를 정적 메소드인 pushNamed와 pop으로 변경해도 동일하게 동작합니다. 플러터 공식 도큐먼트 중 하나인 Navigation & routing 문서에서는 Navigator.of(BuildContext)를 거의 사용하지 않고 Navigator.push(BuildContext)나 Navigator.pop(BuildContext)를 사용하고 있습니다. 둘은 어떤 차이가 있을까요? 또 플러터는 내부적으로 네비게이션 처리를 어떻게 하고 있을까요? 플러터 내부 구현을 살펴보며 플러터를 보다 깊게 알아 봅시다.

Navigator.of

of는 호출 방식에서도 알 수 있듯이 정적 메소드입니다.

of 메소드는 StatefulWidget을 확장한 Navigator 클래스의 정적 메소드입니다. Navigator 클래스의 정적 메소드이긴 하나 Navigator의 정적 필드나 메소드에 접근하지 않는 헬퍼 메소드입니다. of 메소드로 전달된 BuildContext로 rootNavigator가 true이면 context의 findRootAncestorStateOfType을 호출해 최상단의 NavigatorState를 반환하고, rootNavigator가 false이면 context의 findAncestorStateOfType을 호출해 위젯 트리를 거슬러 올라가며 NavigatorState를 찾아 반환합니다. 이러한 과정에도 NavigatorState를 찾지 못한다면 FlutterError 예외를 던지는 간단한 로직입니다.

class Navigator extends StatefulWidget {
/// 중략
static NavigatorState of(
BuildContext context, {
bool rootNavigator = false,
}) {
// Handles the case where the input context is a navigator element.
NavigatorState? navigator;
if (context is StatefulElement && context.state is NavigatorState) {
navigator = context.state as NavigatorState;
}
if (rootNavigator) {
navigator = context.findRootAncestorStateOfType<NavigatorState>() ?? navigator;
} else {
navigator = navigator ?? context.findAncestorStateOfType<NavigatorState>();
}

assert(() {
if (navigator == null) {
throw FlutterError(
'Navigator operation requested with a context that does not include a Navigator.\n'
'The context used to push or pop routes from the Navigator must be that of a '
'widget that is a descendant of a Navigator widget.',
);
}
return true;
}());
return navigator!;
}
}

Navigator의 또 다른 정적 메소드인 pushNamed와 pop은 어떻게 구현되어 있을까요? 정적 메소드인 pushNamed와 pop은 Navigator.of(context)를 통해 가져온 NavigateState의 pushNamed와 pop을 호출하고 있습니다.

class Navigator extends StatefulWidget {
@optionalTypeArgs
static Future<T?> pushNamed<T extends Object?>(
BuildContext context,
String routeName, {
Object? arguments,
}) {
return Navigator.of(context).pushNamed<T>(routeName, arguments: arguments);
}
@optionalTypeArgs
static void pop<T extends Object?>(BuildContext context, [ T? result ]) {
Navigator.of(context).pop<T>(result);
}
}

Navigator.pushNamed와 Navigator.pop은 단순히 Navigator.of의 헬퍼 메소드로 보일 수 있지만 중요한 차이점은 Navigator.of의 옵셔널 인자인 rootNavigator 입니다.

rootNavigator 사용하기

rootNavigator의 기본값은 false 입니다. 많은 경우 true를 넘길지 false를 넘길지 신경쓰지 않기 때문에 기본값인 false가 사용됩니다. Navigator.of 정적 메소드의 코드를 살펴봤던데로 rootNavigator의 값에 따라 NavigatorState를 가져올 때 findRootAncestorStateOfType를 사용할지 findAncestorStateOfType를 사용할지가 결정됩니다.

두 메소드 다 제네릭 타입으로 전달한 State를 찾아 반환하는 역할이지만 fintRootAncestorStateOfType은 최상단의 State를 찾아 반환하고, findAncestorStateOfType은 현재 위젯에서 최근거리의 State를 찾아 반환하는 차이가 있습니다.

현재 위젯에서 최근거리가 아닌 최상단의 NavigatorState가 필요한 경우는 언제 일까요? 바로 여러 개의 NavigatorState가 사용되는 케이스입니다. 이를 확인하기 위해 Tab 기반의 간단한 데모 프로젝트를 만들어 봅시다. 먼저 네비게이션에 사용할 TabPage, TabDetailPage, SettingPage를 생성합니다.

import 'package:flutter/material.dart';

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

@override
State<TabPage> createState() => _TabPageState();
}

class _TabPageState extends State<TabPage> with TickerProviderStateMixin {
late final TabController _tabController;

@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}

@override
void dispose() {
_tabController.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('TabBar Sample'),
actions: [
IconButton(
onPressed: () {
Navigator.of(context).pushNamed('setting');
},
icon: const Icon(Icons.settings),
),
],
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(icon: Icon(Icons.cloud_outlined)),
Tab(icon: Icon(Icons.beach_access_sharp)),
Tab(icon: Icon(Icons.brightness_5_sharp)),
],
),
),
body: TabBarView(
controller: _tabController,
children: const [
TabDetailPage(title: 'First Tab'),
TabDetailPage(title: 'Second Tab'),
TabDetailPage(title: 'Third Tab'),
],
),
);
}
}

class TabDetailPage extends StatelessWidget {
final String title;
const TabDetailPage({
super.key,
required this.title,
});

@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text("It's $title"),
ElevatedButton(
onPressed: () {
Navigator.of(context).pushNamed('setting');
},
child: const Text('Go to Setting Page'),
),
],
);
}
}

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

@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("Setting Page"),
ElevatedButton(
onPressed: () =>
Navigator.of(context).pop(),
child: const Text('Back'),
),
],
),
),
);
}
}

TabPage는 TabController와 TabBar, TabBarView를 이용해 3개의 TabDetailPage를 표시합니다. TabPage Scaffold의 AppBar actions에 설정 버튼을 배치하고, 터치하면 Navigator.of(context).pushNamed를 이용해 SettingPage로 이동하도록 MaterialApp에 라우팅을 구성합니다. TabDetailPage의 중앙에도 버튼을 배치하고 AppBar의 action 터치 시 동일하게 SettingPage로 이동하도록 합니다. MaterialApp에서 간단히 AppBar와 TabBar의 테마도 간단히 설정합니다.

import 'package:flutter/material.dart';
import 'package:navigation_example/setting_page.dart';
import 'package:navigation_example/tab_page.dart';

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

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

@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Navigation Example',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
appBarTheme: const AppBarTheme(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
tabBarTheme: TabBarTheme(
labelColor: Colors.white,
unselectedLabelColor: Colors.white.withOpacity(0.5),
),
useMaterial3: true,
),
home: const TabPage(),
routes: {
'setting': (context) => const SettingPage(),
},
);
}
}

실행하면 아래와 같은 결과를 확인할 수 있습니다.

AppBar action의 설정을 터치하든 TabDetailPage의 설정을 터치하든 SettingPage는 TabPage를 뒤덮게 됩니다. context가 위치한 위젯으로 부터 근거리의 NavigateState를 가져오는데, 우리가 만든 프로젝트의 NavigateState는 MaterialApp 생성 시 내부적으로 생성된걸 반환하기 때문에 SettingPage가 TabPage를 뒤덮습니다.

Navigator.of의 두 번째 인자로 rootNavigator에 true로 전달해 얻어온 NavigateState의 pushNamed와 pop을 호출하면 어떻게 동작할까요? 현재의 네비게이션 데모 프로젝트에는 NavigateState가 1개만 존재하기 때문입니다.만약 TabDetailPage 내부의 Go to Setting Page 버튼을 클릭했을 때 SettingPage가 TabPage를 덮지않고, TabDetailPage에서 네비게이션이 동작하게 하려면 어떻게 해야 할까요? 정답은 이 포스팅의 주제였던 Navigator 위젯을 사용하면 됩니다.

Navigator 위젯 사용해 Nested Navigation 구현하기

Navigator 위젯은 스택으로 하위 위젯을 관리하는 위젯입니다.

TabDetailPage의 build 메소드에서 반환하는 위젯 트리를 Navigator로 감싸줍니다. Navigator.of에서는 rootNavigator에 false로 전달합니다.

import 'package:flutter/material.dart';
import 'package:navigation_example/setting_page.dart';

class TabDetailPage extends StatelessWidget {
final String title;
const TabDetailPage({
super.key,
required this.title,
});

@override
Widget build(BuildContext context) {
return Navigator(
onGenerateInitialRoutes: (navigator, initialRoute) {
return [
MaterialPageRoute(
builder: (context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text("It's $title"),
ElevatedButton(
onPressed: () {
Navigator.of(context, rootNavigator: false)
.pushNamed('setting');
},
child: const Text('Go to Setting Page'),
),
],
);
},
),
];
},
onGenerateRoute: (settings) {
if (settings.name == 'setting') {
return MaterialPageRoute(builder: (context) => const SettingPage());
} else {
throw Exception('Unknown route: ${settings.name}');
}
},
);
}
}

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

@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("Setting Page"),
ElevatedButton(
onPressed: () =>
Navigator.of(context, rootNavigator: false).pop(),
child: const Text('Back'),
),
],
),
),
);
}
}

Navigator의 onGenerateInitialRoutes는 초기 위젯 스택으로 사용할 값을 반환합니다. 플러터는 StatelessWidget이나 StatefulWidget을 감싼 Route 객체를 이용해 네비게이션과 관련된 다양한 처리를 하는데, TabDetailPage의 build를 통해 구성하던 위젯 트리를 MaterialPageRoute의 builder로 감싸 반환하도록 변경합니다.

class TabDetailPage extends StatelessWidget {
/// 중략
@override
Widget build(BuildContext context) {
return Navigator(
onGenerateInitialRoutes: (navigator, initialRoute) {
return [
MaterialPageRoute(
builder: (context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text("It's $title"),
ElevatedButton(
onPressed: () {
Navigator.of(context, rootNavigator: false)
.pushNamed('setting');
},
child: const Text('Go to Setting Page'),
),
],
);
},
),
];
},
/// 이하 생략
);
}
}

TabDetailPage의 Navigator를 사용해 SettingPage의 라우팅 처리도 해야하기 때문에 MaterialApp의 routes와 마찬가지로 TabDetailPage Navigator의 onGenerateRoute에서 settings 인자의 name을 비교해 setting이면 SettingPage를 builder에서 반환하는 MaterialPageRoute를 리턴합니다.

class TabDetailPage extends StatelessWidget {
/// 중략
@override
Widget build(BuildContext context) {
return Navigator(
/// 중략
onGenerateRoute: (settings) {
if (settings.name == 'setting') {
return MaterialPageRoute(builder: (context) => const SettingPage());
} else {
throw Exception('Unknown route: ${settings.name}');
}
},
);
}
}

이제 실행하면 TabDetailPage의 설정 버튼은 TabDetailPage의 Navigator로 동작하고, AppBar의 설정 버튼은 MaterialApp을 통해 동작합니다.

SettingPage에서 뒤로가기 버튼은 Navigator.of의 rootNavigator에 false를 전달했기 때문에 pushNamed에 사용된 context에 따라 MaterialApp에서 pop 되거나 TabDetailPage의 Navigator에서 pop됩니다.

TabDetailPage 코드 정리하기

TabDetailPage가 네비게이션 관련 처리도 하고 있어 코드 정리가 필요합니다. NestedNavigator 위젯을 생성하고, MaterialApp의 인터페이스와 동일하게 구성합니다. onGenerateInitialRoutes에서는 homeBuilder를 통해 전달된 함수를 호출해 생성한 위젯을 MaterialPageRoute로 감싸 반환합니다. onGenerateRoute에서는 Map 타입의 routes에서 키 검사 후 WidgetBuilder를 호출해 MaterialPageRoute의 builder로 전달합니다. TabDetailPage의 build 메소드의 root를 NestedNavigator로 감싸줍니다.

import 'package:flutter/material.dart';


class NestedNavigator extends StatelessWidget {
final WidgetBuilder homeBuilder;
final Map<String, WidgetBuilder> routes;
const NestedNavigator({
super.key,
required this.homeBuilder,
required this.routes,
});

@override
Widget build(BuildContext context) {
return Navigator(
onGenerateInitialRoutes: (navigator, initialRoute) {
return [
MaterialPageRoute(builder: (context) => homeBuilder(context)),
];
},
onGenerateRoute: (settings) {
if (routes.containsKey(settings.name)) {
return MaterialPageRoute(builder: routes[settings.name]!);
} else {
throw Exception('Unknown route: ${settings.name}');
}
},
);
}
}

class TabDetailPage extends StatelessWidget {
final String title;
const TabDetailPage({
super.key,
required this.title,
});

@override
Widget build(BuildContext context) {
return NestedNavigator(
homeBuilder: (context) => Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text("It's $title"),
ElevatedButton(
onPressed: () {
Navigator.of(context, rootNavigator: false).pushNamed('setting');
},
child: const Text('Go to Setting Page'),
),
],
),
routes: {
'setting': (context) => const SettingPage(),
},
);
}
}

끝으로

Navigator의 정적 메소드 of와 pushNamed와 pop의 차이를 비교하면서 플러터 네비게이션의 전반적인 내부 동작 원리를 살펴봤습니다. go_router의 쉘라우터와 유사하게 동작하는 네비게이션을 직접 구현함으로써 go_router를 구성하는 기술과 가까워졌습니다. 다음 포스팅에서는 Navigator가 위젯을 스택으로 어떻게 관리하는지를 살펴보고, MaterialPageRoute 등의 동작 방식을 살펴보며 네비게이션 시 전환 효과를 다양하게 만들어 보도록 하겠습니다.

그럼 오늘도 Happy Codding👨‍💻

--

--

Cody Yun
Flutter Seoul

I wanna be a full stack software engineer at the side of user-facing application.