Danny의 iOS 컨닝페이퍼
article thumbnail

[TIL #37] 2023 / 07 / 18

앱을 처음 시작할 때, 나타나는 튜토리얼 화면을 만들어 보려 합니다.

 

보통 튜토리얼 화면을 만드는 방법으로는

UIScrolView 또는 UIPageViewController를 사용하여 만들 수 있습니다.

 

여기서 만약, 그냥 UIScrolView를 통해 만들게 되면, 기능을 구현하는 방식은 비슷하지만

레이아웃을 잡을 때, 생각보다 골치 아프더라고요.

(스크롤 가능한 콘텐츠를 보여주기 때문에, 콘텐츠의 크기, 위치, 스크롤 영역 등을 정확한 설계가 필요)

 

그리고 UIScrolView는 보이는 영역(View Port)을 제외하고도

모든 페이지들이 메모리에 올라가기 때문에, 많은 페이지를 보여줄 때는 비효율 적일 수 있습니다.

 

예를 들어, [page1, page2, page3]와 같은 페이지 배열이 있다면,

 

UIScrolView는 초기에 page1을 보여주지만, page2, page3들도 미리 메모리에 올라갑니다.

그러므로, 많은 페이지를 보여줄수록 메모리 사용량이 증가하고 초기 로딩 속도에 영향을 주겠죠?!

 

그래서, UIScrolView 보다 만들기 간단하고 페이지들을 쉽게 관리할 수 있는

UIPageViewController의 사용 방법을 알아봅시다.

 

 

UIPageViewController

간단히, 사용 방법만 적어보려 합니다.

 

처음 실행 시, SceneDelegate에서 UserDefaults를 통해

튜토리얼 화면을 보여줄 여부를 체크하고 실행하게 만들었습니다.

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        
        guard let windowScene = (scene as? UIWindowScene) else { return }
        let window = UIWindow(windowScene: windowScene)
        
        let startVC = StartViewController()

        window.rootViewController = startVC
        window.makeKeyAndVisible()
        
        self.window = window
        
        // 튜토리얼 컨트롤러를 실행할 여부를 체크
        startVC.checkTutorialRun()
    }
}
class StartViewController: UIViewController {

    func checkTutorialRun() {
        let userDefault = UserDefaults.standard
        if userDefault.bool(forKey: "Tutorial") == false {
            let tutorialVC = TutorialViewController(transitionStyle: .scroll, navigationOrientation: .horizontal)
            tutorialVC.modalPresentationStyle = .fullScreen
            present(tutorialVC, animated: false)
        }
    }
}

 

먼저, UIPageViewController에 사용될 페이지를 만들어 봅시다.

 

일단, UIPageViewController는 각각의 페이지들은 UIViewController로 만들어 사용됩니다.

그래서 각 페이지에서 UIViewController의 모든 기능들도 사용할 수 있죠.

(단순 이미지 뿐만 아니라, 상호작용도 가능하게 만들 수 있습니다)

 

일단, UIPageViewController에 사용될 페이지를 만들어 봅시다.

(생성자로 간단히 재사용할 수 있게 만들어 줬습니다)

class PageContentsViewController: UIViewController {
    
    private var stackView: UIStackView!
    
    private var imageView = UIImageView()
    private var titleLabel = UILabel()
    private var subTitleLabel = UILabel()

    override func viewDidLoad() {
        super.viewDidLoad()

        setupUI()
        setupLayout()
    }
    
    init(imageName: String, title: String, subTitle: String) {
        super.init(nibName: nil, bundle: nil)
        imageView.image = UIImage(named: imageName)
        titleLabel.text = title
        subTitleLabel.text = subTitle
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupUI() {
        view.backgroundColor = .systemBackground
        
        imageView.contentMode = .scaleAspectFill
        
        titleLabel.font = .preferredFont(forTextStyle: .title1)
        
        subTitleLabel.font = .preferredFont(forTextStyle: .body)
        subTitleLabel.textAlignment = .center
        subTitleLabel.numberOfLines = 0
        
        self.stackView = UIStackView(arrangedSubviews: [imageView, titleLabel, subTitleLabel])
        stackView.axis = .vertical
        stackView.spacing = 20
        stackView.alignment = .center
    }
    
    private func setupLayout() {
        view.addSubview(stackView)
        imageView.snp.makeConstraints {
            $0.width.height.equalTo(view).multipliedBy(0.6)
        }
        
        stackView.snp.makeConstraints {
            $0.center.equalToSuperview()
            $0.width.equalToSuperview().inset(50)
        }
    }
}

 

페이지를 만들어 줬으니, 이제 UIPageViewController를 구현해 봅시다.

 

⭐️ 여기서, 가장 중요한 건 UIPageViewController의 dataSource 설정입니다. ⭐️

화면에 보여질 뷰컨트롤러들을 관리 및 인디케이터(UIPageControl) 관련 설정할 수 있습니다.

class TutorialViewController: UIPageViewController {
 
    private var pages = [UIViewController]()
    private var initialPage = 0
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupPage()
        setupUI()
    }
    
    private func setupPage() {
        let page1 = PageContentsViewController(imageName: "page1",
                                               title: "검색",
                                               subTitle: "여기서 지역을 검색할 수 있습니다. 자유롭게 검색해보세요")
        let page2 = PageContentsViewController(imageName: "page2",
                                               title: "지도",
                                               subTitle: "지도를 통해 검색된 위치를 확인해보세요")
        let page3 = PageContentsViewController(imageName: "page3",
                                               title: "설정창",
                                               subTitle: "여기는 설정창입니다")
        pages.append(page1)
        pages.append(page2)
        pages.append(page3)
    }
    
    private func setupUI() {
        // ⭐️ dataSource 화면에 보여질 뷰컨트롤러들을 관리합니다 ⭐️
        self.dataSource = self
        // UIPageViewController에서 처음 보여질 뷰컨트롤러 설정 (첫 번째 page)
        self.setViewControllers([pages[initialPage]], direction: .forward, animated: true)
}

// MARK: - DataSource

extension TutorialViewController: UIPageViewControllerDataSource {
    // 이전 뷰컨트롤러를 리턴 (우측 -> 좌측 슬라이드 제스쳐)
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        // 현재 VC의 인덱스를 구합니다.
        guard let currentIndex = pages.firstIndex(of: viewController) else { return nil }
        
        guard currentIndex > 0 else { return nil }
        return pages[currentIndex - 1]
    }
    
    // 다음 보여질 뷰컨트롤러를 리턴 (좌측 -> 우측 슬라이드 제스쳐)
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        guard let currentIndex = pages.firstIndex(of: viewController) else { return nil }
        
        guard currentIndex < (pages.count - 1) else { return nil }
        return pages[currentIndex + 1]
    }
}

 

슬라이드로 페이지들이 넘어가긴 하는데, 화면이 너무 심심하죠?!

 

그럼 바로, 인디케이터(UIPageControl)를 추가해 봅시다.

 

 

인디케이터(UIPageControl) 추가

인디케이터를 추가하는 방법은 2가지 방법이 있습니다.

 

1. dataSource를 통해 추가

2. 직접 UIPageControl을 구현해서 추가

 

 

dataSource를 사용

먼저, 간편히 사용할 수 있는 dataSource를 통해 추가하는 방법으로 만들어 봅시다.

 

이렇게 dataSource에 적용해주기만 하면, UIPageControl이 자동으로 생성되고 동작합니다.

extension TutorialViewController: UIPageViewControllerDataSource {

    // 인디케이터(pageControl)의 총 개수
    func presentationCount(for pageViewController: UIPageViewController) -> Int {
        return pages.count
    }

    // 인디케이터(pageControl)에 반영할 값 (pageControl.currentPage라고 생각하면 된다)
    func presentationIndex(for pageViewController: UIPageViewController) -> Int {
        guard let viewController = pageViewController.viewControllers?.first,
              let currentIndex = pages.firstIndex(of: viewController) else { return 0 }

        return currentIndex
    }
}

 

대신, 이 방법에 문제가 살짝 있는데...

UIPageViewController의 내부의 UIPageControl 속성이 없어, 디자인 변경이 조금 까다롭습니다.

 

일단, 이런 방법으로 디자인을 변경할 수 있습니다.

(appearance 이용, subView 이용)

 

 

1. appearance를 사용하여, 앱 전역에서 Style 적용시키기

 

이 방법을 사용할 때는 UIPageControl 스타일들이 앱 전역에서 변경되므로,

생각해서 사용해야 합니다.

(여기선, 크기 조절하는 방법은 도저히 모르겠네요)

class TutorialViewController: UIPageViewController {

    private var pages = [UIViewController]()
    private var initialPage = 0
    
    private var pageControl: UIPageControl!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupPage()
        setupUI()
    }

    private func setupUI() {
        // ⭐️ dataSource 화면에 보여질 뷰컨트롤러들을 관리합니다 ⭐️
        self.dataSource = self
        // UIPageViewController에서 처음 보여질 뷰컨트롤러 설정 (첫 번째 page)
        self.setViewControllers([pages[initialPage]], direction: .forward, animated: true)
        
        // UIPageViewController 속성에 UIPageControl가 없으므로 디자인을 바꾸려면, 아래와 같은 방법으로 바꿔줘야 한다.
        // 방법 1. UIPageControl의 전역으로 UI 설정 (앱에서 사용되는 모든 UIPageControl의 UI를 바꾼다면, appearance로 사용해도 됨)
        pageControl = UIPageControl.appearance()
        pageControl.currentPageIndicatorTintColor = .red
        pageControl.pageIndicatorTintColor = .lightGray
        pageControl.backgroundColor = .systemBackground
    }
}

 

 

 

2. 서브뷰로 접근하여, Style 변경하기

 

요건, 라이프 사이클 중

레이아웃 조절하는 시점인 viewDidLayoutSubviews에서 설정해 주면, 좋을 것 같습니다.

override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    
    for view in view.subviews {
        if view is UIScrollView {
            // UIPageViewController의 뷰를 끝까지 확장 (UIPageViewController 내부는 UIScrollView로 구성 됨)
            view.frame = UIScreen.main.bounds
            
        } else if view is UIPageControl {
            let pageControl = (view as? UIPageControl)
            pageControl?.isUserInteractionEnabled = false
            pageControl?.currentPageIndicatorTintColor = .red
            pageControl?.pageIndicatorTintColor = .lightGray
            pageControl?.backgroundColor = .systemBackground
        }
    }
}

 

 

직접 만들기

간단히 사용하기엔, dataSource를 통해 사용하는게 편하긴 한데,

크기, 위치 및 스타일을 조절하기가 힘들더라고요.

 

그래서, 그냥 직접 UIPageControl를 만들어 사용하는 방법도 올려봐요.

코드가 살짝 추가 되지만, 저 생각엔 이 방법이 가장 좋은 것 같습니다.

(dataSource의 인디케이터 부분을 생략해야 함)

class TutorialViewController: UIPageViewController {
    
    private var pages = [UIViewController]()
    private var initialPage = 0
    
    private var pageControl: UIPageControl!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupPage()
        setupUI()
        setupLayout()
    }
    
    private func setupUI() {
        // ⭐️ dataSource 화면에 보여질 뷰컨트롤러들을 관리합니다 ⭐️
        self.dataSource = self
        // delegate를 통해 pageControl과 싱크를 맞춤
        self.delegate = self
        // UIPageViewController에서 처음 보여질 뷰컨트롤러 설정 (첫 번째 page)
        self.setViewControllers([pages[initialPage]], direction: .forward, animated: true)
        
        // UIPageControl UI 설정
        pageControl = UIPageControl()
        pageControl.numberOfPages = pages.count
        pageControl.currentPageIndicatorTintColor = .red
        pageControl.pageIndicatorTintColor = .lightGray
        pageControl.backgroundColor = .systemBackground
        pageControl.addTarget(self, action: #selector(pageControlHandler), for: .valueChanged)
    }
    
    private func setupLayout() {
        
        view.addSubview(pageControl)
        pageControl.snp.makeConstraints {
            $0.bottom.equalTo(view.safeAreaLayoutGuide)
            $0.centerX.equalToSuperview()
        }
    }
}

// MARK: - Delegate

extension TutorialViewController: UIPageViewControllerDelegate {
    
    // UIPageViewController의 델리게이트를 사용해 UIPageControl의 현재 페이지를 업데이트 시킴
    func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {

        guard let viewControllers = pageViewController.viewControllers,
              let currentIndex = pages.firstIndex(of: viewControllers[0]) else { return }

        pageControl.currentPage = currentIndex
    }
}

// MARK: - Action

extension TutorialViewController {
    
    // 페이지 컨트롤을 움직이면, 페이지를 표시 해줌
    @objc func pageControlHandler(_ sender: UIPageControl) {
        guard let currnetViewController = viewControllers?.first,
              let currnetIndex = pages.firstIndex(of: currnetViewController) else { return }
        
        // 코드의 순서 상 페이지의 인덱스보다 pageControl의 값이 먼저 변한다.
        // 그러므로, currentPage가 크면 오른쪽 방향, 작으면 왼쪽 방향으로 움직이게 설정해 줌
        let direction: UIPageViewController.NavigationDirection = (sender.currentPage > currnetIndex) ? .forward : .reverse
        self.setViewControllers([pages[sender.currentPage]], direction: direction, animated: true)
    }
}

 

자, 기본적인 페이지 기능들은 다 만들어 줬습니다.

 

마지막으로 슬라이드를 귀찮아 하시는 분들을 위해, 버튼도 생성해봅시다.

 

 

버튼 추가

버튼에는 페이지 스크롤 기능이 없으므로, 페이지 이동하는 메서드를 직접 구현 해줘야 합니다.

class TutorialViewController: UIPageViewController {

    private var skipButton: UIButton!
    private var nextButton: UIButton!
    
    private var pages = [UIViewController]()
    private var initialPage = 0
    
    private var pageControl: UIPageControl!

    
    override func viewDidLoad() {
        super.viewDidLoad()

        setupPage()
        setupUI()
        setupLayout()
    }
 
    private func setupUI() {
        // 버튼 UI 설정
        skipButton = UIButton()
        skipButton.setTitle("Skip", for: .normal)
        skipButton.setTitleColor(.systemBlue, for: .normal)
        skipButton.addTarget(self, action: #selector(buttonHandler), for: .touchUpInside)
        
        nextButton = UIButton()
        nextButton.setTitle("Next", for: .normal)
        nextButton.setTitleColor(.systemBlue, for: .normal)
        nextButton.addTarget(self, action: #selector(buttonHandler), for: .touchUpInside)
    }
    
    private func setupLayout() {
        view.addSubview(skipButton)
        skipButton.snp.makeConstraints {
            $0.top.equalTo(view.safeAreaLayoutGuide)
            $0.leading.equalToSuperview().offset(20)
        }
        
        view.addSubview(nextButton)
        nextButton.snp.makeConstraints {
            $0.top.equalTo(view.safeAreaLayoutGuide)
            $0.trailing.equalToSuperview().offset(-20)

        }
    }
}

// MARK: - Action

extension TutorialViewController {
    
    @objc func buttonHandler(_ sender: UIButton) {
        switch sender.currentTitle {
        case "Skip":
            UserDefaults.standard.set(true, forKey: "Tutorial")
            dismiss(animated: true)
        case "Next":
            goToNextPage()
            pageControl.currentPage += 1

        default: break
        }
    }
}

// MARK: - Extension

extension TutorialViewController {
    // 다음 페이지로 이동하기
    func goToNextPage() {
        // UIPageViewController에는
        guard let currentPage = viewControllers?[0],
              let nextPage = self.dataSource?.pageViewController(self, viewControllerAfter: currentPage) else { return }
        
        self.setViewControllers([nextPage], direction: .forward, animated: true)
    }
    
    // 이전 페이지로 이동하기
    func goToPreviousPage() {
        guard let currentPage = viewControllers?[0],
              let previousPage = self.dataSource?.pageViewController(self, viewControllerAfter: currentPage) else { return }
        
        self.setViewControllers([previousPage], direction: .reverse, animated: true)
    }
    
    // 특정 페이지로 이동하기
    func goToSpecificPage(index: Int, ofViewControllers pages: [UIViewController]) {
        setViewControllers([pages[index]], direction: .forward, animated: true, completion: nil)
    }
}

 

 

마무리

정리하다 보니 코드가 조금씩 생략 됐는데,

헷갈리시는 분들은 전체 코드 를 참고해 주세요.

 

 

 

반응형
profile

Danny의 iOS 컨닝페이퍼

@Danny's iOS

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!