[Flutter] 온보딩 구현하기 (feat. indicator)

코코아의 앱 개발일지·2024년 12월 1일
1

Flutter

목록 보기
2/2

앱을 처음 설치하는 유저에게 보여주는 온보딩은 앱에 대한 기본 사용법 또는 기능에 대해 설명해준다.
오늘은 내가 진행한 프로젝트에서 온보딩 화면을 구현한 방법에 대해 다뤄보겠다.

✍🏻 요구사항 분석

위의 화면으로 볼 때, 구현해야하는 기능은 아래와 같을 것이다.

  1. 페이지는 총 3개이고, 스크롤을 통해 넘길 수 있다.
  2. 페이지 번호는 인디케이터로 확인할 수 있다.
  3. 건너뛰기를 하면 로그인 화면으로 이동한다.
  4. 마지막 온보딩 페이지에서는 하단에 시작하기 버튼을 띄워준다.

이외에도 '온보딩'이라는 특성 상 최초 앱을 설치한 유저에게만 보여줘야 한다. 즉, 온보딩 과정을 끝낸 이후에는 다시 표시해주지 않아야 한다.

온보딩을 구현하는 방법은 다양할 수 있겠지만, 위의 디자인 상으로는 페이지마다 표시해 줄 정보가 모두 동일하다.
세 페이지 모두 동일한 위치에 아래 3가지 정보를 포함한다.

  • 제목 (텍스트)
  • 설명 (텍스트)
  • 이미지

그러면 화면 3개를 따로 만들지 않고 재활용하기가 용이하다. 스크린 파일 하나에서 모든 온보딩 과정을 한번 구현해 보자!

💻 코드 작성

1️⃣ Model 작성

먼저 페이지 별로 표시해 줄 정보를 클래스로 만들어 준다.

class OnboardingInfo {
  OnboardingInfo({
    required this.title,
    required this.content,
    required this.image});

  final String title;
  final String content;
  final String image;
}

2️⃣ 화면 별로 들어갈 데이터 정의

위에서 만든 모델로 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"),
  ];
}

3️⃣ 화면 구현

인디케이터 라이브러리 추가

페이지 번호를 표시해주기 위한 인디케이터 라이브러리를 추가해 준다. 구글링해보다가 가장 괜찮아 보이는 것으로 선택했다. 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,
                  )),
            ),
          ),
        ),
      ],
    );
  }
}

📱 완성 화면


📚 참고 자료

profile
안드로이드 개발자를 꿈꾸는 학생입니다

0개의 댓글