티스토리 뷰
Flutter는 단순한 네비게이션뿐만 아니라 중첩 내비게이션을 통해 앱 내에서 복잡한 흐름을 효율적으로 처리할 수 있습니다. 중첩 내비게이션은 다수의 경로(Route)를 관리할 때 매우 유용한 기능으로, 특정 위젯이나 화면 내부에서 별도의 네비게이션 흐름을 처리할 수 있게 해줍니다. 이번 블로그에서는 Flutter에서 중첩 네비게이션을 구현하는 방법과 이를 통해 앱의 구조를 효율적으로 관리하는 방법을 다룹니다.
중첩 내비게이션이 필요한 이유
대부분의 앱은 여러 화면을 포함하며, 화면 간 전환을 관리하기 위해 네비게이터를 사용합니다. 그러나 모든 경로를 최상위 네비게이터에 관리하게 되면 복잡성이 증가하고 유지 관리가 어려워질 수 있습니다. 이러한 경우, 중첩 네비게이션을 도입하면 각 위젯이 자신만의 네비게이터를 가지며 독립적으로 동작하게 할 수 있습니다.
예를 들어, 사물인터넷(IoT) 장치 설정 과정을 관리하는 앱을 생각해 봅시다. 설정 과정은 ‘장치 찾기’, ‘장치 선택’, ‘연결’, 그리고 ‘설정 완료’로 구성될 수 있습니다. 이런 여러 페이지를 최상위 네비게이터에서 모두 관리하는 대신, SetupFlow라는 별도의 네비게이터에서만 설정 흐름을 관리하는 것이 더 적절할 수 있습니다.
주요 경로 정의
중첩 네비게이션을 구현하기 위해서는 먼저 각 경로를 정의해야 합니다. 최상위 네비게이터는 홈 화면과 설정 화면 같은 경로만 처리하고, 설정 과정에 대한 경로는 별도로 처리합니다.
const routeHome = '/';
const routeSettings = '/settings';
const routePrefixDeviceSetup = '/setup/';
const routeDeviceSetupStart = '/setup/$routeDeviceSetupStartPage';
const routeDeviceSetupStartPage = 'find_devices';
const routeDeviceSetupSelectDevicePage = 'select_device';
const routeDeviceSetupConnectingPage = 'connecting';
const routeDeviceSetupFinishedPage = 'finished';
이 경로를 통해 설정 흐름을 명확하게 구분하고, 최상위 네비게이터는 각 페이지를 직접 다루는 대신 설정 경로를 통해 전체 흐름을 처리하게 됩니다.
onGenerateRoute를 사용한 동적 경로 처리
최상위 네비게이터에서 모든 경로를 미리 정의하지 않고, 동적으로 경로를 처리하기 위해 onGenerateRoute를 사용합니다. 이는 설정 흐름이 시작되었는지 여부를 경로 이름으로 판단하고, 적절한 위젯을 반환하도록 만듭니다.
onGenerateRoute: (settings) {
final Widget page;
if (settings.name == routeHome) {
page = const HomeScreen();
} else if (settings.name == routeSettings) {
page = const SettingsScreen();
} else if (settings.name!.startsWith(routePrefixDeviceSetup)) {
final subRoute = settings.name!.substring(routePrefixDeviceSetup.length);
page = SetupFlow(
setupPageRoute: subRoute,
);
} else {
throw Exception('Unknown route: ${settings.name}');
}
return MaterialPageRoute<dynamic>(
builder: (context) {
return page;
},
settings: settings,
);
},
SetupFlow 위젯과 네비게이터
SetupFlow 위젯은 중첩 네비게이터를 사용하여 설정 과정 내에서 각 페이지로 이동을 관리합니다. 여기서는 네비게이터 키를 사용하여 각 페이지로 전환하며, 설정 흐름에 따라 네비게이터가 적절한 페이지를 표시합니다.
final _navigatorKey = GlobalKey<NavigatorState>();
void _onDiscoveryComplete() {
_navigatorKey.currentState!.pushNamed(routeDeviceSetupSelectDevicePage);
}
void _onDeviceSelected(String deviceId) {
_navigatorKey.currentState!.pushNamed(routeDeviceSetupConnectingPage);
}
void _onConnectionEstablished() {
_navigatorKey.currentState!.pushNamed(routeDeviceSetupFinishedPage);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Device Setup'),
),
body: Navigator(
key: _navigatorKey,
initialRoute: widget.setupPageRoute,
onGenerateRoute: _onGenerateRoute,
),
);
}
Route<Widget> _onGenerateRoute(RouteSettings settings) {
final page = switch (settings.name) {
routeDeviceSetupStartPage => WaitingPage(
message: 'Searching for nearby devices...',
onWaitComplete: _onDiscoveryComplete,
),
routeDeviceSetupSelectDevicePage => SelectDevicePage(
onDeviceSelected: _onDeviceSelected,
),
routeDeviceSetupConnectingPage => WaitingPage(
message: 'Connecting...',
onWaitComplete: _onConnectionEstablished,
),
routeDeviceSetupFinishedPage => FinishedPage(
onFinishPressed: _exitSetup,
),
_ => throw StateError('Unexpected route name: ${settings.name}!'),
};
return MaterialPageRoute(
builder: (context) {
return page;
},
settings: settings,
);
}
사용자 인터페이스(UI)와 앱바(AppBar) 설정
설정 흐름 내에서는 일관된 UI 경험을 제공하기 위해 AppBar를 모든 페이지에 적용합니다. 사용자가 뒤로 가기를 눌렀을 때, 설정 흐름을 종료할지 묻는 대화 상자를 표시해 설정 과정이 중단되지 않도록 유도합니다.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: _onExitPressed,
icon: const Icon(Icons.chevron_left),
),
title: const Text('Bulb Setup'),
),
body: Navigator(
key: _navigatorKey,
onGenerateRoute: _onGenerateRoute,
),
);
}
Future<void> _onExitPressed() async {
final isConfirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Exit Setup?'),
content: const Text('Your progress will be lost.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Leave'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Stay'),
),
],
);
},
) ?? false;
if (isConfirmed) {
Navigator.of(context).pop();
}
}
결론: Flutter 중첩 네비게이션의 장점
Flutter에서 중첩 네비게이션을 사용하면 앱 내 특정 위젯이나 흐름에 대해 독립적인 네비게이션을 구현할 수 있어, 복잡한 네비게이션 구조를 더 깔끔하고 관리하기 쉽게 만들 수 있습니다. 특히 설정 과정이나 단계별 흐름을 처리할 때 이러한 패턴은 매우 유용하며, 코드의 유지 관리와 확장성에도 긍정적인 영향을 줍니다.
import 'package:flutter/material.dart';
const routeHome = '/';
const routeSettings = '/settings';
const routePrefixDeviceSetup = '/setup/';
const routeDeviceSetupStart = '/setup/$routeDeviceSetupStartPage';
const routeDeviceSetupStartPage = 'find_devices';
const routeDeviceSetupSelectDevicePage = 'select_device';
const routeDeviceSetupConnectingPage = 'connecting';
const routeDeviceSetupFinishedPage = 'finished';
void main() {
runApp(
MaterialApp(
theme: ThemeData(
brightness: Brightness.dark,
appBarTheme: const AppBarTheme(
backgroundColor: Colors.blue,
),
floatingActionButtonTheme: const FloatingActionButtonThemeData(
backgroundColor: Colors.blue,
),
),
onGenerateRoute: (settings) {
final Widget page;
if (settings.name == routeHome) {
page = const HomeScreen();
} else if (settings.name == routeSettings) {
page = const SettingsScreen();
} else if (settings.name!.startsWith(routePrefixDeviceSetup)) {
final subRoute =
settings.name!.substring(routePrefixDeviceSetup.length);
page = SetupFlow(
setupPageRoute: subRoute,
);
} else {
throw Exception('Unknown route: ${settings.name}');
}
return MaterialPageRoute<dynamic>(
builder: (context) {
return page;
},
settings: settings,
);
},
debugShowCheckedModeBanner: false,
),
);
}
@immutable
class SetupFlow extends StatefulWidget {
static SetupFlowState of(BuildContext context) {
return context.findAncestorStateOfType<SetupFlowState>()!;
}
const SetupFlow({
super.key,
required this.setupPageRoute,
});
final String setupPageRoute;
@override
SetupFlowState createState() => SetupFlowState();
}
class SetupFlowState extends State<SetupFlow> {
final _navigatorKey = GlobalKey<NavigatorState>();
@override
void initState() {
super.initState();
}
void _onDiscoveryComplete() {
_navigatorKey.currentState!.pushNamed(routeDeviceSetupSelectDevicePage);
}
void _onDeviceSelected(String deviceId) {
_navigatorKey.currentState!.pushNamed(routeDeviceSetupConnectingPage);
}
void _onConnectionEstablished() {
_navigatorKey.currentState!.pushNamed(routeDeviceSetupFinishedPage);
}
Future<void> _onExitPressed() async {
final isConfirmed = await _isExitDesired();
if (isConfirmed && mounted) {
_exitSetup();
}
}
Future<bool> _isExitDesired() async {
return await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Are you sure?'),
content: const Text(
'If you exit device setup, your progress will be lost.'),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(true);
},
child: const Text('Leave'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop(false);
},
child: const Text('Stay'),
),
],
);
}) ??
false;
}
void _exitSetup() {
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
if (await _isExitDesired() && context.mounted) {
_exitSetup();
}
},
child: Scaffold(
appBar: _buildFlowAppBar(),
body: Navigator(
key: _navigatorKey,
initialRoute: widget.setupPageRoute,
onGenerateRoute: _onGenerateRoute,
),
),
);
}
Route<Widget> _onGenerateRoute(RouteSettings settings) {
final page = switch (settings.name) {
routeDeviceSetupStartPage => WaitingPage(
message: 'Searching for nearby bulb...',
onWaitComplete: _onDiscoveryComplete,
),
routeDeviceSetupSelectDevicePage => SelectDevicePage(
onDeviceSelected: _onDeviceSelected,
),
routeDeviceSetupConnectingPage => WaitingPage(
message: 'Connecting...',
onWaitComplete: _onConnectionEstablished,
),
routeDeviceSetupFinishedPage => FinishedPage(
onFinishPressed: _exitSetup,
),
_ => throw StateError('Unexpected route name: ${settings.name}!')
};
return MaterialPageRoute(
builder: (context) {
return page;
},
settings: settings,
);
}
PreferredSizeWidget _buildFlowAppBar() {
return AppBar(
leading: IconButton(
onPressed: _onExitPressed,
icon: const Icon(Icons.chevron_left),
),
title: const Text('Bulb Setup'),
);
}
}
class SelectDevicePage extends StatelessWidget {
const SelectDevicePage({
super.key,
required this.onDeviceSelected,
});
final void Function(String deviceId) onDeviceSelected;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Select a nearby device:',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 54,
child: ElevatedButton(
style: ButtonStyle(
backgroundColor: WidgetStateColor.resolveWith((states) {
return const Color(0xFF222222);
}),
),
onPressed: () {
onDeviceSelected('22n483nk5834');
},
child: const Text(
'Bulb 22n483nk5834',
style: TextStyle(
fontSize: 24,
),
),
),
),
],
),
),
),
);
}
}
class WaitingPage extends StatefulWidget {
const WaitingPage({
super.key,
required this.message,
required this.onWaitComplete,
});
final String message;
final VoidCallback onWaitComplete;
@override
State<WaitingPage> createState() => _WaitingPageState();
}
class _WaitingPageState extends State<WaitingPage> {
@override
void initState() {
super.initState();
_startWaiting();
}
Future<void> _startWaiting() async {
await Future<dynamic>.delayed(const Duration(seconds: 3));
if (mounted) {
widget.onWaitComplete();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 32),
Text(widget.message),
],
),
),
),
);
}
}
class FinishedPage extends StatelessWidget {
const FinishedPage({
super.key,
required this.onFinishPressed,
});
final VoidCallback onFinishPressed;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 200,
height: 200,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFF222222),
),
child: const Center(
child: Icon(
Icons.lightbulb,
size: 140,
color: Colors.white,
),
),
),
const SizedBox(height: 32),
const Text(
'Bulb added!',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32),
ElevatedButton(
style: ButtonStyle(
padding: WidgetStateProperty.resolveWith((states) {
return const EdgeInsets.symmetric(
horizontal: 24, vertical: 12);
}),
backgroundColor: WidgetStateColor.resolveWith((states) {
return const Color(0xFF222222);
}),
shape: WidgetStateProperty.resolveWith((states) {
return const StadiumBorder();
}),
),
onPressed: onFinishPressed,
child: const Text(
'Finish',
style: TextStyle(
fontSize: 24,
),
),
),
],
),
),
),
),
);
}
}
@immutable
class HomeScreen extends StatelessWidget {
const HomeScreen({
super.key,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: _buildAppBar(context),
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 200,
height: 200,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFF222222),
),
child: Center(
child: Icon(
Icons.lightbulb,
size: 140,
color: Theme.of(context).scaffoldBackgroundColor,
),
),
),
const SizedBox(height: 32),
const Text(
'Add your first bulb',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.of(context).pushNamed(routeDeviceSetupStart);
},
child: const Icon(Icons.add),
),
);
}
PreferredSizeWidget _buildAppBar(BuildContext context) {
return AppBar(
title: const Text('Welcome'),
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
Navigator.pushNamed(context, routeSettings);
},
),
],
);
}
}
class SettingsScreen extends StatelessWidget {
const SettingsScreen({
super.key,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: _buildAppBar(),
body: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: List.generate(8, (index) {
return Container(
width: double.infinity,
height: 54,
margin: const EdgeInsets.only(left: 16, right: 16, top: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: const Color(0xFF222222),
),
);
}),
),
),
);
}
PreferredSizeWidget _buildAppBar() {
return AppBar(
title: const Text('Settings'),
);
}
}
'Flutter Cookbook' 카테고리의 다른 글
Flutter로 패럴랙스 스크롤 효과 구현하기: 단계별 가이드 (1) | 2024.10.21 |
---|---|
Flutter로 사진 필터 캐러셀 구현하기: 단계별 가이드 (1) | 2024.10.20 |
Flutter에서 다운로드 버튼 구현하기: 단계별 가이드 (1) | 2024.10.18 |
탭(Tab) 만들기: 쉽고 효율적인 사용자 인터페이스 구현 (0) | 2024.10.17 |
테마 사용 가이드: 앱의 색상과 글꼴 스타일 공유하기 (1) | 2024.10.16 |