728x90
SMALL
안녕하세요! iOS 앱 개발을 시작하는 분이라면 한 번쯤 '나만의 탭바'를 만들어보고 싶다는 생각을 해보셨을 텐데요. 오늘 이 글에서는 기본 탭바 컨트롤러(UITabBarController) 대신, UIView를 활용하여 완전히 커스터마이징 가능한 탭바를 구현하는 방법을 쉽고 명확하게 알려드릴게요. 이 가이드를 따라 하면 누구나 멋진 커스텀 탭바를 만들 수 있습니다. 🚀
프로젝트 설계 핵심 로직
우리가 만들 커스텀 탭바는 다음과 같은 두 가지 핵심 원칙을 기반으로 설계되었습니다.
- 역할 분리 (Separation of Concerns):
- CustomTabBar: 탭바의 시각적 UI와 사용자 인터랙션(버튼 탭)만을 담당합니다. 어떤 화면으로 전환할지 같은 복잡한 로직은 모릅니다.
- MainTabBarViewController: 탭바를 담는 컨테이너 역할을 합니다. CustomTabBar에서 전달받은 이벤트에 따라 실제 화면 전환 로직을 실행합니다.
- 통신 패턴:
- 클로저(Closure): CustomTabBar는 버튼이 탭되면 클로저를 통해 MainTabBarViewController에게 "버튼이 눌렸어요!"라고 알립니다.
- 델리게이트(Delegate): AddTransactionViewController와 같은 모달 화면이 닫힐 때, 델리게이트 패턴을 통해 MainTabBarViewController에게 알립니다.
이러한 설계는 코드를 깔끔하게 만들고, 각 컴포넌트의 재사용성을 높여줍니다.
구현 순서
이제 본격적으로 코드를 작성해 볼까요?
1단계: 커스텀 탭바(CustomTabBar) 만들기
UIView를 상속받아 탭바 UI를 구성합니다. 이 뷰는 3개의 버튼과 이 버튼들을 담을 UIStackView로 이루어져 있습니다.
- 중요한 프로퍼티 & 메서드:
- onButtonTapped: ((Int) -> Void)?: 탭바의 버튼이 탭되었을 때, 이 클로저를 통해 버튼의 tag 값을 상위 뷰 컨트롤러에 전달합니다.
- selectButton(withTag:): 외부에서 탭바의 UI 상태를 제어할 수 있는 공개 메서드입니다. MainTabBarViewController에서 이 메서드를 호출하여 탭바의 버튼 색상을 변경합니다.
- updateButtonSelection(_:): 버튼의 색상을 바꾸는 실제 UI 로직을 담고 있는 프라이빗 메서드입니다. didTappedButton과 selectButton에서 모두 이 메서드를 호출하여 코드 중복을 방지합니다.
/// 앱의 메인 화면 하단에 위치하는 커스텀 탭바 뷰입니다.
///
/// 이 뷰는 홈, 추가, 캘린더 버튼을 포함하며, 탭 이벤트 처리를 담당합니다.
/// `MainTabBarViewController`와 같은 상위 컨테이너 뷰 컨트롤러에 의해 사용됩니다.
class CustomTabBar: UIView {
// MARK: - Properties
/// 현재 시각적으로 선택된 상태인 버튼을 추적하는 변수입니다.
/// 이 프로퍼티는 버튼의 색상을 변경하는 등 UI 업데이트에 사용됩니다.
private var selectedButton: UIButton?
/// 버튼이 탭되었을 때 호출되는 클로저입니다.
///
/// 이 클로저를 통해 탭된 버튼의 `tag`가 상위 뷰 컨트롤러로 전달됩니다.
/// 클로저는 `onButtonTapped?(sender.tag)`와 같이 호출됩니다.
var onButtonTapped: ((Int) -> Void)?
/// 탭바에 포함된 모든 버튼 인스턴스를 저장하는 배열입니다.
///
/// 이 배열은 `selectButton(withTag:)` 메서드에서 특정 버튼을 효율적으로 찾기 위해 사용됩니다.
private var buttons: [UIButton] = []
// MARK: - UI Element
/// 탭바의 버튼들을 수평으로 정렬하는 스택뷰입니다.
private let stackView: UIStackView = {
let stack = UIStackView()
stack.axis = .horizontal
stack.distribution = .equalSpacing
stack.alignment = .center
stack.isLayoutMarginsRelativeArrangement = true
return stack
}()
/// 홈 화면으로 이동하는 버튼입니다. 태그는 `0`으로 설정됩니다.
private let homeButton = createButton(systemName: "house.circle")
/// 거래 추가 화면으로 이동하는 버튼입니다. 태그는 `1`로 설정됩니다.
private let addButton = createButton(systemName: "plus.circle")
/// 캘린더 화면으로 이동하는 버튼입니다. 태그는 `2`로 설정됩니다.
private let calendarButton = createButton(systemName: "calendar.circle")
// MARK: - Life Cycle
/// 뷰가 초기화될 때 호출되는 지정 이니셜라이저입니다.
///
/// 이 메서드 내에서 `setupView()`를 호출하여 탭바의 UI를 구성합니다.
override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Public Method
/// 탭바의 특정 버튼을 프로그래밍 방식으로 선택합니다.
///
/// `MainTabBarController`에서 모달 뷰 컨트롤러가 닫힌 후와 같이
/// 탭바의 상태를 외부에서 직접 업데이트해야 할 때 사용됩니다.
/// - Parameter tag: 선택할 버튼의 태그입니다.
public func selectButton(withTag tag: Int) {
// 모든 버튼을 순회하며 태그가 일치하는 버튼을 찾아 UI 업데이트를 수행합니다.
guard let buttonToSelect = buttons.first(where: { $0.tag == tag }) else { return }
updateButtonSelection(buttonToSelect)
}
// MARK: - Private Function
/// 탭바의 UI 구성 및 초기 설정을 담당합니다.
///
/// 이 메서드는 버튼들을 스택뷰에 추가하고, 각 버튼에 액션 메서드를 연결합니다.
/// 또한, 스택뷰에 대한 오토 레이아웃 제약 조건을 활성화합니다.
private func setupView() {
// buttons 배열에 버튼 인스턴스를 추가합니다.
buttons = [homeButton, addButton, calendarButton]
addSubview(stackView)
buttons.forEach { button in
stackView.addArrangedSubview(button)
button.addTarget(self, action: #selector(didTappedButton), for: .touchUpInside)
}
// 각 버튼의 태그를 설정합니다.
homeButton.tag = 0
addButton.tag = 1
calendarButton.tag = 2
stackView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: topAnchor),
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
/// SF Symbols를 사용하여 버튼을 생성하는 유틸리티 메서드입니다.
/// - Parameter systemName: 버튼에 사용할 SF Symbol의 이름입니다.
/// - Returns: 설정된 `UIButton` 인스턴스입니다.
private static func createButton(systemName: String) -> UIButton {
let button = UIButton(type: .system)
let config = UIImage.SymbolConfiguration(pointSize: 20)
let image = UIImage(systemName: systemName, withConfiguration: config)
button.setImage(image, for: .normal)
button.tintColor = .systemGray
return button
}
// MARK: - Action Method
/// 버튼 탭 이벤트를 처리하는 액션 메서드입니다.
///
/// 이 메서드는 사용자가 버튼을 탭했을 때 호출되며,
/// 버튼의 시각적 상태를 업데이트하고, `onButtonTapped` 클로저를 실행합니다.
/// - Parameter sender: 탭된 `UIButton` 인스턴스입니다.
@objc private func didTappedButton(_ sender: UIButton) {
// UI 업데이트를 위한 함수를 호출합니다.
updateButtonSelection(sender)
// 데이터 전달 클로저를 호출합니다.
onButtonTapped?(sender.tag)
}
// MARK: - Private UI Update Logic
/// 버튼의 시각적 선택 상태를 업데이트하는 로직을 담고 있습니다.
///
/// 이 메서드는 이전에 선택된 버튼의 색상을 초기화하고,
/// 새로 선택된 버튼의 색상을 변경하는 역할을 수행합니다.
/// - Parameter sender: 선택 상태를 업데이트할 `UIButton` 인스턴스입니다.
private func updateButtonSelection(_ sender: UIButton) {
// 이전에 선택된 버튼의 상태를 초기화합니다.
if let selectedButton = selectedButton {
selectedButton.tintColor = .systemGray
}
// 새로 선택된 버튼의 색상을 변경합니다.
sender.tintColor = .systemBlue
// 새로운 선택된 버튼을 저장합니다.
selectedButton = sender
}
}
2단계: 메인 탭바 컨트롤러(MainTabBarViewController) 만들기
UIViewController를 상속받는 MainTabBarController 클래스를 생성합니다. 이 클래스는 우리가 만든 CustomTabBar를 포함하고, 화면을 전환하는 로직을 담당합니다.
- 중요한 프로퍼티 & 메서드:
- viewControllers: [UINavigationController]: 탭바를 통해 전환될 모든 화면(MainVC, CalendarVC)을 UINavigationController로 감싸서 저장합니다.
- containerView: UIView: 자식 뷰 컨트롤러의 뷰가 들어갈 컨테이너 뷰입니다.
- selectViewController(at:): 핵심 화면 전환 로직을 담은 메서드입니다. 기존의 뷰 컨트롤러를 제거하고, 새로운 뷰 컨트롤러를 containerView에 추가합니다. 이 과정에서 오토 레이아웃을 사용하여 뷰의 크기를 정확하게 설정하는 것이 중요합니다.
- presentAddViewController(): 모달 뷰(AddTransactionViewController)를 띄우는 메서드입니다. 여기서 addVC.delegate = self를 통해 델리게이트를 설정하여 모달이 닫힐 때 알림을 받도록 합니다.
/// 앱의 메인 화면과 탭바를 관리하는 컨테이너 뷰 컨트롤러입니다.
///
/// 이 클래스는 `CustomTabBar`를 통해 화면을 전환하고, 모달 뷰 컨트롤러가 닫힌 후 상태를 동기화하는 역할을 합니다.
///
/// ## 실행 로직 상세 분석
///
/// 1. **초기화**: `MainTabBarViewController`가 생성될 때, `MainViewController`와 `CalendarViewController`를 각각 `UINavigationController`로 감싸 `viewControllers` 배열에 저장합니다. 이렇게 하면 각 탭 화면이 독립적인 내비게이션 스택을 가질 수 있습니다.
///
/// 2. **레이아웃 및 바인딩 설정**: `viewDidLoad()`에서 `setupLayout()`과 `setupBindings()`를 호출합니다.
/// - `setupLayout()`: `containerView`와 `customTabBar`를 뷰 계층에 추가하고, 오토 레이아웃 제약 조건을 설정하여 화면에 안정적으로 배치합니다.
/// - `setupBindings()`: `customTabBar`의 `onButtonTapped` 클로저에 대한 이벤트 핸들러를 정의하여, 버튼 탭에 따라 화면 전환 로직을 실행하도록 연결합니다.
///
/// 3. **화면 전환**: `selectViewController(at:)` 메서드는 자식 뷰 컨트롤러를 전환하는 핵심 로직을 담당합니다. 이 메서드는 현재 표시 중인 뷰 컨트롤러를 제거한 후, 새로운 뷰 컨트롤러를 자식으로 추가하고 `containerView` 내에 오토 레이아웃으로 배치합니다.
///
/// 4. **모달 뷰 컨트롤러 처리**: '추가' 버튼(태그 1)이 탭되면 `presentAddViewController()`를 호출하여 `AddTransactionViewController`를 모달로 띄웁니다.
/// - `AddTransactionViewController`는 닫힐 때 `AddTransactionDelegate`를 통해 `MainTabBarViewController`에 알립니다.
/// - `didDismissAddTransaction()` 델리게이트 메서드가 호출되면, 화면을 홈(`MainViewController`)으로 전환하고 탭바의 상태를 홈 버튼으로 동기화합니다.
///
/// 이 구조는 각 컴포넌트의 역할을 명확히 분리하여 코드의 유지보수성과 재사용성을 높입니다.
class MainTabBarViewController: UIViewController {
// MARK: - Properties
/// 앱의 트랜잭션 데이터를 관리하는 객체입니다.
private let transactionManager: TransactionManager
/// 현재 `containerView`에 표시되고 있는 자식 뷰 컨트롤러를 추적합니다.
/// 화면 전환 시 이전 뷰 컨트롤러를 제거하고 새로운 뷰 컨트롤러를 추가하는 데 사용됩니다.
private var currentViewController: UIViewController?
/// 탭바에서 전환될 모든 뷰 컨트롤러들을 담고 있는 배열입니다.
/// 각 뷰 컨트롤러는 독립적인 내비게이션 스택을 위해 `UINavigationController`로 감싸져 있습니다.
private let viewControllers: [UINavigationController]
// MARK: - UI Element
/// 자식 뷰 컨트롤러의 뷰가 표시될 공간을 제공하는 뷰입니다.
/// 이 뷰는 커스텀 탭바 위에 위치하며, 전체 화면을 차지합니다.
private let containerView = UIView()
/// 앱 하단에 위치하는 커스텀 탭바 뷰입니다.
/// 사용자의 탭 이벤트를 처리하고 시각적 피드백을 제공합니다.
private let customTabBar = CustomTabBar()
// MARK: - Initialization
/// `MainTabBarViewController`를 초기화합니다.
///
/// - Parameter transactionManager: 앱의 핵심 데이터 관리 로직을 담당하는 객체입니다.
init(transactionManager: TransactionManager) {
// UINavigationController로 감싸지 않고 뷰 컨트롤러 자체만 생성합니다.
let mainVC = UINavigationController(rootViewController: MainViewController(transactionManager: transactionManager))
let calendarVC = UINavigationController(rootViewController: CalendarViewController(transactionManager: transactionManager))
self.viewControllers = [mainVC, calendarVC]
self.transactionManager = transactionManager
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
setupLayout()
setupBindings()
// 초기 화면 설정 (0번 인덱스, 홈 화면)
selectViewController(at: 0)
self.customTabBar.selectButton(withTag: 0)
}
// MARK: - Private Function
/// 뷰의 레이아웃을 설정합니다.
///
/// `containerView`와 `customTabBar`를 뷰 계층에 추가하고, 오토 레이아웃 제약 조건을 활성화합니다.
/// `containerView`는 탭바를 제외한 전체 화면을 차지하도록 설정됩니다.
private func setupLayout() {
let customBackground = UIColor(named: "CustomBackground")
view.backgroundColor = customBackground
//view.backgroundColor = .secondarySystemBackground
// 뷰 추가
view.addSubview(containerView)
view.addSubview(customTabBar)
// 오토레이아웃 설정
containerView.translatesAutoresizingMaskIntoConstraints = false
customTabBar.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
// 컨테이너 뷰는 탭바를 제외한 전체 화면을 차지
containerView.topAnchor.constraint(equalTo: view.topAnchor),
containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
containerView.bottomAnchor.constraint(equalTo: customTabBar.topAnchor),
// 커스텀 탭바는 화면 하단에 위치
customTabBar.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 40),
customTabBar.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -40),
customTabBar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
customTabBar.heightAnchor.constraint(equalToConstant: 60)
])
}
/// UI 요소와 로직을 연결합니다.
///
/// `customTabBar`의 버튼 탭 이벤트에 대한 클로저를 정의하여,
/// 탭이 눌렸을 때 적절한 화면을 전환하거나 모달 뷰를 표시하도록 합니다.
private func setupBindings() {
customTabBar.onButtonTapped = { [weak self] index in
guard let self = self else { return }
if index == 1 {
self.presentAddViewController()
} else {
let viewControllerIndex = (index == 0) ? 0 : 1
self.selectViewController(at: viewControllerIndex)
// ⭐️ 추가됨: 탭이 눌렸을 때도 탭바의 상태를 업데이트합니다.
self.customTabBar.selectButton(withTag: index)
}
}
}
/// 지정된 인덱스에 해당하는 자식 뷰 컨트롤러를 선택하고 화면에 표시합니다.
///
/// 이 메서드는 다음 작업을 순차적으로 수행합니다.
/// 1. `currentViewController`가 존재하면 뷰 계층에서 제거합니다.
/// 2. `viewControllers` 배열에서 새 뷰 컨트롤러를 가져와 `addChild()`를 호출하여 부모-자식 관계를 설정합니다.
/// 3. 새 뷰의 `translatesAutoresizingMaskIntoConstraints`를 `false`로 설정하고 `containerView`에 추가합니다.
/// 4. 새 뷰의 오토 레이아웃 제약 조건을 `containerView`에 맞게 설정하여 뷰를 올바르게 배치합니다.
/// 5. `didMove(toParent:)`를 호출하여 자식 뷰 컨트롤러의 라이프사이클을 완료합니다.
/// - Parameter index: 표시할 뷰 컨트롤러의 인덱스입니다.
private func selectViewController(at index: Int) {
// 현재 표시 중인 뷰 컨트롤러 제거
currentViewController?.willMove(toParent: nil)
currentViewController?.view.removeFromSuperview()
currentViewController?.removeFromParent()
// 새 뷰 컨트롤러 추가
let newViewController = viewControllers[index]
self.addChild(newViewController)
self.containerView.addSubview(newViewController.view)
// ⭐️ 수정됨: 프레임 대신 오토 레이아웃 제약 조건을 사용합니다.
newViewController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
newViewController.view.topAnchor.constraint(equalTo: containerView.topAnchor),
newViewController.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
newViewController.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
newViewController.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
])
newViewController.didMove(toParent: self)
self.currentViewController = newViewController
}
/// 거래 추가 뷰 컨트롤러를 모달로 표시합니다.
///
/// 이 메서드는 `AddTransactionViewController`를 `UINavigationController`로 감싸
/// 모달로 띄우고, 델리게이트를 자신(`self`)으로 설정하여 닫힐 때 알림을 받도록 합니다.
private func presentAddViewController() {
// AddTransactionViewController를 모달로 띄우기
let addVC = AddTransactionViewController(mode: .create, transactionManager: transactionManager)
addVC.delegate = self
let navigationController = UINavigationController(rootViewController: addVC)
navigationController.modalPresentationStyle = .fullScreen
self.present(navigationController, animated: true)
}
}
// MARK: - AddTransactionDelegate Extension
/// `AddTransactionDelegate` 프로토콜을 채택하여 모달 뷰 컨트롤러가 닫힐 때의 동작을 정의합니다
extension MainTabBarViewController: AddTransactionDelegate {
/// `AddTransactionViewController`가 성공적으로 닫혔을 때 호출됩니다.
///
/// 이 메서드는 홈 화면으로 돌아가고 탭바의 홈 버튼을 선택 상태로 업데이트합니다.
func didDismissAddTransaction() {
// 델리게이트를 통해 알림을 받으면, MainVC를 선택하고 탭바의 UI도 업데이트합니다.
self.selectViewController(at: 0)
self.customTabBar.selectButton(withTag: 0) // Home 버튼의 태그는 0
}
}
3단계: 모달 뷰 닫힌 후 상태 동기화하기
모달 뷰가 dismiss될 때 MainTabBarViewController에게 알려줘야 합니다. 이를 위해 델리게이트 패턴을 사용합니다.
- 델리게이트 구현:
- AddTransactionDelegate 프로토콜을 정의하고, AddTransactionViewController가 이 프로토콜의 delegate 프로퍼티를 가지도록 합니다.
- MainTabBarViewController는 이 프로토콜을 채택하고 didDismissAddTransaction() 메서드를 구현합니다.
- didDismissAddTransaction() 메서드에서는 selectViewController(at: 0)와 customTabBar.selectButton(withTag: 0)를 호출하여 홈 화면으로 돌아가고 탭바의 홈 버튼을 선택한 상태로 업데이트합니다.
@objc private func didTappedDismiss() {
self.dismiss(animated: false) {
self.delegate?.didDismissAddTransaction()
}
}

728x90
LIST