Navigator와 라우트로 화면 전환
Navigator와 라우트로 화면 전환
요약: 화면 전환은 함수 호출이 아니라 내비게이터 스택(또는 URL)의 상태다.
1. 왜 / 언제
버튼을 누르면 상세 화면이 열린다. 뒤로 가면 목록이 그대로 있다. 이 동작의 실체는 함수 호출이 아니다. 화면 스택에 페이지를 올리고 내리는 일이다.
작은 앱은 Navigator.push로 충분하다. 웹 URL, 딥링크, 로그인 게이트, 탭마다 독립 스택이 필요하면 선언형 라우터를 쓴다. 실무에서는 go_router가 기본 후보다.
화면이 늘기 전에 경로를 한곳으로 모은다. 위젯 여기저기에 push가 흩어지면 뒤로 가기·인증 분기·웹 주소를 맞출 수 없다.
2. 핵심
Navigator는 위젯 트리 안의 스택이다. push는 위에 페이지를 올린다. pop은 맨 위를 제거한다. 아래 화면의 State는 보통 살아 있다. 그래서 목록 스크롤이 상세에서 돌아와도 유지된다.
선언형 라우팅은 경로 문자열이 곧 상태다. /posts/42이면 그 화면이 맞다. 브라우저 주소, 알림 딥링크, 위젯에서 연 화면이 같은 규칙을 탄다.
인자는 세 갈래다.
| 방식 | 용도 | 주의 |
|---|---|---|
path (:id) |
화면을 가리키는 id | 웹·딥링크에 강하다 |
| query | 필터·페이지 번호 | 문자열이다 |
extra |
객체 통째 전달 | 웹 새로고침에 약하다 |
모달(다이얼로그·바텀시트)은 라우트와 역할을 섞지 않는다. 짧은 확인은 다이얼로그다. 주소가 있어야 하는 화면은 라우트다.
탭 앱은 탭마다 스택이 따로 필요할 수 있다. go_router의 StatefulShellRoute가 그 패턴이다. 한 전역 스택에 탭 화면을 모두 push하면 탭 전환이 뒤로 가기가 된다.
BuildContext는 트리의 자리다. 위젯이 떨어진 뒤(unmounted) push하면 실패한다. 비동기 작업 다음에는 context.mounted를 본다.
3. 예제
명령형 push
class ListPage extends StatelessWidget {
const ListPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('글 목록')),
body: ListTile(
title: const Text('첫 글'),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const DetailPage(id: '1'),
),
);
},
),
);
}
}
class DetailPage extends StatelessWidget {
const DetailPage({super.key, required this.id});
final String id;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('글 $id')),
body: Center(
child: TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('닫기'),
),
),
);
}
}
pop에 값을 넘기면 이전화면이 결과를 받는다. 선택 화면·필터 시트에 쓴다.
final String? result = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => const PickPage(),
),
);
go_router
dependencies:
go_router: ^14.0.0
final GoRouter router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (BuildContext context, GoRouterState state) {
return const HomePage();
},
),
GoRoute(
path: '/posts/:id',
builder: (BuildContext context, GoRouterState state) {
final String id = state.pathParameters['id']!;
return PostPage(id: id);
},
),
],
);
class App extends StatelessWidget {
const App({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: router,
);
}
}
이동:
context.go('/posts/42');
context.push('/posts/42');
context.pop();
go는 스택을 그 경로에 맞게 맞춘다. push는 위에 한 장을 더 올린다. 목록→상세는 보통 push다. 로그인 후 홈으로 되돌릴 때는 go가 맞다.
인증 게이트는 리다이렉트로 한곳에 둔다. 각 화면에서 토큰을 검사하지 않는다. auth는 세션을 들고 있는 객체다.
final GoRouter router = GoRouter(
refreshListenable: auth,
redirect: (BuildContext context, GoRouterState state) {
final bool signedIn = auth.isSignedIn;
final bool loggingIn = state.matchedLocation == '/login';
if (!signedIn && !loggingIn) {
return '/login';
}
if (signedIn && loggingIn) {
return '/';
}
return null;
},
routes: [
GoRoute(path: '/login', builder: (_, __) => const LoginPage()),
GoRoute(path: '/', builder: (_, __) => const HomePage()),
],
);
4. 흔한 실수
| 실수 | 결과 | 대안 |
|---|---|---|
비동기 후 바로 push |
unmounted context | if (!context.mounted) return; |
큰 객체를 extra만으로 전달 |
웹 새로고침 시 유실 | id는 path, 객체는 화면에서 로드 |
| 다이얼로그를 라우트처럼 쌓기 | 뒤로 가기 스택이 꼬인다 | 모달과 페이지를 분리 |
| 화면마다 경로 문자열 하드코딩 | 오타·리팩터 비용 | 경로 상수를 한 파일에 |
탭마다 push만 사용 |
탭이 히스토리가 된다 | 중첩 내비 / StatefulShellRoute |
Future<void> openDetail(BuildContext context, String id) async {
await preload(id);
if (!context.mounted) {
return;
}
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DetailPage(id: id),
),
);
}
MaterialApp과 MaterialApp.router를 섞지 않는다. 라우터를 쓰면 home/routes가 아니라 routerConfig다.
리다이렉트의 signedIn은 예시의 세션 플래그다. 실제 앱에서는 보안 저장소나 Auth 객체를 읽는다. GoRouter 생성 시점에 context가 아직 준비되지 않을 수 있으면 refreshListenable으로 세션 변경을 다시 평가한다.
정리
화면 전환은 위젯 함수 호출이 아니다. 스택 또는 URL의 상태다. 작은 앱은 Navigator로 시작하고, 경로가 제품의 일부가 되면 라우터를 한곳으로 모은다.
연습
- 목록에서 상세로
push하고pop으로 돌아오는 두 화면을 만든다. pop으로 선택한 id를 목록에 돌려 준다.- 같은 흐름을
go_router의/posts/:id로 옮긴다. - 비동기 로드 뒤
context.mounted를 검사하고 이동한다.
댓글
0아직 댓글이 없습니다. 첫 댓글을 남겨보세요.