내용 정리
오늘은 팀 프로젝트에서 구현을 맡은 부분인
TabBar를 구현하였다.
각종 이슈와 부딪히며 결국은 목표를 달성한 과정을 담아보았다.
바로 작업을 시작하기에 앞서 어떤 식으로 구현할 것인지 구상해 보았다.
사실 탭바는 UITabBarController를 사용하면 비교적 쉽게 구현할 수 있다.
그러나 나는 커스텀한 요소를 넣고 싶었고, 때문에 UICollectionView로 탭바를 구현하기로 하였다.
커스텀 탭바를 만드는데 왜 하필 UICollectionView를 골랐냐고 물어본다면, UICollectionView는 indexPath를 이용해 각 셀의 설정을 달리할 수도 있고, 델리게이트 메소드 등을 통해 각 셀에 액션을 주거나 UI를 업데이트 하는데 유리하기 때문이다.
이번 프로젝트 중에는 최대한 MVC 아키텍처 패턴을 준수하며 진행을 해보고 싶었기 때문에 내가 맡은 파트에서도 개별적으로 MVC를 나누어 진행하기로 했다.

MVC 패턴을 쓰기 위해 나름 공부를 해보았지만 여전히 헷갈리는 개념인 것 같다...
나눠본다고 나눠보았는데, 이게 맞는지도 잘 모르겠다.
일단은 MVC도 나눠봤으니 디렉토리도 이에 맞추어 분리해 보았다.
TabBar
├── Controller
│ └── MainTabBarController.swift
│
├── Model
│ └── TabBarDelegate.swift
│
└── View
├── MainTabBar.swift
└── TabBarItem.swift
이제 본격적으로 탭바의 제작에 들어가보자
우선 탭바의 탭 아이템으로 쓰일 컬렉션뷰의 셀을 만들도록 하자
static let id: String = "TabBarItem" // 셀의 고유 이름
private let tabTitle: [String] = ["킥보드 찾기", "킥보드 등록", "마이 페이지"] // 셀의 타이틀
private let tabLabel = UILabel() // 탭의 타이틀 UI
셀이 가질 데이터는 사실 레이블 밖에 없기 때문에 특별히 지정해 줄 데이터가 없다.
셀의 고유 이름으로 쓰일 id와 나중에 셀 설정을 할 때 indexPath.row를 가져와서 셀 레이블의 텍스트를 적용해 줄 레이블 값 배열을 정의해준다.
나머지는 UI 값을 주는 메소드들을 만들어주고...
셀의 indexPath.row를 받아와 텍스트를 적용시킬 interanl한 메소드를 만들어준다.
/// 셀의 레이블을 설정하는 메소드
/// - Parameter row: 현재 셀의 indexPath row
func setupLabelConfig(_ row: Int) {
self.tabLabel.text = self.tabTitle[row]
}
이 메소드는 컬렉션뷰의 dataSource 메소드에서 셀을 설정할 때 사용하여 indexPath.row에 따라 텍스트를 바꿔주는 메소드이다.
이렇게 하면 컬렉션뷰 셀의 구현은 끝이다.
이제 위에서 만든 셀을 사용하여 컬렉션뷰를 만들어야 하는데, 컬렉션뷰는 뷰 컨트롤러가 아닌 UIView에 구현할 생각이다.
커스텀 UIView를 만들어 이 뷰를 탭 바로 사용할 생각이기 때문이다.
먼저 새로운 UIView 클래스를 정의해주고 필요한 프로퍼티들을 정의해준다.
// 탭바 UI를 구현하는 UIView
final class MainTabBar: UIView {
// 탭바
private lazy var tabBar: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.itemSize = .init(width: UIScreen.main.bounds.width / 3, height: 50)
layout.scrollDirection = .horizontal
layout.minimumLineSpacing = 0
layout.minimumInteritemSpacing = 0
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
return collectionView
}()
private let indicator = UIView()
// ...
}
여기서는 컬렉션뷰를 탭바라고 정의하였다. 그리고 indicator는 현재 탭이 어디인지 시각적으로 더욱 잘 보여주기 위해 만든 UIView이다.
셀을 만들 때처럼 메소드를 만들어 UI에 대한 설정을 해준 뒤 컬렉션뷰 데이터소스 메소드를 설정한다.
// 셀의 수량 설정
// 킥보드 찾기, 킥보드 등록, 마이 페이지로 3개의 셀
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 3
}
// 셀의 모양을 설정
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: TabBarItem.id, for: indexPath) as? TabBarItem else {
return UICollectionViewCell()
}
cell.setupLabelConfig(indexPath.row) // 셀 텍스트 설정
cell.selectedTab(self.pageIndex) // 선택된 셀 강조
return cell
}
셀의 개수는 3개로 고정되기 때문에 3을 리턴해주고, 셀의 설정에서 현재 VC가 무엇인지를 전달하여 현재 VC와 탭의 인덱스가 같으면 강조하는 효과를 준다.
이제 뷰 컨트롤러를 만들고 탭바를 추가해주자
우선 뷰 컨트롤러 클래스를 정의해준다.
final class MainTabBarController: UIViewController { ... }
이 컨트롤러가 가질 값은 탭 바의 인덱스에 따라 바뀔 뷰 컨트롤러와 탭바이다.
그래서 아래와 같이 정의해준다
private let tabBar = MainTabBar() // 탭바 UI
private let viewControllers: [UIViewController] // 표시할 VC 목록
private var currentVC: UIViewController? // 현재 VC
MainTabBar타입은 위에서 만든 커스텀 UIView이고, 아래 프로퍼티들은 현재 컨트롤러에서 관리할 뷰 컨트롤러들이다.
viewControllers는 탭바가 관리할 모든 뷰 컨트롤러들의 모임이고, currentVC는 현재 보여지고 있는 뷰 컨트롤러를 말한다.
이 때, 뷰 컨트롤러의 생성자를 통해 관리할 뷰 컨트롤러들을 입력 받도록 한다.
init(viewControllers: [UIViewController]) {
self.viewControllers = viewControllers
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
생성자를 새로 만들어줬기 때문에 required init도 정의해 주어야 한다.
이제 각 UI 요소들에 대한 설정을 해주고, 가장 중요한 VC 메소드를 작성해준다.
VC의 경우 currentVC로 지정된 뷰 컨트롤러의 뷰를 보여주도록 하고, 탭이 바뀌면 해당 뷰를 지운 뒤 다른 뷰를 다시 currentVC로 지정하는 방식을 사용하였다.
/// 현재 보여지는 뷰를 관리하는 메소드
/// - Parameter index: 몇 번째 뷰를 보여줄 것인지
private func displayViewController(_ index: Int) {
if let currentVC = self.currentVC {
currentVC.view.removeFromSuperview()
currentVC.removeFromParent()
}
let selectedVC = viewControllers[index]
self.addChild(selectedVC)
self.view.insertSubview(selectedVC.view, belowSubview: self.tabBar)
selectedVC.view.snp.makeConstraints {
$0.top.leading.trailing.equalToSuperview()
$0.bottom.equalTo(self.tabBar.snp.top)
}
selectedVC.didMove(toParent: self)
self.currentVC = selectedVC // 현재 VC를 선택한 VC로 변경
}
이 메소드의 작동 순서는 다음과 같다
currentVC가 있다면 이를 삭제index번째의 뷰 컨트롤러를 인스턴스화didMove메소드로 설정이 완료되었음을 명시적으로 선언currentVC에 새로운 뷰 컨트롤러를 정의이렇게 하면 탭을 눌렀을 때, 탭의 인덱스를 파라미터로 전달하여 이 메소드를 호출하고,
메소드가 현재 뷰 컨트롤러를 제거한 뒤 새로운 뷰 컨트롤러를 정의하여 현재 뷰 컨트롤러를 바꾸어주게 된다.
이를 애니메이션 같은 코드를 사용해 구현해주면 더욱 자연스러운 연출을 만들 수 있게 된다.
이제 테스트 더미를 만들고 빌드 테스트를 진행해보자.
우선 테스트 더미가 될 뷰 컨트롤러를 만들어주자.
class FirstVC: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .red
}
}
class SecondVC: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .blue
}
}
class ThirdVC: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .yellow
}
}
구분하기 쉽게 색상을 다르게 지정해 주었다.
그리고 메인 뷰 컨트롤러에 탭바 뷰 컨트롤러를 삽입하면 된다.
class ViewController: UIViewController {
private let mainTabBarController = MainTabBarController(viewControllers: [FirstVC(), SecondVC(), ThirdVC()])
override func viewDidLoad() {
super.viewDidLoad()
print("ViewController DidLoad")
self.addChild(mainTabBarController)
view.addSubview(mainTabBarController.view)
mainTabBarController.view.snp.makeConstraints {
$0.edges.equalToSuperview()
}
mainTabBarController.didMove(toParent: self)
}
}
테스트가 목적이었기 때문에 대략적으로 구현만 해주었다.
여기까지 진행하고 빌드한 모습은 이렇다.

다행히 문제 없이 잘 구현이 되었다.
하지만 지금은 탭을 아무리 눌러도 뷰 컨트롤러가 바뀌지 않는다.
왜냐하면 뷰 컨트롤러를 바꿔주는 메소드를 호출하는 코드가 없기 때문이다. 이제부터 이 부분을 델리게이트를 만들어서 진행할 예정이다.
우리 팀은 데이터 전달을 델리게이트 패턴을 이용하여 전달하기로 결정했기 때문에 새로운 파일을 만들어 델리게이트를 구현해주기로 했다.
// 탭바의 데이터 전송을 위한 델리게이트
protocol TabBarDelegate: AnyObject {
func changeVC(_ index: Int) // 현재 보여지는 VC를 변경하는 메소드
}
여기에 만든 메소드를 위에서 만든 커스텀 탭바 뷰에서 호출하고, 이를 관리하는 탭바 뷰 컨트롤러에서 메소드에 대한 자세한 구현을 해두면 우리가 원하는 대로 탭을 선택했을 때 VC가 바뀌는 기능을 추가할 수 있다.
final class MainTabBar: UIView {
weak var tabBarDelegate: TabBarDelegate?
}
final class MainTabBarController: UIViewController, TabBarDelegate {
self.tabBar.tabBarDelegate = self
func changeVC(_ index: Int) {
UIView.transition(with: view, duration: 0.3, options: .transitionCrossDissolve) {
self.displayViewController(index)
}
}
}
이제 실제로 빌드를 해서 테스트를 해보자.

보다시피 VC가 잘 바뀌는 것을 확인할 수 있다.
그런데 여기서 인디케이터의 위치가 고정된 채 움직이지 않는 모습을 볼 수 있다. 탭이 바뀔 때마다 인디케이터의 위치도 변했으면 좋겠으니 이 작업을 진행 해보도록 한다.
인디케이터는 탭을 선택할 때마다 이동이 되어야 하기 때문에 셀을 선택했을 때 특정 동작을 수행할 수 있는 컬렉션뷰의 델리게이트 메소드를 활용하는 것이 좋을 것 같다고 생각했다.
이 때, 인디케이터는 좌표값이 바뀌어야 하기 때문에 오토레이아웃으로 설정하면 제대로 작동되지 않아 frame을 통해 위치와 크기를 정의해 주었다. 그래서 인디케이터의 좌표값 x를 변화시켜 인디케이터의 애니메이션을 구현할 것이다.
private func moveIndicator() {
let constraintX: CGFloat
switch self.pageIndex {
case .search:
constraintX = (self.bounds.width / 3) / 2 - 25
case .registration:
constraintX = (self.bounds.width / 2) - 25
case .myPage:
constraintX = (self.bounds.width - ((self.bounds.width / 3) / 2 + 25))
}
UIView.animate(withDuration: 0.3) {
self.indicator.frame.origin.x = constraintX
self.indicator.layoutIfNeeded()
}
}
이 부분은 엄청 고민했지만 결국 하드코딩으로 값을 주었다...
현재 뷰의 페이지 상태에 따라 constraintX라는 값을 변화시키고, 이 값을 인디케이터의 x값으로 지정해주는 간단한 메소드이다.
원래는 컬렉션뷰의 각 셀의 위치값 x를 받아와서 진행할까 싶었지만 그 부분의 구현이 어려워서 하드코딩으로 대체했다...
self는 탭바와 인디케이터를 가지고 있는 UIView이고 이는 곧 커스텀 탭바이기 때문에 self의 width를 3으로 나누면 각 탭의 크기가 된다. 그리고 이것을 2로 나누면 탭의 중앙의 값이 되기 때문에 이런 공식을 사용해서 값을 지정해줬다.
수학에 약한 탓에 정말 볼품없어 보이지만... 잘 실행되기만 하면 되는거 아닐까??
이제 컬렉션뷰의 델리게이트 메소드에 moveIndicator 메소드를 추가하여 실행해보자
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
moveIndicator()
}

부드럽게 잘 움직이는 모습을 볼 수 있다.
사실 블로그 내용에는 빼먹은 내용도 많고, 내용처럼 순탄하게 제작하지도 못했다.
특히 초반에 멘탈 이슈와 컨디션 난조로 코드를 싹 갈아엎기도...(진짜로 다 지우고 다시 처음부터 시작했다)
중간중간 그냥 UITabBarController로 구현할 걸 후회하기도 했지만,
결과물을 보니 역시 커스텀하게 만들기를 잘 했다는 생각이 든다.
오늘은 프로젝트에서 중요도는 높지만 구현 난이도는 비교적 간단한 편인
탭바의 제작을 진행하였다.
드디어 본격적인 프로젝트의 진행 시작이라서 마음이 급한 탓에 실수도 많이 하고
여러모로 예상보다 오래 걸리고 힘들었던 것 같다.
내일부터는 다시 멘탈을 잡고 열심히 해보자...!!