Flutter 튜토리얼 17편: 화면 전환과 데이터 전달
요약
핵심 요지
문서가 설명하는 범위
- Navigator와 Route 기본 개념
- 화면 전환: push, pop, pushReplacement
- 새 화면으로 데이터 전달
- 이전 화면으로 결과 반환
- 명명된 라우트 (Named Routes)
읽는 시간: 15분 | 난이도: 초급
참고 자료
- Navigate to a new screen and back - 기본 네비게이션
- Pass arguments to a named route - 데이터 전달
- Return data from a screen - 결과 반환
문제 상황
대부분의 앱은 여러 화면으로 구성됩니다. 목록 화면에서 상세 화면으로 이동하고, 편집 화면에서 결과를 반환해야 합니다.
일반적인 네비게이션 패턴
목록 화면 → 상세 화면 → 편집 화면 ↑_________↓__________↓ (뒤로가기)문제는 다음과 같습니다.
- 화면 간 전환 애니메이션을 처리해야 한다.
- 새 화면에 필요한 데이터를 전달해야 한다.
- 편집 결과를 이전 화면으로 반환해야 한다.
- 화면 스택을 적절히 관리해야 한다.
해결 방법
Flutter는 Navigator 위젯으로 화면 스택을 관리하고, Route로 각 화면을 표현합니다.
챕터 1: 기본 네비게이션
Why
NOTEFlutter에서 화면(페이지)은
Route라고 불립니다.
Android의 Activity, iOS의 ViewController와 같은 개념이지만, Flutter에서는 그냥 위젯입니다.Route = 화면 = 위젯Navigator = 화면 스택 관리자
What
NOTE
Navigator는 스택 구조로 화면을 관리합니다.
push()로 새 화면을 스택에 추가하고,pop()으로 현재 화면을 제거합니다.graph LR A[화면 A] -->|push| B[화면 B] B -->|pop| A
How
TIP기본 push/pop 사용
import 'package:flutter/material.dart';class FirstScreen extends StatelessWidget {const FirstScreen({super.key});@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: const Text('첫 번째 화면')),body: Center(child: ElevatedButton(onPressed: () {// 새 화면으로 이동Navigator.push(context,MaterialPageRoute(builder: (context) => const SecondScreen()),);},child: const Text('두 번째 화면으로'),),),);}}class SecondScreen extends StatelessWidget {const SecondScreen({super.key});@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: const Text('두 번째 화면')),body: Center(child: ElevatedButton(onPressed: () {// 이전 화면으로 돌아가기Navigator.pop(context);},child: const Text('돌아가기'),),),);}}void main() {runApp(const MaterialApp(title: 'Navigation Demo',home: FirstScreen(),));}플랫폼별 페이지 전환
Route 타입 플랫폼 전환 애니메이션 MaterialPageRouteAndroid 슬라이드 + 페이드 CupertinoPageRouteiOS 오른쪽에서 슬라이드
Watch out
WARNING
pop()을 호출할 때 스택에 화면이 하나뿐이면 앱이 종료될 수 있습니다.
Navigator.canPop(context)로 먼저 확인하세요.// 안전한 popif (Navigator.canPop(context)) {Navigator.pop(context);} else {// 스택에 화면이 하나뿐임// 앱 종료 확인 대화상자 표시 등}
결론: Navigator.push()로 화면을 열고, Navigator.pop()으로 돌아갑니다.
챕터 2: 새 화면으로 데이터 전달
Why
NOTE상세 화면을 열 때 어떤 항목의 상세 정보인지 알려줘야 합니다.
생성자를 통해 데이터를 전달하는 것이 가장 명확합니다.목록 화면 → (Todo 객체) → 상세 화면
What
NOTE새 화면의 생성자에 필요한 데이터를 파라미터로 전달합니다.
required키워드로 필수 데이터임을 명시할 수 있습니다.
How
TIP생성자로 데이터 전달 (권장)
// 데이터 모델class Todo {final String id;final String title;final String description;final bool isCompleted;const Todo({required this.id,required this.title,required this.description,this.isCompleted = false,});}// 목록 화면class TodoListScreen extends StatelessWidget {const TodoListScreen({super.key});final List<Todo> todos = const [Todo(id: '1', title: '장보기', description: '우유, 빵, 계란 구매'),Todo(id: '2', title: '운동하기', description: '30분 조깅'),Todo(id: '3', title: '공부하기', description: 'Flutter 튜토리얼 완독'),];@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: const Text('할 일 목록')),body: ListView.builder(itemCount: todos.length,itemBuilder: (context, index) {final todo = todos[index];return ListTile(title: Text(todo.title),subtitle: Text(todo.description),onTap: () {// 데이터를 생성자로 전달Navigator.push(context,MaterialPageRoute(builder: (context) => TodoDetailScreen(todo: todo),),);},);},),);}}// 상세 화면class TodoDetailScreen extends StatelessWidget {const TodoDetailScreen({super.key, required this.todo});final Todo todo;@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: Text(todo.title)),body: Padding(padding: const EdgeInsets.all(16.0),child: Column(crossAxisAlignment: CrossAxisAlignment.start,children: [Text('설명',style: Theme.of(context).textTheme.titleMedium,),const SizedBox(height: 8),Text(todo.description),const SizedBox(height: 16),Text('완료 여부: ${todo.isCompleted ? "완료" : "미완료"}'),],),),);}}RouteSettings로 데이터 전달 (대안)
// 네비게이션Navigator.push(context,MaterialPageRoute(builder: (context) => const TodoDetailScreen(),settings: RouteSettings(arguments: todo),),);// 데이터 추출 (상세 화면에서)@overrideWidget build(BuildContext context) {final todo = ModalRoute.of(context)!.settings.arguments as Todo;// ...}
Watch out
WARNING
RouteSettings.arguments를 사용할 때는 타입 캐스팅이 필요합니다.
잘못된 타입이 전달되면 런타임 에러가 발생합니다.// ❌ 위험: 타입이 다르면 런타임 에러final todo = ModalRoute.of(context)!.settings.arguments as Todo;// ✅ 안전: 타입 검사 후 사용final args = ModalRoute.of(context)!.settings.arguments;if (args is Todo) {// 안전하게 사용} else {// 에러 처리}생성자 방식이 컴파일 타임에 타입 안전성을 보장하므로 권장됩니다.
결론: 생성자 파라미터로 데이터를 전달하면 타입 안전하고 명확합니다.
챕터 3: 이전 화면으로 결과 반환
Why
NOTE선택 화면에서 사용자가 선택한 항목을 이전 화면으로 전달해야 합니다.
편집 화면에서 저장 결과를 반환하는 것도 같은 패턴입니다.선택 화면 → (선택 결과) → 이전 화면
What
NOTE
Navigator.pop(context, result)로 결과와 함께 화면을 닫습니다.
Navigator.push()는Future를 반환하므로await로 결과를 받을 수 있습니다.
How
TIP결과 반환 패턴
// 선택 화면class ColorPickerScreen extends StatelessWidget {const ColorPickerScreen({super.key});@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: const Text('색상 선택')),body: ListView(children: [ListTile(leading: const CircleAvatar(backgroundColor: Colors.red),title: const Text('빨강'),onTap: () => Navigator.pop(context, Colors.red),),ListTile(leading: const CircleAvatar(backgroundColor: Colors.green),title: const Text('초록'),onTap: () => Navigator.pop(context, Colors.green),),ListTile(leading: const CircleAvatar(backgroundColor: Colors.blue),title: const Text('파랑'),onTap: () => Navigator.pop(context, Colors.blue),),],),);}}// 메인 화면class MainScreen extends StatefulWidget {const MainScreen({super.key});@overrideState<MainScreen> createState() => _MainScreenState();}class _MainScreenState extends State<MainScreen> {Color _selectedColor = Colors.grey;Future<void> _selectColor() async {// await로 결과 대기final Color? result = await Navigator.push<Color>(context,MaterialPageRoute(builder: (context) => const ColorPickerScreen()),);// 위젯이 아직 마운트되어 있는지 확인if (!mounted) return;// 결과가 있으면 상태 업데이트if (result != null) {setState(() {_selectedColor = result;});ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('선택된 색상: $result')),);}}@overrideWidget build(BuildContext context) {return Scaffold(appBar: AppBar(title: const Text('색상 선택 데모')),body: Center(child: Column(mainAxisAlignment: MainAxisAlignment.center,children: [Container(width: 100,height: 100,color: _selectedColor,),const SizedBox(height: 24),ElevatedButton(onPressed: _selectColor,child: const Text('색상 선택하기'),),],),),);}}타입 지정
// MaterialPageRoute에 반환 타입 지정final Color? result = await Navigator.push<Color>(context,MaterialPageRoute<Color>(builder: (context) => const ColorPickerScreen(),),);
Watch out
WARNING
await후에는 반드시mounted를 확인해야 합니다.
화면이 닫힌 동안 원래 화면도 dispose 되었을 수 있습니다.Future<void> _navigateAndGetResult() async {final result = await Navigator.push(...);// ❌ 위험: 위젯이 이미 dispose 되었을 수 있음setState(() { ... });// ✅ 안전: mounted 확인 후 사용if (!mounted) return;setState(() { ... });}
결론: Navigator.pop(context, result)로 결과를 반환하고, await로 받아서 mounted 확인 후 처리합니다.
챕터 4: 다양한 네비게이션 메서드
Why
NOTE단순히 화면을 열고 닫는 것 외에 다양한 네비게이션 패턴이 필요합니다.
로그인 후 홈 화면으로 이동하면서 로그인 화면을 제거하는 것이 예입니다.로그인 → 홈 (로그인 화면 제거)설정 → 로그아웃 → 로그인 (모든 화면 제거)
What
NOTE
Navigator는 다양한 화면 전환 메서드를 제공합니다.
스택을 조작하여 원하는 네비게이션 패턴을 구현할 수 있습니다.
How
TIP주요 Navigator 메서드
메서드 설명 push()새 화면을 스택에 추가 pop()현재 화면 제거 pushReplacement()현재 화면을 새 화면으로 교체 pushAndRemoveUntil()새 화면 추가 후 조건까지 제거 popUntil()조건까지 화면 제거 pushReplacement 예제
// 로그인 성공 후 홈 화면으로 이동 (로그인 화면 제거)void _onLoginSuccess() {Navigator.pushReplacement(context,MaterialPageRoute(builder: (context) => const HomeScreen()),);// 이제 뒤로가기를 눌러도 로그인 화면으로 돌아가지 않음}pushAndRemoveUntil 예제
// 로그아웃: 모든 화면 제거 후 로그인 화면으로void _logout() {Navigator.pushAndRemoveUntil(context,MaterialPageRoute(builder: (context) => const LoginScreen()),(route) => false, // 모든 화면 제거);}// 홈 화면까지만 남기고 새 화면 추가void _goToSettings() {Navigator.pushAndRemoveUntil(context,MaterialPageRoute(builder: (context) => const SettingsScreen()),(route) => route.isFirst, // 첫 번째 화면(홈)만 남김);}popUntil 예제
// 홈 화면까지 모든 화면 닫기void _goBackToHome() {Navigator.popUntil(context, (route) => route.isFirst);}// 특정 라우트 이름까지 닫기void _goBackToSettings() {Navigator.popUntil(context, ModalRoute.withName('/settings'));}maybePop 예제
// 뒤로 갈 수 있으면 뒤로, 없으면 무시void _onBackPressed() {Navigator.maybePop(context);}
Watch out
WARNING
pushAndRemoveUntil의 조건 함수가 항상false를 반환하면 루트 화면까지 모두 제거됩니다.
의도치 않게 앱이 빈 상태가 될 수 있습니다.// 주의: 모든 화면이 제거됨Navigator.pushAndRemoveUntil(context,MaterialPageRoute(...),(route) => false, // 조건이 절대 true가 되지 않음);최소한 하나의 화면은 남기거나, 새 화면이 반드시 추가되도록 해야 합니다.
결론: 상황에 맞는 Navigator 메서드를 선택하여 스택을 적절히 관리합니다.
챕터 5: 명명된 라우트 (Named Routes)
Why
NOTE화면이 많아지면 라우트를 중앙에서 관리하는 것이 편리합니다.
문자열 이름으로 화면을 참조하면 코드가 간결해집니다.Navigator.pushNamed(context, '/settings');// vsNavigator.push(context, MaterialPageRoute(...));
What
NOTE
MaterialApp의routes맵에 라우트 이름과 빌더를 정의합니다.
Navigator.pushNamed()로 이름으로 화면을 열 수 있습니다.참고: 명명된 라우트는 더 이상 권장되지 않습니다. 복잡한 앱에서는
go_router같은 패키지를 사용하세요.
How
TIPNamed Routes 정의
void main() {runApp(MaterialApp(title: 'Named Routes Demo',initialRoute: '/',routes: {'/': (context) => const HomeScreen(),'/settings': (context) => const SettingsScreen(),'/profile': (context) => const ProfileScreen(),},));}Named Routes 사용
// 화면 이동ElevatedButton(onPressed: () {Navigator.pushNamed(context, '/settings');},child: const Text('설정'),)// 데이터와 함께 이동Navigator.pushNamed(context,'/profile',arguments: {'userId': '12345'},);// 데이터 추출@overrideWidget build(BuildContext context) {final args = ModalRoute.of(context)!.settings.arguments as Map;final userId = args['userId'];// ...}onGenerateRoute로 동적 라우트
MaterialApp(onGenerateRoute: (settings) {// /user/123 같은 동적 경로 처리if (settings.name!.startsWith('/user/')) {final userId = settings.name!.split('/').last;return MaterialPageRoute(builder: (context) => UserScreen(userId: userId),);}// 알 수 없는 라우트return MaterialPageRoute(builder: (context) => const NotFoundScreen(),);},)
Watch out
WARNING명명된 라우트는 타입 안전성이 떨어지고 데이터 전달이 불편합니다.
Flutter 팀은 복잡한 앱에서go_router사용을 권장합니다.// Named Routes의 한계Navigator.pushNamed(context,'/user', // 오타가 있어도 컴파일 에러 없음arguments: userObj, // 타입 검사 없음);// go_router 권장GoRouter(routes: [GoRoute(path: '/user/:id',builder: (context, state) => UserScreen(userId: state.pathParameters['id']!, // 타입 안전),),],)
결론: 간단한 앱에서는 Named Routes가 편리하지만, 복잡한 앱에서는 go_router를 권장합니다.
챕터 6: 네비게이션 실전 패턴
Why
NOTE실제 앱에서는 여러 네비게이션 패턴을 조합해서 사용합니다.
로그인 흐름, 온보딩, 모달 화면 등 다양한 시나리오를 처리해야 합니다.
What
NOTE일반적인 네비게이션 패턴들:
- 인증 흐름: 로그인 → 홈 (스택 교체)
- 온보딩: 단계별 → 홈 (완료 시 스택 제거)
- 모달 대화상자: 결과 반환
- 중첩 네비게이션: 탭별 독립 스택
How
TIP인증 흐름 패턴
class AuthWrapper extends StatelessWidget {const AuthWrapper({super.key});@overrideWidget build(BuildContext context) {// 인증 상태에 따라 다른 화면 표시return StreamBuilder<User?>(stream: AuthService.instance.authStateChanges,builder: (context, snapshot) {if (snapshot.connectionState == ConnectionState.waiting) {return const SplashScreen();}if (snapshot.hasData) {return const HomeScreen();}return const LoginScreen();},);}}// 로그인 성공 후void _onLoginSuccess() {// 스택 전체 교체 (뒤로가기 시 로그인 화면으로 가지 않도록)Navigator.pushAndRemoveUntil(context,MaterialPageRoute(builder: (context) => const HomeScreen()),(route) => false,);}모달 선택 패턴
Future<String?> showCountryPicker(BuildContext context) async {return await showModalBottomSheet<String>(context: context,builder: (context) => ListView(children: [ListTile(title: const Text('대한민국'),onTap: () => Navigator.pop(context, 'KR'),),ListTile(title: const Text('미국'),onTap: () => Navigator.pop(context, 'US'),),ListTile(title: const Text('일본'),onTap: () => Navigator.pop(context, 'JP'),),],),);}// 사용final country = await showCountryPicker(context);if (country != null) {print('선택된 국가: $country');}WillPopScope로 뒤로가기 제어
WillPopScope(onWillPop: () async {// 저장되지 않은 변경사항이 있으면 확인if (_hasUnsavedChanges) {final shouldLeave = await showDialog<bool>(context: context,builder: (context) => AlertDialog(title: const Text('변경사항 저장'),content: const Text('저장하지 않은 변경사항이 있습니다. 나가시겠습니까?'),actions: [TextButton(onPressed: () => Navigator.pop(context, false),child: const Text('취소'),),TextButton(onPressed: () => Navigator.pop(context, true),child: const Text('나가기'),),],),);return shouldLeave ?? false;}return true;},child: Scaffold(...),)
Watch out
WARNING
WillPopScope는 Flutter 3.12부터 deprecated 되었습니다.
대신PopScope를 사용하세요.// Flutter 3.12 이상PopScope(canPop: !_hasUnsavedChanges,onPopInvoked: (didPop) {if (!didPop) {// 뒤로가기가 막힌 경우 확인 대화상자 표시_showUnsavedChangesDialog();}},child: Scaffold(...),)
결론: 앱의 요구사항에 맞는 네비게이션 패턴을 선택하고, 일관성 있게 적용합니다.
한계
기본 Navigator 시스템에는 몇 가지 한계가 있습니다.
- 딥 링킹: URL 기반 네비게이션이 복잡합니다.
- 상태 복원: 앱 재시작 시 네비게이션 상태 복원이 어렵습니다.
- 타입 안전성: 명명된 라우트는 타입 검사가 불가능합니다.
- 중첩 네비게이션: 탭별 독립 스택 관리가 복잡합니다.
복잡한 네비게이션이 필요하면 go_router, auto_route 같은 패키지 사용을 고려하세요.
Footnotes
-
Route(라우트): Flutter에서 화면을 의미하는 추상화 클래스다. MaterialPageRoute가 가장 흔히 사용된다. ↩
공유
이 글이 도움이 되었다면 다른 사람과 공유해주세요!