iOS) 상단 탭바 구현 (1) - 메뉴바

iOS에서 탭바는 하단에 고정되어 있다. 상단 부분에 탭바를 만들고 싶다면, 직접 구현해야 한다. 라이브러리를 써도 되지만, 직접 구현하는 편이 성능상에도 좋을것 같고, 예상치 못한 버그를 수정하는데도 좋을것 같아서 직접 구현해보았다.

구현하려고 하는 상단 탭바

 

UITabBarController의 공식문서에 나와있는 탭바의 view 계층 및 구현을 참고하여 구현했다.

공식문서 탭바 뷰 계층

상단 메뉴바 구현

상단 메뉴바는 화면을 전환하는 역할을 담당한다. 상단 탭바의 경우 버튼 스택뷰를 이용하거나, 콜렉션 뷰를 이용하여 탭바 안에 들어가는 ViewController의 개수에 따라 일정한 크기를 유지하도록 조절 할 수 있다. 나는 콜렉션 뷰를 활용했다.

메뉴바는 UIView에 콜렉션뷰를 넣고, 콜렉션 뷰의 하단에, 현재 위치를 표시해주는 하단바를 콜렉션 뷰 밑부분에 붙여서 구현 할 생각이다.

 

우선은 메뉴바가 될 콜렉션 뷰에 들어갈 셀을 정의했다.

import UIKit

class MenuBarCell: UICollectionViewCell {
    
    static let reusableIdentifier = "MenuBarCell"
    
    lazy var itemTitle: UILabel = {
        let label = UILabel()
        label.font = UIFont(name: "NotoSansKR-Medium", size: 16)
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()
    
    override var isSelected: Bool {
        didSet {
            self.itemTitle.font = isSelected ? UIFont(name: "NotoSansKR-Bold", size: 16) : UIFont(name: "NotoSansKR-Medium", size: 16)
        }
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupView() {
        contentView.addSubview(itemTitle)
        NSLayoutConstraint.activate([
            itemTitle.centerXAnchor.constraint(equalTo: centerXAnchor),
            itemTitle.centerYAnchor.constraint(equalTo: centerYAnchor)
        ])
    }
}

isSelected를 오버라이딩 하여, 해당 셀의 select 상태에 따라 UI가 변경 되도록 했다. 

 

이제 메뉴바View를 생성하고, 콜렉션뷰와 하단 바의 UI를 구성해주었다.

struct MenuItem {
    let title: String
}

class CustomMenuBar: UIView {
    
    private enum Metric {
        static let currentMarkViewHeight = 2.0
    }
    
    lazy var menuCollectionView: UICollectionView = {
        let layout = UICollectionViewFlowLayout()
        layout.scrollDirection = .horizontal
        layout.itemSize = .init(width: 30, height: 30)
        layout.minimumLineSpacing = 0.0
        let collectionView = UICollectionView(frame: CGRect(x: 0, y: 0, width: 0, height: 0), collectionViewLayout: layout)
        collectionView.translatesAutoresizingMaskIntoConstraints = false
        collectionView.backgroundColor = .white
        collectionView.showsHorizontalScrollIndicator = false
        collectionView.showsVerticalScrollIndicator = false
        collectionView.isScrollEnabled = false
        collectionView.contentInset = .zero
        
        return collectionView
    }()
    
    var currentMarkView: UIView = {
        let view = UIView()
        view.translatesAutoresizingMaskIntoConstraints = false
        view.backgroundColor = .black
        
        return view
    }()
    
    private lazy var currentMarkLeading: NSLayoutConstraint = {
        return currentMarkView.leadingAnchor.constraint(equalTo: menuCollectionView.leadingAnchor)
    }()
    
    private var items: [MenuItem] = []
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        addView()
        setMenuCollectionView()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func addView() {
        menuCollectionView.register(MenuBarCell.self, forCellWithReuseIdentifier: MenuBarCell.reusableIdentifier)
        addSubview(menuCollectionView)
        addSubview(currentMarkView)
    }
    
    private func setMenuCollectionView() {
        menuCollectionView.dataSource = self
        menuCollectionView.delegate = self
        NSLayoutConstraint.activate([
            menuCollectionView.leadingAnchor.constraint(equalTo: leadingAnchor),
            menuCollectionView.trailingAnchor.constraint(equalTo: trailingAnchor),
            menuCollectionView.topAnchor.constraint(equalTo: topAnchor),
            menuCollectionView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Metric.currentMarkViewHeight)
        ])
    }
    
    private func setCurrentMark() {
        NSLayoutConstraint.activate([
            currentMarkView.bottomAnchor.constraint(equalTo: bottomAnchor),
            currentMarkView.heightAnchor.constraint(equalToConstant: Metric.currentMarkViewHeight),
            currentMarkLeading,
            currentMarkView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 1 / CGFloat(items.count))
        ])
    }
    
    func setItems(items: [MenuItem]) {
        self.items = items
        setCurrentMark()
    }
    
}

extension CustomMenuBar: UICollectionViewDelegate, UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return items.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: MenuBarCell.reusableIdentifier, for: indexPath) as? MenuBarCell else { return MenuBarCell() }
        cell.itemTitle.text = "\(items[indexPath.row].title)"
        return cell
    }
}

extension CustomMenuBar: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        let width = collectionView.frame.width
        let height = collectionView.frame.height
        return CGSize(width: width/CGFloat(items.count), height: height)
    }
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
        return UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
    }
}

콜렉션 뷰의 사이즈들은 콜렉션 뷰의 width / 메뉴 개수로 설정했고, collectionView의 inset는 상하좌우 0으로 설정 해, 셀이 콜렉션 뷰에 꽉 차도록 UI를 구성했다.

 

하단 바는 leadingAnchor를 따로 빼두어서, 콜렉션뷰의 셀이 선택이 될 때, 해당 셀의 leadingAnchor의 위치와 같아지도록 constant를 설정하여 하여 셀이 클릭 될 때, 해당 셀의 위치와 같아지도록 하였다. top, leading, width, height가 정해져 있으니, 오토레이아웃에 의해 자동으로 그려지는것을 이용하였다.

 private lazy var currentMarkLeading: NSLayoutConstraint = {
        return currentMarkView.leadingAnchor.constraint(equalTo: menuCollectionView.leadingAnchor)
    }()
    
  private func setCurrentMark() {
        NSLayoutConstraint.activate([
            currentMarkView.bottomAnchor.constraint(equalTo: bottomAnchor),
            currentMarkView.heightAnchor.constraint(equalToConstant: Metric.currentMarkViewHeight),
            currentMarkLeading,
            currentMarkView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 1 / CGFloat(items.count))
        ])
    }

이제 해당 메뉴 바를 클릭하면 해당 화면으로 이동하고, 상단 메뉴의 UI도 변경되도록 해야한다. delegate를 이용하여 이를 처리했다.

 

protocol CustomMenuBarDelegate: AnyObject {
    func didSelect(indexNum: Int)
}

func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        guard let cell = collectionView.cellForItem(at: indexPath) else { return }
        cell.isSelected = true
        let selectIndex = CGFloat(indexPath.row)
        let cellWidth = cell.frame.width
        let leadingDistance = selectIndex * cellWidth
        currentMarkLeading.constant = leadingDistance
        delegate?.didSelect(indexNum: indexPath.row)
    }
    
func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) {
        guard let cell = collectionView.cellForItem(at: indexPath) else { return }
        cell.isSelected = false
    }

셀이 선택되면, 선택된 셀의 indexPath를 이용해 width * indexPath.row를 하여, 셀의 위치를 계산하고, 그만큼의 constant를 준다. 선택된 셀은 하나만 있어야 하므로, 나머지 셀은 didDeselectItemAt으로 isSelect를 false로 둔다.