안녕하세요! 오늘부터는 패스트캠퍼스의 네카라쿠배 iOS 앱 초격차 패키지 Online 강의 중, N사 음성메모앱 파트를 UIKit으로 구현한 내용을 리뷰해보려고 합니다.
🛠 강의는 SwiftUI 기반으로 진행되었지만, 저는 UIKit과 SwiftUI 모두 연습하고 싶어 UIKit으로 먼저 구현해보고, 이후에는 피그마만 참고하여 SwiftUI 버전도 따로 제작할 예정입니다!
🎯구현 목표
오늘은 앱 시작 시 보여지는 Launch 및 온보딩 화면을 UIKit으로 어떻게 구성했는지 리뷰합니다.
화면 흐름은 다음과 같습니다:
- 앱 실행
- ViewController 로드
- 타이틀/서브타이틀 세팅
- OnboardPageViewController 연결
- OnboardPageViewController는 4개의 온보딩 페이지 준비
- 사용자는 스와이프하거나 dot을 클릭하여 페이지 이동
- 페이지가 이동될 때마다:
- 현재 페이지 인덱스 전달됨
- 타이틀, 서브타이틀, 페이지 인디케이터 업데이트
- 마지막 페이지면 버튼 보이기
- 마지막 페이지에서 “시작하기” 버튼 클릭
- → 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 |