와챠의 우당탕탕 코딩 일기장

[Flutter] OnBoarding Screen/온보딩 화면/introduction_screen custom 본문

코딩 일기장/Flutter

[Flutter] OnBoarding Screen/온보딩 화면/introduction_screen custom

minWachya 2023. 7. 13. 12:57
반응형

만들고자 하는 것


flutter에서는 온보딩 화면을 간단하게 만들 수 있는 라이브러리를 제공해준다.

바로 이것.

https://pub.dev/packages/introduction_screen

 

introduction_screen | Flutter Package

Introduction/Onboarding package for flutter app with some customizations possibilities

pub.dev

 

기존에 제공해주는 기능은 다음과 같다.

이미지, 타이틀, 설명 텍스트, 스킵 버튼, 다음 버튼, 인디케이터 이것들의 위치가 고정이다.

그래서 아래와 같이 이미지, 타이틀, 설명을 지정해주면 나만의 온보딩을 간단하게 만들 수 있다.

PageViewModel(
  title: "Title of introduction page",
  body: "Welcome to the app! This is a description of how it works.",
  image: const Center(
    child: Icon(Icons.waving_hand, size: 50.0),
  ),
)

 

그런데 나는 위에서 보여드린 그림과 같이.. 이런 형태의 온보딩을 만들어야만 한다.ㅎㅎ

그런데 다행히도 이 라이브러리는 커스텀도 할 수 있어서 커스텀을 하여 만들어보기로 했다.


1. 의존성 추가

dependencies:
  introduction_screen: ^3.1.10

 

2. 온보딩 화면이 보여졌는지 아닌지를 SharedPreference로 관리하려고 한다.

data/local/provider/local_pref_provider.dart생성 후 아래 코드 작성~

import 'package:shared_preferences/shared_preferences.dart';

class LocalPrefProvider {
  late SharedPreferences prefs;

  final showOnBoarding = "onboarding";

  void setShowOnBoarding(bool bool) async {
    prefs = await SharedPreferences.getInstance();
    prefs.setBool(showOnBoarding, bool);
  }

  Future<bool> getShowOnBoarding() async {
    prefs = await SharedPreferences.getInstance();
    return prefs.getBool(showOnBoarding) ?? true;
  }
}

 

3. main화면에 온보딩화면 본 적 없으면 보이도록 추가

Future<void> main() async {
  // 비동기 메서드를 사용함
  WidgetsFlutterBinding.ensureInitialized();

  // 온보딩 보였는지 아닌지 변수 가져오기
  bool showOnBoarding = await LocalPrefProvider().getShowOnBoarding();

  runApp(MultiProvider(child: MyApp(showOnBoarding: showOnBoarding));
}

class MyApp extends StatelessWidget {
  final bool showOnBoarding;

  const MyApp({super.key, required this.showOnBoarding});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      themeMode: ThemeMode.system,
      // 온보딩 보인 적 없으면 보이고, 보인 적 있으면 메인 화면 보여주기
      home: showOnBoarding ? const OnBoardingScreen() : const MainScreen(),
    );
  }
}

 

4. 온보딩 화면 생성

내가 원하던 모습으로 커스텀한 코드는 다음과 같다.

(이미지는 더미 이미지를 넣었다. 앱 모두 개발한 후에 캡쳐된 화면을 넣을 예정이다.)

PageViewModel(
      useScrollView: false,	// 스크롤 없이 한 화면에 보이기
      title: "",			// 기본 타이틀 사용 안 함
      bodyWidget: Column(
        crossAxisAlignment: CrossAxisAlignment.center,
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
        // 제목 + 네온 효과
          Text(
            title,
            style: TextStyle(
              color: Colors.white,
              fontSize: 24,
              shadows: [
                for (double i = 1; i < 7; i++)
                  Shadow(color: AppColor.purpleColor2, blurRadius: 3 * i)
              ],
            ),
          ),
          const SizedBox(height: 10.0),
          // 내용
          SizedBox(
            height: 60,
            child: Text(
              context,
              textAlign: TextAlign.center,
              style: const TextStyle(color: Colors.white, fontSize: 16),
            ),
          ),
          const SizedBox(height: 10.0),
          Row(
            crossAxisAlignment: CrossAxisAlignment.center,
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
            // 왼쪽 이동 버튼
              IconButton(
                onPressed: () {
                  _introKey.currentState?.previous();
                },
                icon: Visibility(
                  visible: isVisibleLeft,
                  child: SvgPicture.asset(
                    'assets/icons/ic_left_purple.svg',
                  ),
                ),
              ),
              const SizedBox(
                width: 26.0,
              ),
              // 이미지
              Expanded(
                child: Image.asset(
                  imgPath,
                  height: screenHeightSize * 0.5,
                  fit: BoxFit.fitHeight,
                ),
              ),
              const SizedBox(
                width: 26.0,
              ),
              // 오른쪽 이동 버튼
              IconButton(
                onPressed: () {
                  _introKey.currentState?.next();
                },
                icon: Visibility(
                  visible: isVisibleRight,
                  child: SvgPicture.asset(
                    'assets/icons/ic_right_purple.svg',
                  ),
                ),
              ),
            ],
          )
        ],
      ),
    );

 

globalHeader를 사용해 스킵 버튼을 오른쪽 상단에 두었다.

또한 state를 관리하며 마지막 페이지가 되면 스킵버튼 안 보이게 함..ㅎ

globalHeader:
          Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
        Padding(
          padding: const EdgeInsets.all(18.0),
          child: TextButton(
            onPressed: () {
              _introKey.currentState?.skipToEnd();
            },
            child: Text(
              _skipState ? "건너뛰기" : "",
              style: const TextStyle(color: Colors.white, fontSize: 16),
              textAlign: TextAlign.right,
            ),
          ),
        )
      ]),
      
onChange: (value) {
        if (value == 4) {
          _skipState = false;
        } else {
          _skipState = true;
        }
        setState(() {});
      },

 

 

전체 코드는 다음과 같다.

마지막 페이지에서 권한 요청을 했기 때문에 권한 요청 코드도 함께 있다.

import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:introduction_screen/introduction_screen.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:pocket_pose/config/app_color.dart';
import 'package:pocket_pose/data/local/provider/local_pref_provider.dart';
import 'package:pocket_pose/ui/screen/main_screen.dart';

class OnBoardingScreen extends StatefulWidget {
  const OnBoardingScreen({super.key});

  @override
  State<OnBoardingScreen> createState() => _OnBoardingScreenState();
}

class _OnBoardingScreenState extends State<OnBoardingScreen> {
  final _introKey = GlobalKey<IntroductionScreenState>();
  late double screenHeightSize;

  bool _skipState = true;

  @override
  Widget build(BuildContext context) {
    screenHeightSize = MediaQuery.of(context).size.height;

    return IntroductionScreen(
      onChange: (value) {
        if (value == 4) {
          _skipState = false;
        } else {
          _skipState = true;
        }
        setState(() {});
      },
      globalBackgroundColor: Colors.black,
      globalHeader:
          Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
        Padding(
          padding: const EdgeInsets.all(18.0),
          child: TextButton(
            onPressed: () {
              _introKey.currentState?.skipToEnd();
            },
            child: Text(
              _skipState ? "건너뛰기" : "",
              style: const TextStyle(color: Colors.white, fontSize: 16),
              textAlign: TextAlign.right,
            ),
          ),
        )
      ]),
      globalFooter: const SizedBox(
        height: 30.0,
      ),
      key: _introKey,
      pages: [
        getImagePageViewModel(
            title: "대기",
            context: "참여자가 3명 이상이어야\n‘PoPo 스테이지’를시작할 수 있어요.🔥",
            imgPath: "assets/images/bg_popo_result.png",
            isVisibleLeft: false),
        getImagePageViewModel(
            title: "캐치",
            context:
                "랜덤으로 챌린지 노래가 선정됩니다.\n선착순 3명만 참여 가능하니 캐치 버튼을 빨리 눌러 참여해봐요! 💪",
            imgPath: "assets/images/bg_popo_result.png"),
        getImagePageViewModel(
            title: "플레이",
            context: "노래에 맞춰 춤을 춰봐요.✨\n춤 동작 마다 점수가 표시됩니다.",
            imgPath: "assets/images/bg_popo_result.png"),
        getImagePageViewModel(
            title: "결과",
            context:
                "최고의 평가를 받은 MVP가 선정 됩니다. 🥳🎉\nMVP는 5초간 모두의 앞에서 세레머니를 할 기회가 주어집니다.",
            imgPath: "assets/images/bg_popo_result.png"),
        getSvgPageViewModel(
            title: "시작하기",
            context: "자 그럼 지금부터\n포포와 함께 춤 짱이 되러 가볼까요?😝",
            imgPath: "assets/images/bg_popo_result.png"),
      ],
      onDone: () => goHomepage(),
      showDoneButton: false,
      showNextButton: false,
      showSkipButton: false,
      skip: const Text(
        'Skip',
        style: TextStyle(color: Colors.white),
      ),
      next: const Icon(
        Icons.arrow_forward,
        color: Colors.white,
      ),
      done: const Text(
        'Done',
        style: TextStyle(color: Colors.white),
      ),
      dotsDecorator: DotsDecorator(
        size: const Size(10.0, 10.0),
        color: Colors.white,
        activeSize: const Size(10.0, 10.0),
        activeColor: AppColor.purpleColor,
        activeShape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.all(Radius.circular(25.0)),
        ),
      ),
    );
  }

  PageViewModel getImagePageViewModel(
      {required String title,
      required context,
      required imgPath,
      bool isVisibleLeft = true,
      bool isVisibleRight = true,
      bool isVisibleSkip = true}) {
    return PageViewModel(
      useScrollView: false,
      title: "",
      bodyWidget: Column(
        crossAxisAlignment: CrossAxisAlignment.center,
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text(
            title,
            style: TextStyle(
              color: Colors.white,
              fontSize: 24,
              shadows: [
                for (double i = 1; i < 7; i++)
                  Shadow(color: AppColor.purpleColor2, blurRadius: 3 * i)
              ],
            ),
          ),
          const SizedBox(height: 10.0),
          SizedBox(
            height: 60,
            child: Text(
              context,
              textAlign: TextAlign.center,
              style: const TextStyle(color: Colors.white, fontSize: 16),
            ),
          ),
          const SizedBox(height: 10.0),
          Row(
            crossAxisAlignment: CrossAxisAlignment.center,
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              IconButton(
                onPressed: () {
                  _introKey.currentState?.previous();
                },
                icon: Visibility(
                  visible: isVisibleLeft,
                  child: SvgPicture.asset(
                    'assets/icons/ic_left_purple.svg',
                  ),
                ),
              ),
              const SizedBox(
                width: 26.0,
              ),
              Expanded(
                child: Image.asset(
                  imgPath,
                  height: screenHeightSize * 0.5,
                  fit: BoxFit.fitHeight,
                ),
              ),
              const SizedBox(
                width: 26.0,
              ),
              IconButton(
                onPressed: () {
                  _introKey.currentState?.next();
                },
                icon: Visibility(
                  visible: isVisibleRight,
                  child: SvgPicture.asset(
                    'assets/icons/ic_right_purple.svg',
                  ),
                ),
              ),
            ],
          )
        ],
      ),
    );
  }

  PageViewModel getSvgPageViewModel(
      {required String title, required context, required imgPath}) {
    return PageViewModel(
        useScrollView: false,
        title: "",
        bodyWidget: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              title,
              style: TextStyle(
                color: Colors.white,
                fontSize: 24,
                shadows: [
                  for (double i = 1; i < 7; i++)
                    Shadow(color: AppColor.purpleColor2, blurRadius: 3 * i)
                ],
              ),
            ),
            const SizedBox(height: 10.0),
            SizedBox(
              height: 60,
              child: Text(
                context,
                textAlign: TextAlign.center,
                style: const TextStyle(color: Colors.white, fontSize: 16),
              ),
            ),
            const SizedBox(height: 10.0),
            Row(
              crossAxisAlignment: CrossAxisAlignment.center,
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                IconButton(
                  onPressed: () {
                    _introKey.currentState?.previous();
                  },
                  icon: SvgPicture.asset(
                    'assets/icons/ic_left_purple.svg',
                  ),
                ),
                Flexible(
                  child: Column(
                    children: [
                      SvgPicture.asset(
                        'assets/images/charactor_on_boarding.svg',
                        fit: BoxFit.contain,
                        height: screenHeightSize * 0.4,
                      ),
                      Container(
                        decoration: BoxDecoration(
                            borderRadius: BorderRadius.circular(30),
                            boxShadow: [
                              for (double i = 1; i < 4; i++)
                                BoxShadow(
                                    color: AppColor.blueColor,
                                    blurStyle: BlurStyle.outer,
                                    blurRadius: 3 * i)
                            ]),
                        child: TextButton(
                          style: TextButton.styleFrom(
                            side: const BorderSide(
                                color: Colors.white, width: 2.5),
                            shape: RoundedRectangleBorder(
                                borderRadius: BorderRadius.circular(30)),
                          ),
                          onPressed: () {
                            permission();
                          },
                          child: const Padding(
                            padding: EdgeInsets.all(8.0),
                            child: Text(
                              "PoPo 입장",
                              style:
                                  TextStyle(color: Colors.white, fontSize: 24),
                            ),
                          ),
                        ),
                      ),
                    ],
                  ),
                ),
                IconButton(
                  onPressed: () {
                    _introKey.currentState?.next();
                  },
                  icon: Visibility(
                    visible: false,
                    child: SvgPicture.asset(
                      'assets/icons/ic_right_purple.svg',
                    ),
                  ),
                ),
              ],
            )
          ],
        ));
  }

  void goHomepage() async {
    LocalPrefProvider().setShowOnBoarding(false);
    Navigator.of(context).pushReplacement(
      MaterialPageRoute(builder: (_) => const MainScreen()),
    );
  }

  Future<bool> permission() async {
    await [Permission.camera, Permission.storage].request();

    if (await Permission.camera.isGranted &&
        await Permission.storage.isGranted) {
      goHomepage();
      return Future.value(true);
    } else {
      Fluttertoast.showToast(msg: '포포 스테이지를 즐기기 위해 권한을 설정해주세요.');
      return Future.value(false);
    }
  }
}

 

완성 화면은 다음과 같다.

상단에 스킵버튼 + 이미지
스킵버튼 없는 마지막 페이지 + svg

 


참고

반응형
Comments