2025. 1. 10. 21:02ㆍProject Log/학부 졸업프로젝트
테스트 목적
상단 바, 하단 바, 여러 개의 화면 등으로 앱을 구성할텐데, 화면 간 이동이 원활하게 되는지 테스트하는 것은 매우 중요하다.
지난번에 Kotlin으로 앱을 개발할 때, 처음에는 분명 공식 가이드대로 Navigation route를 생성했다. 그런데 프로젝트 후반으로 가면서, 여러 소스 코드에 Navigation 관련 코드를 추가해야 하는 조금 번거로운 부분이 생겼다. 화면 스택 관리도 미흡한 부분이 있었다고 생각한다.
그래서 Navigation 관련 코드와 파일 구조를 최대한 간단하게 설계할 것이다. 나도 그렇고, 다른 팀원들도 이 큰 틀을 받았을 때 쉽게 화면과 기능을 추가할 수 있게 하고 싶다. 1️⃣ 공식 문서 코드를 먼저 테스트하고, 2️⃣ 공식 자료, 유튜브 Flutter 강의나 개발 블로그를 활용하여 완성도 있게 보완하는 것까지를 목표로 한다.
공식 문서 Navigation 코드 테스트
먼저 'navigation_test' 앱을 생성했다. 화면 클래스 소스코드 파일을 각각 만든다. main.dart에 화면 소스코드를 import 하고, initialRoute와 routes를 추가한다. 각 화면은 Stateless Widget을 상속받아 만들었고, 화면의 버튼에 navigation 로직을 추가하였다.
main.dart
initialRoute '/' 경로에는 FirstScreen을 맵핑하였고, 'second'에는 SecondScreen, 'third'에는 ThirdScreen을 맵핑했다.
import 'package:flutter/material.dart';
import 'first_screen.dart';
import 'second_screen.dart';
import 'third_screen.dart';
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,
),
initialRoute: '/',
routes: {
'/': (context) => const FirstScreen(),
'/second': (context) => const SecondScreen(),
'/third': (context) => const ThirdScreen(),
}
// home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}
first_screen.dart
FirstScreen을 StatelessWidget을 상속받아 만들었다. 'Go to second screen'이라고 적힌 ElevatedButton을 생성했다. 버튼에 onPressed 이벤트가 발생하면, Navigator.pushNamed 콜백 함수가 호출되어 SecondScreen으로 이동한다.
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('First Screen'),
),
body: Center(
child: ElevatedButton(
onPressed: () {
// Navigate to the second screen when tapped.
Navigator.pushNamed(context, '/second');
},
child: const Text('Go to second screen'),
),
),
);
}
}
second_screen.dart
FirstScreen을 StatelessWidget을 상속받아 만들었다. 'Go back!'과 'Go to third screen'이라고 적힌 ElevatedButton을 생성했다.
'Go back!' 버튼에 onPressed 이벤트가 발생하면, Navigator.pop 콜백 함수가 호출되어 현재 화면이 스택에서 제거되고 이전 화면인 FirstScreen으로 이동한다.
'Go to third screen' 버튼에 onPressed 이벤트가 발생하면, Navigator.pushNamed 콜백 함수가 호출되어 ThirdScreen으로 이동한다.
import 'package:flutter/material.dart';
class SecondScreen extends StatelessWidget {
const SecondScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Second Screen'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
// Navigate back to first screen when tapped.
Navigator.pop(context);
},
child: const Text('Go back!'),
),
ElevatedButton(
onPressed: () {
Navigator.pushNamed(context, '/third');
},
child: const Text('Go to third screen')
)
],
),
),
);
}
}
third_screen.dart
ThirdScreen을 StatelessWidget을 상속받아 만들었다. 'Go back!'이라고 적힌 ElevatedButton을 생성했다.
'Go back!' 버튼에 onPressed 이벤트가 발생하면, Navigator.pop 콜백 함수가 호출되어 현재 화면이 스택에서 제거되고 이전 화면인 SecondScreen으로 이동한다.
import 'package:flutter/material.dart';
class ThirdScreen extends StatelessWidget {
const ThirdScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Third Screen'),
),
body: Center(
child: ElevatedButton(
onPressed: () {
Navigator.pop(context);
},
child: const Text('Go back!'),
),
),
);
}
}
실행 결과
위의 코드를 실행하면 아래와 같이 나온다. 이것만으로는 부족하다. 아래에서 더 보완해 보겠다.
예시 코드를 어떻게 보완할까?
공식 코드 스니펫으로는 부족한 점이 있다.
- main에 각 화면의 소스코드를 개별로 import 하는 것이 지저분하다.
- 상단의 AppBar에서 좌우 아이콘은 공통으로 고정하고, 가운데 title만 화면마다 다르게 뜨도록 해야 한다. AppBar 스타일이나 공통 요소 코드를 모든 화면마다 추가하는 것은 비효율적이다.
- 현재 구성한 '화면 1 - 화면 2 - 화면 3' 이동 경로는 너무 단조로워서 조금 더 복잡한 경로를 만들어봐야 한다.
- appbar와 body만 있고, 하단에 Navigation bar가 없다.
- 파일의 디렉토리 구조가 정리가 안 된 느낌이다.
여기서부터 본격적으로 개선을 시작한다. 실행 결과는 맨 아래에 있다.
기능별 분리와 MVVM 구조 적용
Model - View - ViewModel(MVVM) 디자인 패턴은 애플리케이션을 Model, ViewModel, View 3개의 파트로 분리하는 것이다. View와 View Model은 애플리케이션의 UI layer를 구성한다. Repository와 Services는 애플리케이션의 Data layer를 구성한다.
- View: UI를 정의한다. Scaffold 위젯과 그 하위 위젯들로 구성된 화면이 하나의 뷰가 될 수 있다.
- 뷰 모델의 플래그 또는 Null 가능 필드를 기반으로 위젯을 표시하거나 숨기기 위한 간단한 if 문
- 애니메이션 로직
- 화면 크기나 방향 같은 디바이스 정보를 기반으로 하는 레이아웃 로직
- 간단한 라우팅 로직
- View Model: UI와 Data layer 사이의 다리 역할로, 비즈니스 로직을 핸들링한다. 데이터를 그대로 UI에 보여주기 어려운 경우가 많으므로 필요한 형태로 가공한다.
- Repository에서 가져온 데이터 필터링, 정렬 집계 작업
- 데이터를 잃지 않고 다시 빌드할 수 있도록 View에 필요한 현재 상태를 유지하는 작업
- View에서 이벤트 핸들러에 연결할 수 있는 콜백 명령을 노출하는 작업
- Repository: 애플리케이션에서 사용하는 데이터를 관리한다. 외부 서비스에서 데이터를 가져오고, 원본 데이터를 도메인 모델로 변환한다. 도메인 모델이란 앱에서 필요로 하는 데이터로, View Model에서 사용하기 적절한 형태로 포맷팅 된다.
- 캐싱
- 에러 핸들링
- 재시도 로직
- 데이터 새로고침
- Service로부터 새로운 데이터 수신
- 사용자 상호작용에 따라 데이터 갱신
- Service: 클라이언트 서버, 플러그인 등 외부 API와 상호작용을 한다. API endpoints를 감싸고, Future나 Stream 같은 비동기 응답 객체를 제공한다.
- 기본 플랫폼(iOS, Android) APIs
- REST 엔드포인트
- 로컬 파일
* 참고 링크
- Guide to app architecture: https://docs.flutter.dev/app-architecture/guide
- [043] 플러터 (Flutter) 배우기 - MVVM 아키텍처 패턴 적용: https://totally-developer.tistory.com/115
- [Flutter] 프로젝트 초기 설정 - 폴더 구조: https://cherrie.hashnode.dev/flutter-1
디렉토리 구조
먼저 MVVM 아키텍처 패턴에 따라 생성한 디렉토리 구조와 속해있는 파일들은 다음과 같다.
- lib > feature 디렉토리에 기능별 분류를 하고, 기능마다 screens, viewmodel, repository, service 디렉토리를 생성했다.
- utils는 앱 전체에서 사용되는 기능 관련 코드를 저장한다.
- widgets는 앱 전체에서 사용되는 위젯 코드를 저장한다.
- main.dart는 앱의 진입점이고, navigation_menu.dart는 네비게이션 메뉴 코드이다.
- assets에는 이미지 파일을 저장하며, 프로젝트 루트 경로에 생성했다.
앱의 진입점 설정
먼저 'navigation_advance' 앱을 생성했다. main.dart에는 앱의 진입점이 정의되어 있다.
main.dart
- 스플래시 화면 관련 코드가 먼저 나오는데 이 부분을 아래에서 자세히 다루기 때문에, 일단 패스한다.
- 스플래시 화면이 지나간 후, home으로 지정된 Navigation 클래스에 있는 화면이 나오도록 설정하였다.
import 'package:flutter/material.dart';
import 'package:navigation_advanced/navigation_menu.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
FlutterNativeSplash.remove;
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
useMaterial3: true,
),
home: const Navigation(),
debugShowCheckedModeBanner: false,
);
}
}
기본 Navigation Bar
navigation_menu.dart
- selectedIndex는 현재 선택된 네비게이션 바의 인덱스로, setState를 통해 값이 변경될 때마다 UI를 새로고침한다.
- NavigationDestination은 네비게이션 아이템으로, 아이콘과 레이블(홈, 검색, 설정)을 지정하였다.
- IndexedStack을 활용하여, 현재 선택된 인덱스에 해당하는 화면만 표시한다.
import 'package:flutter/material.dart';
import 'package:navigation_advanced/features/test_category/screens/home.dart';
import 'package:navigation_advanced/features/test_category/screens/search.dart';
import 'package:navigation_advanced/features/test_category/screens/settings.dart';
class Navigation extends StatefulWidget {
final int initialIndex;
const Navigation({super.key, this.initialIndex = 0});
@override
State<Navigation> createState() => _NavigationState();
}
class _NavigationState extends State<Navigation> {
late int selectedIndex;
@override
void initState() {
super.initState();
selectedIndex = widget.initialIndex;
}
@override
Widget build(BuildContext context) {
return Scaffold(
bottomNavigationBar: NavigationBar(
selectedIndex: selectedIndex,
onDestinationSelected: (value) {
setState(() {
selectedIndex = value;
});
},
destinations: [
NavigationDestination(
icon: const Icon(Icons.home),
label: "홈",
),
NavigationDestination(
icon: const Icon(Icons.search_off_rounded),
label: "검색",
),
NavigationDestination(
icon: const Icon(Icons.settings),
label: "설정",
),
],
),
body: IndexedStack(
index: selectedIndex,
children: const [
HomeScreen(),
SearchScreen(),
SettingsScreen(),
],
),
);
}
}
커스텀 AppBar 생성 및 적용
custom_app_bar.dart
- PreferredSizeWidget 인터페이스를 사용하며, App Bar 높이를 플러터 기본 제공 높이인 kToolbarHeight로 지정한다.
- backgroundColor를 지정한다.
- action에 프로필 모양 아이콘을 추가한다. onPressed 이벤트가 있으면 Navigator.push로 ProfileDetailScreen으로 이동한다. (createRoute에 대해서는 뒤에서 자세히 설명한다.)
import 'package:flutter/material.dart';
import 'package:navigation_advanced/features/test_category/screens/profile_detail.dart';
import '../utils/router_without_animation.dart';
class CustomAppBar extends StatelessWidget implements PreferredSizeWidget {
final String title;
const CustomAppBar({super.key, required this.title});
@override
Widget build(BuildContext context) {
return AppBar(
title: Text(title),
backgroundColor: Colors.lime.shade400,
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.account_circle),
iconSize: 40.0,
color: Colors.white,
onPressed: () {
Navigator.push(context, createRoute(const ProfileDetailScreen()));
},
),
],
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
커스텀 Navigation Bar 생성 및 적용
custom_navigation_bar.dart
- currentIndex는 현재 선택된 네비게이션 항목의 인덱스이다.
- onDestinationSelected는 네비게이션 항목이 선택되면 호출되는 콜백 함수로, 선택된 인덱스를 전달한다.
- 'Navigator.pushAndRemoveUntil'과 '(route) => false'는 새로운 화면을 추가하고 스택에 있는 이전 화면들은 모두 지워버린다.
import 'package:flutter/material.dart';
import 'package:navigation_advanced/navigation_menu.dart';
import 'package:navigation_advanced/utils/router_without_animation.dart';
class CustomNavigationBar extends StatelessWidget {
final int currentIndex;
final ValueChanged<int> onDestinationSelected;
const CustomNavigationBar({
super.key,
required this.currentIndex,
required this.onDestinationSelected,
});
@override
Widget build(BuildContext context) {
return NavigationBar(
selectedIndex: currentIndex,
onDestinationSelected: (value) {
Navigator.pushAndRemoveUntil(
context, createRoute(Navigation(initialIndex: value)),
(route) => false);
},
destinations: [
NavigationDestination(
icon: const Icon(Icons.home),
label: "홈",
),
NavigationDestination(
icon: const Icon(Icons.search_off_rounded),
label: "검색",
),
NavigationDestination(
icon: const Icon(Icons.settings),
label: "설정",
),
],
);
}
}
💟 화면 이동 시 자동으로 설정된 애니메이션을 제거하는 방법
처음에는 main.dart에 화면별 route를 정의하고, Navigator.pushNamed로 화면 이동을 하게 만들었다. 그런데 실제로 사용해 보니, 안드로이드와 iOS 모두 기본으로 화면 이동 애니메이션이 존재했다.
내가 느끼기에는 기본 애니메이션이 부자연스러웠다. 그래서 주변 사람에게 사전 정보 없이 사용 후 피드백을 달라고 했다. 애니메이션이 앱 퀄리티를 떨어뜨리는 느낌이라는 말을 들었다. 역시 개발하는 사람 눈에도 보이는데, 사용자에게 안 보일 리 없었다.
그래서 기존 방법 대신에 PageRouteBuilder를 사용하여, 화면 이동 애니메이션을 제거해 버렸다. 훨씬 마음에 드는 결과였다.
router_without_animation.dart
- createRoute의 주요 기능은 애니메이션 없이 화면 이동을 하기 위한 것이다.
- PageRouteBuilder는 페이지 전환 애니메이션을 커스터마이즈할 수 있는 위젯이다.
- transitionBuilder에서 child를 그대로 반환하여, 애니메이션 없이 새 페이지로 바로 이동한다.
import 'package:flutter/material.dart';
// Move Screen with no animation
Route createRoute(Widget page) {
return PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return child;
},
);
}
네비게이션 바에 포함된 화면 코드 모음
home.dart
- StatelessWidget을 상속받아 만든다.
- appBar에 CustomAppBar를 지정하고, 상단 바에 들어갈 텍스트를 전달한다.
- Text와 SizedBox 위젯을 Column의 children으로 추가한다.
- Image.asset에 이미지 경로를 제공한다.
import 'package:flutter/material.dart';
import 'package:navigation_advanced/widgets/custom_app_bar.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const CustomAppBar(title: "Flutter 네비게이션 홈"),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Home 페이지입니다.',
style: TextStyle(
fontSize: 20,
),
),
SizedBox(
height: 400,
width: 400,
child: Image.asset('assets/home.png'),
),
],
),
),
);
}
}
pubspec.yaml 파일에도 asset 파일 경로를 입력해야 한다.
flutter:
assets:
- assets/home.png
- assets/search.png
- assets/settings.png
- assets/flower.png
search.dart
- StatelessWidget을 상속받아 만든다.
- appBar에 CustomAppBar를 지정하고, 상단 바에 들어갈 텍스트를 전달한다.
- SizedBox와 ElevatedButton 위젯을 Column의 children으로 추가한다.
- Image.asset에 이미지 경로를 제공한다.
- ElevatedButton에 onPressed 이벤트가 발생하면, Navigator.push와 createRoute를 사용하여 애니메이션 없이 화면을 이동한다. createRoute에는 화면 클래스 이름을 전달해야 한다.
import 'package:flutter/material.dart';
import 'package:navigation_advanced/widgets/custom_app_bar.dart';
import 'package:navigation_advanced/features/test_category/screens/issue.dart';
import 'package:navigation_advanced/features/test_category/screens/search_list.dart';
import 'package:navigation_advanced/utils/router_without_animation.dart';
class SearchScreen extends StatelessWidget {
const SearchScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const CustomAppBar(title: "최신 건강 정보 검색"),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: 400,
width: 400,
child: Image.asset('assets/search.png'),
),
ElevatedButton(
onPressed: () {
Navigator.push(context, createRoute(const SearchListScreen()));
},
child: const Text('지난 검색 기록'),
),
ElevatedButton(
onPressed: () {
Navigator.push(context, createRoute(const IssueScreen()));
},
child: const Text('최신 이슈 목록'),
),
],
),
)
);
}
}
settings.dart
- StatelessWidget을 상속받아 만든다.
- appBar에 CustomAppBar를 지정하고, 상단바에 들어갈 텍스트를 전달한다.
- SizedBox 위젯을 Center 위젯의 child로 추가한다.
- Image.asset에 이미지 경로를 제공한다.
import 'package:flutter/material.dart';
import 'package:navigation_advanced/widgets/custom_app_bar.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const CustomAppBar(title: "환경 설정"),
body: Center(
child: SizedBox(
height: 400,
width: 400,
child: Image.asset('assets/settings.png'),
),
)
);
}
}
네비게이션 바에 포함되지 않은 화면 코드 모음
search_list.dart
- StatelessWidget을 상속받아 만든다.
- appBar에 CustomAppBar를 지정하고, 상단바에 들어갈 텍스트를 전달한다.
- SizedBox 위젯을 Center 위젯의 child로 추가한다.
- buttomNavigationBar에는 CustomNavigationBar를 지정한다.
- currentIndex는 네비게이션 바에 포함된 화면의 인덱스를 의미한다. SearchListScreen은 SearchScreen을 통해 들어오는 화면이므로, SearchScreen 인덱스인 1을 적는다.
- onDestinationSelected는 네비게이션 항목이 선택되면 호출되는 콜백 함수로, 선택된 인덱스를 전달한다.
- 'Navigator.pushAndRemoveUntil'과 '(route) => false'는 새로운 화면을 추가하고 스택에 있는 이전 화면들은 모두 지워버린다.
import 'package:flutter/material.dart';
import 'package:navigation_advanced/widgets/custom_app_bar.dart';
import 'package:navigation_advanced/navigation_menu.dart';
import 'package:navigation_advanced/widgets/custom_navigation_bar.dart';
import 'package:navigation_advanced/utils/router_without_animation.dart';
class SearchListScreen extends StatelessWidget {
const SearchListScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const CustomAppBar(title: "지난 검색 기록"),
body: Center(
child: SizedBox(
height: 400,
width: 200,
child: const Text(
"지난 검색 기록\n페이지입니다.",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 30),
),
),
),
bottomNavigationBar: CustomNavigationBar(
currentIndex: 1, // SearchScreen Index
onDestinationSelected: (value) {
Navigator.pushAndRemoveUntil(
context, createRoute(Navigation(initialIndex: value)),
(route) => false);
},
),
);
}
}
issue.dart
- StatelessWidget을 상속받아 만든다.
- appBar에 CustomAppBar를 지정하고, 상단바에 들어갈 텍스트를 전달한다.
- SizedBox와 ElevatedButton 위젯을 Column 위젯의 children으로 추가한다. ElevatedButton에 onPressed 이벤트가 발생하면, Navigator.push와 createRoute를 사용하여 애니메이션 없이 화면을 이동한다. createRoute에는 화면 클래스 이름을 전달해야 한다.
- buttomNavigationBar에는 CustomNavigationBar를 지정한다.
- currentIndex는 네비게이션 바에 포함된 화면의 인덱스를 의미한다. IssueScreen은 SearchScreen을 통해 들어오는 화면이므로, SearchScreen 인덱스인 1을 적는다.
- onDestinationSelected는 네비게이션 항목이 선택되면 호출되는 콜백 함수로, 선택된 인덱스를 전달한다.
- 'Navigator.pushAndRemoveUntil'과 '(route) => false'는 새로운 화면을 추가하고 스택에 있는 이전 화면들은 모두 지워버린다.
import 'package:flutter/material.dart';
import 'package:navigation_advanced/widgets/custom_app_bar.dart';
import 'package:navigation_advanced/features/test_category/screens/issue_detail_1.dart';
import 'package:navigation_advanced/navigation_menu.dart';
import 'package:navigation_advanced/widgets/custom_navigation_bar.dart';
import 'package:navigation_advanced/utils/router_without_animation.dart';
class IssueScreen extends StatelessWidget {
const IssueScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const CustomAppBar(title: "최신 이슈 목록"),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
height: 400,
width: 200,
child: const Text(
"최신 이슈 목록\n페이지입니다.",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 30),
),
),
ElevatedButton(
onPressed: () {
Navigator.push(context, createRoute(const IssueDetailScreen1()));
},
child: const Text('이슈 1 보러가기'),
),
],
),
),
bottomNavigationBar: CustomNavigationBar(
currentIndex: 1, // SearchScreen Index
onDestinationSelected: (value) {
Navigator.pushAndRemoveUntil(
context, createRoute(Navigation(initialIndex: value)),
(route) => false);
},
),
);
}
}
issue_detail_1.dart
- StatelessWidget을 상속받아 만든다.
- appBar에 CustomAppBar를 지정하고, 상단바에 들어갈 텍스트를 전달한다.
- SizedBox 위젯을 Center 위젯의 child로 추가한다.
- buttomNavigationBar에는 CustomNavigationBar를 지정한다.
- currentIndex는 네비게이션 바에 포함된 화면의 인덱스를 의미한다. IssueDetailScreen1은 SearchScreen을 통해 들어오는 화면이므로, SearchScreen 인덱스인 1을 적는다.
- onDestinationSelected는 네비게이션 항목이 선택되면 호출되는 콜백 함수로, 선택된 인덱스를 전달한다.
- 'Navigator.pushAndRemoveUntil'과 '(route) => false'는 새로운 화면을 추가하고 스택에 있는 이전 화면들은 모두 지워버린다.
import 'package:flutter/material.dart';
import 'package:navigation_advanced/widgets/custom_app_bar.dart';
import 'package:navigation_advanced/navigation_menu.dart';
import 'package:navigation_advanced/widgets/custom_navigation_bar.dart';
import 'package:navigation_advanced/utils/router_without_animation.dart';
class IssueDetailScreen1 extends StatelessWidget {
const IssueDetailScreen1({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const CustomAppBar(title: "이슈 1: 네비게이션 성공"),
body: Center(
child: SizedBox(
height: 400,
width: 200,
child: const Text(
"이슈1\n페이지입니다.",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 30),
),
),
),
bottomNavigationBar: CustomNavigationBar(
currentIndex: 1, // SearchScreen Index
onDestinationSelected: (value) {
Navigator.pushAndRemoveUntil(
context, createRoute(Navigation(initialIndex: value)),
(route) => false);
},
),
);
}
}
profile_detail.dart
- StatelessWidget을 상속받아 만든다.
- appBar에 CustomAppBar를 지정하고, 상단바에 들어갈 텍스트를 전달한다.
- SizedBox 위젯을 Center 위젯의 child로 추가한다.
- buttomNavigationBar에는 CustomNavigationBar를 지정한다.
- currentIndex는 네비게이션 바에 포함된 화면의 인덱스를 의미한다. ProfileDetailScreen은 App Bar의 아이콘을 클릭하여 들어오는 페이지지만, 현재 네비게이션 구조상 네비게이션 바에 있는 화면 중 하나로 선택해놓는 것이 복잡하지 않다. 프로필 정보와 맥락이 비슷한 SettingScreen 인덱스인 2를 적는다.
- onDestinationSelected는 네비게이션 항목이 선택되면 호출되는 콜백 함수로, 선택된 인덱스를 전달한다.
- 'Navigator.pushAndRemoveUntil'과 '(route) => false'는 새로운 화면을 추가하고 스택에 있는 이전 화면들은 모두 지워버린다.
import 'package:flutter/material.dart';
import 'package:navigation_advanced/widgets/custom_app_bar.dart';
import 'package:navigation_advanced/navigation_menu.dart';
import 'package:navigation_advanced/widgets/custom_navigation_bar.dart';
import 'package:navigation_advanced/utils/router_without_animation.dart';
class ProfileDetailScreen extends StatelessWidget {
const ProfileDetailScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const CustomAppBar(title: "상세 프로필"),
body: Center(
child: SizedBox(
height: 400,
width: 200,
child: const Text(
"상세 프로필\n페이지입니다.",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 30),
),
),
),
bottomNavigationBar: CustomNavigationBar(
currentIndex: 2, // SettingsScreen Index
onDestinationSelected: (value) {
Navigator.pushAndRemoveUntil(
context, createRoute(Navigation(initialIndex: value)),
(route) => false);
},
),
);
}
}
Splash Screen 설정
🍎 iOS 스플래시 화면 만드는 방법
1. 패키지 다운로드
flutter_native_splash 패키지를 다운로드한다.
flutter pub add flutter_native_splash
2. pubspec.yaml 파일 수정
flutter_native_splash 버전은 설치하면서 자동 생성되고, 상제 설정은 직접 적어주어야 한다. 이미지는 assets에도 등록을 해주어야 한다.
dependencies:
flutter:
sdk: flutter
flutter_native_splash: ^2.4.4
flutter_native_splash:
android: true
ios: true
web: false
color: '#94B79D'
image: 'assets/flower.png'
3. 설정을 적용하는 커맨드 실행
* 참고 링크
- [Flutter] Splash Screen 적용하기 (feat. flutter_native_splash): https://blog.naver.com/
- Flutter Native Splash Screen: https://www.youtube.com/watch?v=ioWob9KnWk4&t=29s
[ 스플래시 화면 초기 적용 ]
flutter clean
flutter pub get
flutter pub run flutter_native_splash:create
화면 설정이 완료되면 다음 화면이 나온다.
[ 스플래시 화면 설정 변경 후 적용 ]
flutter_native_splash:remove
flutter clean
flutter pub get
flutter pub run flutter_native_splash:create
4. main.dart 에 코드 추가
위쪽에 이미 한 번 나왔지만, 스플래시 화면 코드만 자세히 보겠다.
- flutter_native_splash 패키지를 임포트한다.
- WidgetsFlutterBinding.ensureInitialized는 비동기 작업을 시작하기 전에 엔진과 프레임워크 초기화를 보장한다.
- FlutterNativeSplash.remove는 스플래시 화면을 제거한다.
import 'package:flutter_native_splash/flutter_native_splash.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
FlutterNativeSplash.remove;
runApp(const MyApp());
}
5. 앱 아이콘 변경
아래 링크에 들어가서 원하는 대표 이미지 파일을 앱 아이콘으로 변환한다.
* 필수 링크
App Icon Generator: https://www.appicon.co
App Icon Generator
www.appicon.co
android용 아이콘과 iOS용 아이콘으로 친절하게 변환해 주었다.
ios/Runner/Assets.xcassets 폴더 안에 있는 기존 AppIcon.appiconset를 지우고, 아래 다운받은 AppIcon.appiconset를 복사 붙여 넣기 한다.
* 참고 링크
[Flutter] 플러터 앱 아이콘(icon) 변경하기:
https://asufi.tistory.com/entry/Flutter-Flutter-앱-출시-하기-release-build-apk
📱 Android 스플래시 화면 만드는 방법
flutter_native_splash 패키지로 한 번에 해결하려고 했는데, 안드로이드 설정은 따로 해야 했다.
* 참고 링크
- [Flutter] Launch / Splash Screen 만들기: https://velog.io/@tygerhwang/Flutter-Launch-Splash-Screen-만들기
- flutter_native_splash 2.4.4: https://pub.dev/packages/flutter_native_splash
1. 앱 아이콘 변경
android > app > src > main > res > mipmap-hdpi, mipmap-mdpi, mipmap-xhdpi, mipmap-xxhdpi, mipmap-xxxhdpi에 아까 생성했던 아이콘 파일을 붙여 넣는다. ic_launcher.png는 기존에 있던 파일로 Flutter 공식 아이콘이 있고, splash.png는 내가 추가한 아이콘이다.
2. color.xml 추가
android > app > src > main > res > values, values-night, values-night-v31, values-v31에 color.xml을 추가하고 아래 코드를 작성한다. 스플래시 화면의 배경 색상을 설정한다.
<resources>
<color name="splash_background_color">#94B79D</color>
</resources>
3. styles.xml 수정
android > app > src > main > res > values, values-night, values-night-v31, values-v31에 있는 styles.xml을 수정한다.
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
(기존 코드 생략)
<item name="android:windowBackground">@color/splash_background_color</item>
</style>
</resources>
4. AndroidManifest.xml 파일 수정
android > app > src > main > AndroidManifest.xml 파일을 수정한다. android:icon 설정을 "@mipmap/{파일명}"으로 수정하고, 스플래시 화면 배경 색상을 설정하는 <meta-data> 태그를 추가한다.
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="navigation_advanced"
android:name="${applicationName}"
android:icon="@mipmap/splash" // 수정한 부분 (splash)
android:enableOnBackInvokedCallback="true">
// 추가한 부분 (스플래시 화면 배경 색상)
<meta-data
android:name="android.windowSplashScreenBackground"
android:resource="@color/splash_background_color" />
보완한 코드 실행 결과
Android와 iOS 시뮬레이터에서 코드를 실행하였다. 스플래시 화면 디자인이 변경되었음을 확인했다. 네비게이션 바, 버튼, 아이콘, 뒤로가기 화살표를 다양하게 클릭해 보며 화면 이동 테스트를 진행했다. 화면 애니메이션이 제대로 제거되었고, App Bar와 Navigation Bar도 사라지지 않고 정상 작동한다.
위의 코드에서 새로운 화면 추가 시 반드시 해야 하는 것!
1. CustomAppBar 설정
Scaffold 내부 appBar에 상단 바 텍스트를 지정한다.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const CustomAppBar(title: "최신 이슈 목록"),
2. Button onPressed 콜백 함수 작성
Scaffold 내부 body에 버튼을 눌러 화면 이동을 하는 기능이 있다면, onPressed 콜백함수에 Navigator.push와 createRoute, 화면 이름을 입력한다.
ElevatedButton(
onPressed: () {
Navigator.push(context, createRoute(const IssueDetailScreen1()));
},
child: const Text('이슈 1 보러가기'),
),
3. CustomNavigationBar 설정
3-1. currentIndex 지정
새로 추가하려는 화면이 속하는 상위 카테고리 화면 (네비게이션 바에 있는 화면)의 인덱스를 지정한다.
3-2. onDestinationSelected
Navigator.pushAndRemoveUntil로 새로운 화면을 스택에 추가하고, 스택에 있던 기존 화면은 모두 삭제한다.
bottomNavigationBar: CustomNavigationBar(
currentIndex: 1, // SearchScreen Index
onDestinationSelected: (value) {
Navigator.pushAndRemoveUntil(
context, createRoute(Navigation(initialIndex: value)),
(route) => false);
},
),
Github 업로드
네비게이션 틀로 쓸 수 있게 소스코드를 깃허브에 올렸다.