앱을 처음 설치하는 유저에게 보여주는 온보딩은 앱에 대한 기본 사용법 또는 기능에 대해 설명해준다.
오늘은 내가 진행한 프로젝트에서 온보딩 화면을 구현한 방법에 대해 다뤄보겠다.
위의 화면으로 볼 때, 구현해야하는 기능은 아래와 같을 것이다.
- 페이지는 총 3개이고, 스크롤을 통해 넘길 수 있다.
- 페이지 번호는 인디케이터로 확인할 수 있다.
- 건너뛰기를 하면 로그인 화면으로 이동한다.
- 마지막 온보딩 페이지에서는 하단에 시작하기 버튼을 띄워준다.
이외에도 '온보딩'이라는 특성 상 최초 앱을 설치한 유저에게만 보여줘야 한다. 즉, 온보딩 과정을 끝낸 이후에는 다시 표시해주지 않아야 한다.
온보딩을 구현하는 방법은 다양할 수 있겠지만, 위의 디자인 상으로는 페이지마다 표시해 줄 정보가 모두 동일하다.
세 페이지 모두 동일한 위치에 아래 3가지 정보를 포함한다.
그러면 화면 3개를 따로 만들지 않고 재활용하기가 용이하다. 스크린 파일 하나에서 모든 온보딩 과정을 한번 구현해 보자!
먼저 페이지 별로 표시해 줄 정보를 클래스로 만들어 준다.
class OnboardingInfo {
OnboardingInfo({
required this.title,
required this.content,
required this.image});
final String title;
final String content;
final String image;
}
위에서 만든 모델로 3개 화면에 들어갈 데이터를 각각 정의해준다.
이를 items
라 명칭.
class OnboardingItems {
static String basePath = "assets/images/onboarding";
List<OnboardingInfo> items = [
OnboardingInfo(
title: "onboarding_title1".tr(),
content: "onboarding_content1".tr(),
image: "$basePath/onboarding1.png"),
OnboardingInfo(
title: "onboarding_title2".tr(),
content: "onboarding_content2".tr(),
image: "$basePath/onboarding2.png"),
OnboardingInfo(
title: "onboarding_title3".tr(),
content: "onboarding_content3".tr(),
image: "$basePath/onboarding3.png"),
];
}
페이지 번호를 표시해주기 위한 인디케이터 라이브러리를 추가해 준다. 구글링해보다가 가장 괜찮아 보이는 것으로 선택했다. smooth_page_indicator 이다.
smooth_page_indicator: ^1.1.0
건너뛰기 버튼은 상단에 고정되어 있으니까, Scaffold의 앱바로 추가해 준다.
body에는 페이지 아이템의 제목, 내용, 이미지가 들어갈 수 있게끔 구현한다.
PageView.builder
를 통해 페이지 이동을 설정해 준다. 이때, 아이템은 OnboardingItems의 개수만큼 만들어 주고, 컨트롤러로는 pageController를 가진다.
class OnboardingScreen extends StatefulWidget {
const OnboardingScreen({super.key});
static String routeName = "/onboarding";
State<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends State<OnboardingScreen> {
final controller = OnboardingItems();
final pageController = PageController();
bool isLastPage = false;
Future<void> moveToLoginScreen() async {
final pres = SharedPreferencesService.get();
pres.setBool("isOnboardingFinished", true);
if (!mounted) return;
Navigator.pushNamedAndRemoveUntil(
context, LoginScreen.routeName, (route) => false);
}
Widget build(BuildContext context) {
return PopScope(
canPop: false,
child: Scaffold(
backgroundColor: ColorStyles.onboardingBackground,
appBar: AppBar(
automaticallyImplyLeading: false,
backgroundColor: ColorStyles.onboardingBackground,
actions: [
TextButton( // 건너뛰기 버튼
onPressed: moveToLoginScreen, // 로그인 화면으로 이동
child: Text(
"skip".tr(),
style: const TextStyle(
color: ColorStyles.categoryEtc, fontSize: 12),
)),
],
),
body: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Flexible(
flex: 5,
child: PageView.builder(
onPageChanged: (index) => setState(() =>
isLastPage = controller.items.length - 1 == index),
itemCount: controller.items.length,
controller: pageController,
itemBuilder: (context, index) {
return SizedBox(
height: MediaQuery.of(context).size.height * 0.5,
width: double.infinity,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
SizedBox(
height: MediaQuery.of(context).size.height *
0.06),
Text( // title
controller.items[index].title,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
Text( // content
controller.items[index].content,
style: const TextStyle(
color: ColorStyles.onboardingContentText,
fontSize: 14,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 60),
Image.asset( // image
controller.items[index].image,
// width: 375
),
],
),
);
}),
),
],
),
)
);
}
SmoothPageIndicator(
controller: pageController,
count: controller.items.length,
effect: const ExpandingDotsEffect(
dotHeight: 8,
dotWidth: 8,
dotColor: ColorStyles.gray_500,
activeDotColor: ColorStyles.accent),
),
화면의 하단 부분에는 인디케이터와 (마지막 페이지라면) 시작하기 버튼이 들어가야 한다. 인디케이터는 페이지 이동과 동기화되어야 하기 때문에 PageView.builder
과 아이템 개수와 컨트롤러를 동일하게 가져간다. ExpandingDotsEffect
로 인디케이터의 스타일을 설정해 준다.
버튼 위젯은 코드에서 따로 분리했는데, 마지막 페이지일 때만 버튼을 표시해주어야 한다.
Widget getStartButton() {
return Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Divider(height: 1, color: ColorStyles.divider),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(15),
backgroundColor: ColorStyles.jBlue_500,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5.0),
),
),
onPressed: moveToLoginScreen,
child: Text("do_start".tr(),
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
)),
),
),
),
],
);
}
찾아보니 플러터에서는 Visibility 속성도 위젯이라고 해서 getStartButton()
을 Visibility 위젯으로 감싸, isLastPage를 visible로 주었다.
Visibility(
visible: isLastPage,
child: getStartButton()
),
마지막 온보딩 화면에서 '시작하기' 버튼을 누르거나 온보딩 과정 중에 '건너뛰기' 버튼을 누를 경우 로그인 화면으로 이동한다. 이때, 온보딩 완료 처리를 위해 SharedPreferences의 "isOnboardingFinished"를 true로 넣어준다.
Future<void> moveToLoginScreen() async {
final pres = SharedPreferencesService.get();
pres.setBool("isOnboardingFinished", true);
if (!mounted) return;
Navigator.pushNamedAndRemoveUntil(
context, LoginScreen.routeName, (route) => false);
}
이 'isOnboardingFinished' 값은 초기 스플래시 화면에서 다음으로 이동시킬 화면을 판단할 때 사용하면 된다.
- 온보딩 완료 이전이라면 (ex. 앱을 처음 설치한 유저) -> 온보딩 화면으로 이동,
- 온보딩을 이미 끝냈으면 -> 로그인 여부에 따라 로그인 화면이나 홈 화면으로 이동
식으로 main.dart
코드에서 다음에 실행할 화면을 지정할 수 있다.
class OnboardingScreen extends StatefulWidget {
const OnboardingScreen({super.key});
static String routeName = "/onboarding";
State<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends State<OnboardingScreen> {
final controller = OnboardingItems();
final pageController = PageController();
bool isLastPage = false;
Future<void> moveToLoginScreen() async {
final pres = SharedPreferencesService.get();
pres.setBool("isOnboardingFinished", true);
if (!mounted) return;
Navigator.pushNamedAndRemoveUntil(
context, LoginScreen.routeName, (route) => false);
}
Widget build(BuildContext context) {
return PopScope(
canPop: false,
child: Scaffold(
backgroundColor: ColorStyles.onboardingBackground,
appBar: AppBar(
automaticallyImplyLeading: false,
backgroundColor: ColorStyles.onboardingBackground,
actions: [
TextButton( // 건너뛰기 버튼
onPressed: moveToLoginScreen, // 로그인 화면으로 이동
child: Text(
"skip".tr(),
style: const TextStyle(
color: ColorStyles.categoryEtc, fontSize: 12),
)),
],
),
body: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Flexible(
flex: 5,
child: PageView.builder(
onPageChanged: (index) => setState(() =>
isLastPage = controller.items.length - 1 == index),
itemCount: controller.items.length,
controller: pageController,
itemBuilder: (context, index) {
return SizedBox(
height: MediaQuery.of(context).size.height * 0.5,
width: double.infinity,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
SizedBox(
height: MediaQuery.of(context).size.height *
0.06),
Text( // title
controller.items[index].title,
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
Text( // content
controller.items[index].content,
style: const TextStyle(
color: ColorStyles.onboardingContentText,
fontSize: 14,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 60),
Image.asset( // image
controller.items[index].image,
// width: 375
),
],
),
);
}),
),
Flexible(
flex: 1,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SmoothPageIndicator(
controller: pageController,
count: controller.items.length,
effect: const ExpandingDotsEffect(
dotHeight: 8,
dotWidth: 8,
dotColor: ColorStyles.gray_500,
activeDotColor: ColorStyles.accent),
),
Visibility(
visible: isLastPage,
child: getStartButton()
),
],
),
),
],
),
));
}
Widget getStartButton() {
return Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Divider(height: 1, color: ColorStyles.divider),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(15),
backgroundColor: ColorStyles.jBlue_500,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5.0),
),
),
onPressed: moveToLoginScreen,
child: Text("do_start".tr(),
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,
)),
),
),
),
],
);
}
}