Flutter 튜토리얼 17편: 화면 전환과 데이터 전달

요약#

핵심 요지#

  • 문제 정의: 앱에서 여러 화면 간 이동과 데이터 교환이 필요하다.
  • 핵심 주장: Flutter의 Navigator1Route2로 화면 전환을 관리한다.
  • 주요 근거: push()로 새 화면을 열고, pop()으로 이전 화면으로 돌아가며 데이터를 전달한다.
  • 실무 기준: 생성자 인자로 데이터를 전달하는 것이 가장 명확하고 타입 안전하다.
  • 한계: 복잡한 네비게이션은 go_router 같은 패키지 사용을 권장한다.

문서가 설명하는 범위#

  • Navigator와 Route 기본 개념
  • 화면 전환: push, pop, pushReplacement
  • 새 화면으로 데이터 전달
  • 이전 화면으로 결과 반환
  • 명명된 라우트 (Named Routes)

읽는 시간: 15분 | 난이도: 초급


참고 자료#


문제 상황#

대부분의 앱은 여러 화면으로 구성됩니다. 목록 화면에서 상세 화면으로 이동하고, 편집 화면에서 결과를 반환해야 합니다.

일반적인 네비게이션 패턴#

목록 화면 → 상세 화면 → 편집 화면
↑_________↓__________↓ (뒤로가기)

문제는 다음과 같습니다.

  • 화면 간 전환 애니메이션을 처리해야 한다.
  • 새 화면에 필요한 데이터를 전달해야 한다.
  • 편집 결과를 이전 화면으로 반환해야 한다.
  • 화면 스택을 적절히 관리해야 한다.

해결 방법#

Flutter는 Navigator 위젯으로 화면 스택을 관리하고, Route로 각 화면을 표현합니다.

챕터 1: 기본 네비게이션#

Why#

NOTE

Flutter에서 화면(페이지)은 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});
@override
Widget 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});
@override
Widget 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)로 먼저 확인하세요.

// 안전한 pop
if (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 튜토리얼 완독'),
];
@override
Widget 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;
@override
Widget 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),
),
);
// 데이터 추출 (상세 화면에서)
@override
Widget 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});
@override
Widget 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});
@override
State<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')),
);
}
}
@override
Widget 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');
// vs
Navigator.push(context, MaterialPageRoute(...));

What#

NOTE

MaterialApproutes 맵에 라우트 이름과 빌더를 정의합니다.
Navigator.pushNamed()로 이름으로 화면을 열 수 있습니다.

참고: 명명된 라우트는 더 이상 권장되지 않습니다. 복잡한 앱에서는 go_router 같은 패키지를 사용하세요.

How#

TIP

Named 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'},
);
// 데이터 추출
@override
Widget 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});
@override
Widget 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#

  1. Navigator(네비게이터): 화면 스택을 관리하는 위젯이다. push, pop 등의 메서드로 화면 전환을 제어한다.

  2. Route(라우트): Flutter에서 화면을 의미하는 추상화 클래스다. MaterialPageRoute가 가장 흔히 사용된다.

공유

이 글이 도움이 되었다면 다른 사람과 공유해주세요!

Flutter 튜토리얼 17편: 화면 전환과 데이터 전달
https://moodturnpost.net/posts/flutter/flutter-navigation-data/
작성자
Moodturn
게시일
2026-01-08
Moodturn

목차