[UIKit] Launch & Onboarding 화면 구현 리뷰 (Feat. 음성메모앱)

2025. 5. 5. 10:00·iOS 개발/UIKit

안녕하세요! 오늘부터는 패스트캠퍼스의 네카라쿠배 iOS 앱 초격차 패키지 Online 강의 중, N사 음성메모앱 파트를 UIKit으로 구현한 내용을 리뷰해보려고 합니다.

🛠 강의는 SwiftUI 기반으로 진행되었지만, 저는 UIKit과 SwiftUI 모두 연습하고 싶어 UIKit으로 먼저 구현해보고, 이후에는 피그마만 참고하여 SwiftUI 버전도 따로 제작할 예정입니다!

🎯구현 목표

오늘은 앱 시작 시 보여지는 Launch 및 온보딩 화면을 UIKit으로 어떻게 구성했는지 리뷰합니다.

화면 흐름은 다음과 같습니다:

  1. 앱 실행
  2. ViewController 로드
    • 타이틀/서브타이틀 세팅
    • OnboardPageViewController 연결
  3. OnboardPageViewController는 4개의 온보딩 페이지 준비
  4. 사용자는 스와이프하거나 dot을 클릭하여 페이지 이동
  5. 페이지가 이동될 때마다:
    • 현재 페이지 인덱스 전달됨
    • 타이틀, 서브타이틀, 페이지 인디케이터 업데이트
    • 마지막 페이지면 버튼 보이기
  6. 마지막 페이지에서 “시작하기” 버튼 클릭
  7. → MainViewController로 화면 전환

🚀 온보딩 화면 흐름에 맞춘 코드 설명

1. 앱이 실행되면 가장 먼저 ViewController가 로드

override func viewDidLoad() {
    super.viewDidLoad()
    
    titleLabel.text = titleModel.mock[0].title
    subTitleLabel.text = titleModel.mock[0].subTitle

    onboardPageControl.pageIndicatorTintColor = UIColor.gray2
    onboardPageControl.currentPageIndicatorTintColor = UIColor.key

    button.isHidden = true
}
  • 타이틀/서브타이틀을 첫 번째 온보딩 페이지 내용으로 초기화
  • UIPageControl 색상 설정
  • 아직 마지막 페이지가 아니므로 “시작하기” 버튼은 숨김 처리

 

2. 내부에 포함된 OnboardPageViewController가 세팅

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    if let desinationViewController = segue.destination as? OnboardPageViewController {
        onboardPageViewController = desinationViewController
        onboardPageViewController.onboardDelegate = self
    }
}

  • ViewController 안에 embed된 OnboardPageViewController와 delegate 연결
  • 이후 페이지가 바뀔 때 마다 OnboardPageViewController → ViewController로 index를 전달

 

3. OnboardPageViewController가 로드되면서 페이지 구성 시작

override func viewDidLoad() {
    super.viewDidLoad()
    self.dataSource = self
    self.delegate = self

    let storyBoard = UIStoryboard(name: "Onboarding", bundle: nil)
    contentPageViewControllerList = [
        storyBoard.instantiateViewController(withIdentifier: "First"),
        storyBoard.instantiateViewController(withIdentifier: "Second"),
        storyBoard.instantiateViewController(withIdentifier: "Third"),
        storyBoard.instantiateViewController(withIdentifier: "Fourth")
    ]

    onboardDelegate?.numberOfPage(numberOfPage: contentPageViewControllerList.count)

    setViewControllers([contentPageViewControllerList[0]], direction: .forward, animated: false, completion: nil)
}

  • 온보딩 화면 4개를 storyboard에서 불러와 배열로 저장
  • 첫 번째 화면을 현재 페이지로 설정
  • ViewController에게 페이지 수 알려주기 (→ UIPageControl dot 수 세팅)

 

4. 페이지 전환 관련 동작

4-1)사용자 스와이프 → 다음/이전 페이지 로드

extension OnboardPageViewController: UIPageViewControllerDataSource {
    
    //이전 화면으로 스와이프 하면 이전 화면으로 어떤 뷰를 보여줄지 결정해 주는 데이터소스
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        let currentIndex = contentPageViewControllerList.firstIndex(of: viewController)!
        
        if currentIndex == 0{
            return nil
        } else {
            return contentPageViewControllerList[currentIndex - 1]
        }
    }
    
    //다음화면으로 스와이프하면 다음화면으로 어떤 뷰를 보여줄지 결정해주는 데이터 소스
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        let currentIndex = contentPageViewControllerList.firstIndex(of: viewController)!
        
        if currentIndex == contentPageViewControllerList.count - 1{
            return nil
        } else {
            return contentPageViewControllerList[currentIndex + 1]
        }
    }
}
  • 사용자가 좌우 스와이프하면 앞/뒤 페이지를 넘겨주는 역할
    • 현재가 첫 번째 페이지라면 이전 페이지가 없기 때문에 nil 반환 → 더 이상 왼쪽으로 못 감, 아니라면, 이전(index - 1) ViewController를 반환
    • 마지막 페이지라면 다음 페이지가 없기 때문에 nil 반환 → 더 이상 오른쪽으로 못 감, 아니라면 다음 페이지 반환

4-2) 사용자가 dot을 직접 탭하면

@IBAction func pageControlTapped(_ sender: Any) {
    let currentPageIndex = onboardPageControl.currentPage
    onboardPageViewController.goToPage(index: currentPageIndex)
    updatePageControlUI(currentPageIndex: onboardPageControl.currentPage)
    updateLabels(for: onboardPageControl.currentPage)
    visibleButton(index: onboardPageControl.currentPage)
}
  • 한 페이지 index로 goToPage() 함수 호출 → 페이지 강제 전환
  • UI도 직접 업데이트: 인디케이터, 타이틀, 버튼 상태
func goToPage(index: Int){
    let currentViewController = viewControllers!.first!
    let currentViewControllerIndex = contentPageViewControllerList.firstIndex(of: currentViewController)!

    let direction: NavigationDirection = index > currentViewControllerIndex ? .forward : .reverse

    onboardDelegate?.numberOfPage(numberOfPage: contentPageViewControllerList.count)
    setViewControllers([contentPageViewControllerList[index]], direction: direction, animated: false, completion: nil)
}
  • 사용자가 UIPageControl을 눌렀을 때 실행됨
  • 현재 화면이 몇 번째 페이지인지 파악하고, 이동하려는 페이지와 비교해 방향(.forward or .reverse) 결정
  • 해당 인덱스의 화면으로 이동시켜줌
❗️주의: 여기서 viewControllers?.first는 항상 첫 번째 온보딩 화면이 아니라, 현재 보여지고 있는 화면을 의미합니다!

 

5. 페이지 이동이 끝나면 ViewController에 현재 index 전달

extension OnboardPageViewController: UIPageViewControllerDelegate {
    
    //didFinishAnimating 페이지 이동 움직임이 끝났을 때 실행해 줄 것을 설정해 주는 것이다.
    func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
        if let currentPageViewController = pageViewController.viewControllers?.first {
            let index = contentPageViewControllerList.firstIndex(of: currentPageViewController)!
            onboardDelegate?.pageChangedTo(index: index)
        }
        
    }
}
  • 스와이프 애니메이션이 끝났을 때 실행됨
  • 현재 보여지는 페이지 index를 계산해 delegate에 전달

→ ViewController의 아래 함수가 호출됨:

func pageChangedTo(index: Int) {
    updatePageControlUI(currentPageIndex: index)
    onboardPageControl.currentPage = index
    updateLabels(for: index)
    visibleButton(index: index)
}
  • 페이지 인디케이터 업데이트
  • 타이틀/서브타이틀 변경
  • 버튼을 보여줄지 숨길지 결정

6. 마지막 페이지에서 “시작하기” 버튼 노출

func visibleButton(index: Int){
    if index == 3 {
        button.isHidden = false
        button.semanticContentAttribute = .forceRightToLeft
        button.tintColor = .key
    } else {
        button.isHidden = true
    }
}
  • index가 3 (마지막 페이지)일 경우 버튼을 보여줌
  • 버튼 안의 이미지 방향을 오른쪽으로 강제 설정

7. 버튼 클릭 → 메인 화면 진입

@IBAction func buttonTapped(_ sender: Any) {
    let storyboard = UIStoryboard(name: "Main", bundle: nil)
    let viewController = storyboard.instantiateViewController(identifier: "MainViewController") as! MainViewController
    viewController.modalPresentationStyle = .fullScreen
    present(viewController, animated: true)
}
  • 시작하기 버튼 클릭시 온보딩을 마치고 MainViewController로 화면 전환
  • 앱의 실질적인 기능 화면 진입!

💻전체 코드

//
//  ViewController.swift
//  N_VoiceMemoApp_UIKit
//
//  Created by wodnd on 5/1/25.
//
import UIKit

struct titleModel: Hashable {
    var title: String
    var subTitle: String
}

extension titleModel {
    static let mock: [titleModel] = [
        titleModel(title: "오늘의 할일", subTitle: "To do list로 언제 어디서든 해야할일을 한눈에"),
        titleModel(title: "똑똑한 나만의 기록장", subTitle: "메모장으로 생각나는 기록은 언제든지"),
        titleModel(title: "하나라도 놓치지 않도록", subTitle: "음성메모 기능으로 놓치고 싶지않은 기록까지"),
        titleModel(title: "정확한 시간의 경과", subTitle: "타이머 기능으로 원하는 시간을 확인")
    ]
}

class ViewController: UIViewController {
    
    @IBOutlet weak var onboardPageControl: UIPageControl!
    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var subTitleLabel: UILabel!
    @IBOutlet weak var button: UIButton!
    
    var onboardPageViewController: OnboardPageViewController!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        titleLabel.text = titleModel.mock[0].title
        subTitleLabel.text = titleModel.mock[0].subTitle
        
        onboardPageControl.pageIndicatorTintColor = UIColor.gray2
        onboardPageControl.currentPageIndicatorTintColor = UIColor.key
        
        button.isHidden = true
    }
    
    override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
        if let desinationViewController = segue.destination as? OnboardPageViewController {
            onboardPageViewController = desinationViewController
            onboardPageViewController.onboardDelegate = self
        }
    }
    
    func updatePageControlUI(currentPageIndex: Int) {
        onboardPageControl.pageIndicatorTintColor = UIColor.gray2
        onboardPageControl.currentPageIndicatorTintColor = UIColor.key
    }
    
    func updateLabels(for index: Int) {
        guard index < titleModel.mock.count else { return }
        titleLabel.text = titleModel.mock[index].title
        subTitleLabel.text = titleModel.mock[index].subTitle
    }
    
    func visibleButton(index: Int){
        if index == 3 {
            button.isHidden = false
            button.semanticContentAttribute = .forceRightToLeft //버튼 image 강제로 오른쪽으로 옮기기
            button.tintColor = .key
        } else {
            button.isHidden = true
        }
    }
    
    @IBAction func pageControlTapped(_ sender: Any) {
        let currentPageIndex = onboardPageControl.currentPage
        onboardPageViewController.goToPage(index: currentPageIndex)
        updatePageControlUI(currentPageIndex: onboardPageControl.currentPage)
        updateLabels(for: onboardPageControl.currentPage)
        visibleButton(index: onboardPageControl.currentPage)
    }
    
    @IBAction func buttonTapped(_ sender: Any) {
        
        let storyboard = UIStoryboard(name: "Main", bundle: nil)
        let viewController = storyboard.instantiateViewController(identifier: "MainViewController") as! MainViewController
        
        viewController.modalPresentationStyle = .fullScreen
        present(viewController, animated: true)
    }
}


extension ViewController: OnboardPageControlDelegate{
    func numberOfPage(numberOfPage: Int) {
        onboardPageControl.numberOfPages = numberOfPage
    }
    
    func pageChangedTo(index: Int) {
        updatePageControlUI(currentPageIndex: index)
        onboardPageControl.currentPage = index
        updateLabels(for: index)
        visibleButton(index: index)
    }
    
    
}

protocol OnboardPageControlDelegate: AnyObject{
    func numberOfPage(numberOfPage: Int)
    func pageChangedTo(index: Int)
}
//
//  OnboardPageViewController.swift
//  N_VoiceMemoApp_UIKit
//
//  Created by wodnd on 5/1/25.
//
import UIKit

class OnboardPageViewController: UIPageViewController {
    
    var contentPageViewControllerList = [UIViewController]()
    weak var onboardDelegate: OnboardPageControlDelegate?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.dataSource = self
        self.delegate = self
        
        let storyBoard = UIStoryboard(name: "Onboarding", bundle: nil)
        
        contentPageViewControllerList = [
            storyBoard.instantiateViewController(withIdentifier: "First"),
            storyBoard.instantiateViewController(withIdentifier: "Second"),
            storyBoard.instantiateViewController(withIdentifier: "Third"),
            storyBoard.instantiateViewController(withIdentifier: "Fourth")
        ]
        
        onboardDelegate?.numberOfPage(numberOfPage: contentPageViewControllerList.count)
        setViewControllers([contentPageViewControllerList[0]], direction: .forward, animated: false, completion: nil)
    }
    
    func goToPage(index: Int){
        let currentViewController = viewControllers!.first!
        let currentViewControllerIndex = contentPageViewControllerList.firstIndex(of: currentViewController)!
        
        let direction: NavigationDirection = index > currentViewControllerIndex ? .forward : .reverse
        
        onboardDelegate?.numberOfPage(numberOfPage: contentPageViewControllerList.count)
        setViewControllers([contentPageViewControllerList[index]], direction: direction, animated: false, completion: nil)
    }
}

extension OnboardPageViewController: UIPageViewControllerDataSource {
    
    //이전 화면으로 스와이프 하면 이전 화면으로 어떤 뷰를 보여줄지 결정해 주는 데이터소스
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
        let currentIndex = contentPageViewControllerList.firstIndex(of: viewController)!
        
        if currentIndex == 0{
            return nil
        } else {
            return contentPageViewControllerList[currentIndex - 1]
        }
    }
    
    //다음화면으로 스와이프하면 다음화면으로 어떤 뷰를 보여줄지 결정해주는 데이터 소스
    func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
        let currentIndex = contentPageViewControllerList.firstIndex(of: viewController)!
        
        if currentIndex == contentPageViewControllerList.count - 1{
            return nil
        } else {
            return contentPageViewControllerList[currentIndex + 1]
        }
    }
}

extension OnboardPageViewController: UIPageViewControllerDelegate {
    
    //didFinishAnimating 페이지 이동 움직임이 끝났을 때 실행해 줄 것을 설정해 주는 것이다.
    func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
        if let currentPageViewController = pageViewController.viewControllers?.first {
            let index = contentPageViewControllerList.firstIndex(of: currentPageViewController)!
            onboardDelegate?.pageChangedTo(index: index)
        }
        
    }
}

🎬 온보딩 화면 구현 영상

 

 


🧩 마무리

이번 포스팅에서는 UIKit을 활용해 UIPageViewController 기반의 온보딩 화면을 직접 구현해보았습니다.

기능적인 흐름뿐만 아니라, 사용자의 동작에 따라 어떻게 ViewController 간의 연동과 UI 업데이트가 이뤄지는지도 정리해보며 UIKit 구조에 대한 이해를 한층 더 깊게 할 수 있는 시간이었습니다.

 

다음 글에서는 메인 화면 구현에 들어갈 예정입니다.

온보딩 이후 실제 기능으로 이어지는 첫 화면을 어떻게 설계하고 구성할지, 기대해주세요!

 

궁금한 점이나 피드백은 언제든 댓글로 남겨주세요! 부족한 내용이지만 끝까지 읽어봐주셔서 감사합니다. 
오늘도 개발자를 위한 명언으로 마무리하도록 하겠습니다! 파이팅입니다~

Developers are problem solvers and discoverers.

"개발자는 해결사이자 발견자이다. (마이클 페더스)

'iOS 개발 > UIKit' 카테고리의 다른 글

[UIKit] 설정 탭 리뷰 (Feat. 나만의 Todo)  (1) 2025.05.20
[UIKit] Timer 기능 리뷰 (Feat. 나만의 Todo)  (0) 2025.05.16
[UIKit] 녹음 기능 리뷰 (Feat. 나만의 Todo)  (0) 2025.05.12
[UIKit] 메모 기능 구현 리뷰 (Feat. 나만의 Todo)  (1) 2025.05.09
[UIKit] Todo 기능 구현 리뷰 (Feat. 나만의 Todo)  (0) 2025.05.07
'iOS 개발/UIKit' 카테고리의 다른 글
  • [UIKit] Timer 기능 리뷰 (Feat. 나만의 Todo)
  • [UIKit] 녹음 기능 리뷰 (Feat. 나만의 Todo)
  • [UIKit] 메모 기능 구현 리뷰 (Feat. 나만의 Todo)
  • [UIKit] Todo 기능 구현 리뷰 (Feat. 나만의 Todo)
Riu
Riu
안녕하세요 iOS 개발자를 꿈꾸는 Riu입니다. Github: woolnd
  • Riu
    Riu 개발노트
    Riu
  • 전체
    오늘
    어제
    • 분류 전체보기 (27) N
      • 티스토리 (2)
      • iOS 개발 (21) N
        • SwiftUI (9)
        • UIKit (6)
        • Combine (5)
        • Architecture (1) N
      • 알고리즘 (1)
      • 회고록 (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • Github
  • 공지사항

  • 인기 글

  • 태그

    회고록
    UIKit
    앗차!
    ios개발
    구름톤유니브
    나만의todo
    안드로이드
    SWIFT
    Architecture
    Combine
    cleanArchitecture
    티스토리
    SWIF
    ios
    SwiftUI
    알고리즘
    막자알림서비스
    figma
    시작
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.0
Riu
[UIKit] Launch & Onboarding 화면 구현 리뷰 (Feat. 음성메모앱)
상단으로

티스토리툴바