적당한 고통은 희열이다

- 댄 브라운 '다빈치 코드' 중에서

Swift iOS 앱 개발/실전 Swift

[Swift iOS] Custom Scrollable Sticky Tab Bar 만들기

hongssup_ 2022. 9. 15. 10:59
반응형

앱에서의 tab bar 는 보통 하나의 스크롤 형식이 아니라 tab 별로 다른 화면을 보여주도록 되어있는 경우가 많다. 

탭 클릭 한 번으로 원하는 위치로 스크롤이 되는 방식은 보통 웹으로 구현할 때 많이 쓰이는 방식으로, Native 앱에서는 따로 제공하는 라이브러리도 없고 구현이 쉽지 않아 custom으로 직접 scrollable 형식의 sticky tab bar 를 만들어보게 되었다. 

 

그리하여 직접 만들어본 tab bar 프로토타입. 

 

코드는 여기에 👉🏻 https://github.com/hongssup/StickyHeaderTab

 

1) UICollectionView 이용해 base 화면 생성하기

collectionView 자체가 scrollView의 특성을 가지고 있기 때문에, 우선 UICollectionView를 이용해 화면 base를 구성하기로 하고 시작했다. 다음과 같이 collectionView를 setup 해준 후, datasource와 delegate를 원하는 방식으로 설정해준다.

import UIKit
import SnapKit

class TabBarVC: UIViewController {
    
    lazy var collectionView: UICollectionView = {
        let layout = UICollectionViewFlowLayout()
        let view = UICollectionView(frame: .zero, collectionViewLayout: layout)
        view.backgroundColor = .white
        return view
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.setupCollectionView()
    }
    
    private func setupCollectionView() {
        self.collectionView.delegate = self
        self.collectionView.dataSource = self
        
        self.collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "cell")
        
        self.view.addSubview(collectionView)
        collectionView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
    }
}

 

2. TabBarView 만들어주기

- StackView vs. UICollectionView

후자는 delegate 따로 다 설정해줘야하고 코드도 길고 불편할 줄 알았는데 생각보다 간단했다. 

추후에 tab cell 자체를 더 꾸며줘야? 할 일이 있다면 후자가 더 편할 것 같기도 하다. 

더보기
// TabBarView.swift
class TabBarView: UIView {
    lazy var dividerView: UIView = {
        let view = UIView()
        view.backgroundColor = .white
        return view
    }()
    
    lazy var tabStack: UIStackView = {
        let stackView = UIStackView()
        stackView.axis = .horizontal
        stackView.distribution = .fillEqually
        return stackView
    }()
    
    var tabStacks: [UIStackView] = []
    var lineViews: [UIView] = []
    var titleLabels: [UILabel] = []
    var selectedIndex: Int = 0
    
    var titles = ["First", "Second", "Third"]
    let selectedFont = UIFont.systemFont(ofSize: 16)
    let unselectedFont = UIFont.systemFont(ofSize: 16)
    let selectedColor = UIColor.black
    let unselectedColor = UIColor.lightGray
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        self.setupView()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupView() {
        self.addSubview(dividerView)
        self.addSubview(tabStack)
        
        dividerView.snp.makeConstraints {
            $0.leading.trailing.bottom.equalToSuperview()
            $0.height.equalTo(1)
        }
        
        tabStack.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
        
        layoutElements(tabStack)
        lineViews[selectedIndex].backgroundColor = selectedColor
        titleLabels[selectedIndex].textColor = selectedColor
        titleLabels[selectedIndex].font = selectedFont
        tabStack.layoutIfNeeded()
    }
    
    private func createVStack() -> UIStackView {
        let stackView = UIStackView()
        stackView.axis = .vertical
        stackView.isUserInteractionEnabled = true
        let tap = UITapGestureRecognizer(target: self, action: #selector(onSelectTab))
        stackView.addGestureRecognizer(tap)
        return stackView
    }
    
    private func createLabel(_ title: String) -> UILabel {
        let label = UILabel()
        label.text = title
        label.font = unselectedFont
        label.textAlignment = .center
        label.textColor = unselectedColor
        label.adjustsFontSizeToFitWidth = true
        return label
    }
    
    private func createLineView() -> UIView {
        let view = UIView()
        view.backgroundColor = .clear
        return view
    }
    
    private func layoutElements(_ stackView: UIStackView) {
        self.titles.forEach { title in
            let vStack = createVStack()
            let label = createLabel(title)
            stackView.addArrangedSubview(vStack)
            
            let lineView = createLineView()
            vStack.addArrangedSubview(label)
            vStack.addArrangedSubview(lineView)
            
            lineViews.append(lineView)
            titleLabels.append(label)
            tabStacks.append(vStack)
            
            lineView.snp.makeConstraints {
                $0.height.equalTo(2.4)
            }
        }
    }
    
    func changeTabSelection(_ index: Int) {
        debugPrint("들어옴: \(selectedIndex) -> \(index)")
        lineViews[selectedIndex].backgroundColor = .white
        titleLabels[selectedIndex].textColor = unselectedColor
        titleLabels[selectedIndex].font = unselectedFont
        lineViews[index].backgroundColor = selectedColor
        titleLabels[index].textColor = selectedColor
        titleLabels[index].font = selectedFont
        selectedIndex = index
    }
    
    // MARK: - Events
    @objc private func onSelectTab(_ sender: UITapGestureRecognizer) {
        guard let view = sender.view as? UIStackView else { return }
        
        if let index = tabStacks.firstIndex(of: view) {
            self.changeTabSelection(index)
        }
    }
}

 

3. UICollectionView Header 에 TabBarView 넣어주기 

self.collectionView.register(TabHeader.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "tabHeader")
// UICollectionViewDataSource
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
    if kind == UICollectionView.elementKindSectionHeader {
        guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "tabHeader", for: indexPath) as? TabHeader else { return UICollectionReusableView() }

        return header
    }
    return UICollectionReusableView()
}

// UICollectionViewDelegateFlowLayout
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
    return (section == 0) ? CGSize.zero : CGSize(width: collectionView.frame.width, height: 44)
}
class TabHeader: UICollectionReusableView {
    let tabBarView = TabBarView()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        self.backgroundColor = .white
        self.setupView()
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    private func setupView() {
        self.addSubview(tabBarView)
        tabBarView.snp.makeConstraints {
            $0.edges.equalToSuperview()
        }
    }
}

 

4. TabBar 선택 시 화면 스크롤 하기

이 간단한 기능에 delegate 패턴도 쓰고 callback도 쓰고.. 복잡하다 복잡해 🤢

Delegate 패턴 이용하여 탭 선택 변경 알리기

// TabBarView.swift
protocol TabBarViewDelegate: AnyObject {
    func tabControl(didChange index: Int)
}

- callBack 사용하여, 선택된 탭의 index로 화면 스크롤 이동

스크롤 이동하는 함수를 선언해준 후,

// TabBarVC
@objc func moveToScroll(_ index: Int) {
    self.collectionView.scrollToItem(at: IndexPath(item: index, section: 1), at: .top, animated: true)
}

TabHeader에 다음과 같이 콜백을 정의해준 다음

var callBack: ((_ index: Int) -> Void)?

UICollectionViewDataSource 의 헤더 설정 부분에서 callBack으로 위 함수를 호출해준다.

header.callBack = { index in
    self.moveToScroll(index)
}

 

5. 화면 스크롤 시 TabBar 선택 변경해주기

1) 스크롤 감지

- 안드로이드에서는 setOnScrollChangeListener 라는 핸들러를 통해 실시간으로 화면 스크롤 위치를 받아올 수 있다고 하는데 iOS 에서는 UIScrollViewDelegate 공식문서를 찾아봐도 딱히 그런 메소드는 따로 제공해주지 않고 있는 듯하다.. 

- 대신 scrollViewDidEndDecelerating 이라는 함수를 사용하여 스크롤이 완료 되었을 때의 index 값을 감지하여 탭을 변경해주었다.

// UICollectionViewDelegate
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    let visibleIndexPathList = self.collectionView.visibleCells.compactMap { (visibleCell) -> Int? in
        return self.collectionView.indexPath(for: visibleCell)?.item
    }

    let sortList = visibleIndexPathList.sorted()
    guard let firstSection = sortList.first else { return }
}

2) TabBar selection 변경 호출

이 부분이 쉽지 않았다. 가장 오래 걸린 부분인듯? 역시나 해결은 생각보다 단순하게 간단하게..

tabbar를 collectionView로 설정했을 경우, .selectItem(at: ... ) 메서드를 이용하여 구현할 수 있다. 

stackView로 구현했을 경우, 여러가지 방법들로 삽질하다가 찾은 해결법은 간단하다. 

 

3) TabBar selection UI 변경

함수 호출 vs didset 이용 

 

 

 

custom tab bar 를 구현하는 간단해보이는 작업이지만 생각보다 많은 고민과 생각을 하게 되는 작업이었다.

내가 아직 얼마나 부족한지 많이 느끼고 앞으로 더 열심히 공부해야겠다는 다짐과 함께.. 실천도 하자..? ㅎ

 

 

 

 

 

 

참고 : Scrollable Segmented Control - StackView 이용한 custom tab control

 

 


2022-10-06

tab bar scroll 시 헤더 높이 만큼 cell 이 가리는 문제

Tab Bar 선택 시 .scrollToItem 사용해서 해당 index 로 스크롤 하도록 해주었는데, 스크롤 시 tab bar 높이만큼 cell 이 가려서 안보이는 문제가 있었다. 

다음과 같이 설정을 해주니 원하는 위치로 딱! 스크롤이 잘 된다! 😊

@objc func moveToScroll(_ index: Int) {
    guard let layout = collectionView.collectionViewLayout.layoutAttributesForItem(at: indexPath) else {
        collectionView.scrollToItem(at: indexPath, at: .top, animated: true)
        return
    }
    let offset = CGPoint(x: 0, y: layout.frame.minY - headerHeight)
    collectionView.setContentOffset(offset, animated: true)
}

 

 

참고 : https://stackoverflow.com/questions/49638855/scrolltoitem-at-indexpath-at-top-hides-cell-under-header-when-sectionheaderspin

728x90
반응형