앱에서의 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)
}
'Swift iOS 앱 개발 > 실전 Swift' 카테고리의 다른 글
프리온보딩 iOS 챌린지 Week 1-1 (0) | 2022.11.28 |
---|---|
UICollectionView dynamic cell size (0) | 2022.10.06 |
[Swift iOS] UICollectionView - Storyboard (0) | 2022.06.15 |
Volumetric Internal Project (0) | 2021.01.21 |
[실전 Swift] AVPlayer.seek(to:time) frame move back and forth 동영상 프레임 이동 (0) | 2021.01.19 |