UIKit) TabBar NestedScrollView 구현

프로필 화면에 들어가면 많이 사용되는 탭바 NestedScrollView를 구현해 보았다. 

 

사용 라이브러리

  • tabman
  • snapkit

 

구현 결과물

 

구현 방식

2개의 스크롤 뷰를 이용. 

  • overlay scrollview: 직접적인 스크롤이 일어나는 스크롤 뷰
  • container scrollview: UI가 들어갈 스크롤 뷰.
  • container scrollview: HeaderView와 하단 탭바뷰로 구성

overlay ScrollView의 content Size를 headerView의 height + 현재 선택된 탭바의 ViewController의 scrollView(tableView 혹은 collectionView)의 contentSize의 height값을으로 만든 후, overlay scrollView의 contentOffset값으로 container ScrollView의 contentOffset값과 선택된 탭바의 ViewController의 contentOffset값을 결정하는 방식으로 구현

 

코드

헤더뷰 아래에 붙일 탭바 정의.

pageboy의 delegate인 pageboyViewController(_ pageboyViewController: PageboyViewController, didScrollToPageAt index:)를 오버라이드 해서, 탭바를 탭하거나 페이징 해서 페이지 전환이 완료되었을 때, index값과 해당 index에 해당하는 viewcontroller를 delegate의 인자값으로 넘겨받음

import Foundation
import UIKit
import Tabman
import Pageboy

protocol CustomTabBarDelegate: AnyObject {
    func pageViewController(_ currentViewController: UIViewController?, didselectPageAt index: Int)
}

class CustomTabBarViewController: TabmanViewController {
    
    let bar: TMBar = {
        let bar = TMBar.ButtonBar()
        bar.layout.transitionStyle = .snap
        return bar
    }()
    
    weak var delegate: CustomTabBarDelegate?
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    func setTapBar(datasource: TMBarDataSource) {
        addBar(bar, dataSource: datasource, at: .top)
    }
    
    override func pageboyViewController(_ pageboyViewController: PageboyViewController, didScrollToPageAt index: TabmanViewController.PageIndex, direction: PageboyViewController.NavigationDirection, animated: Bool) {
        super.pageboyViewController(pageboyViewController, didScrollToPageAt: index, direction: direction, animated: animated)
        delegate?.pageViewController(dataSource?.viewController(for: self, at: index), didselectPageAt: index)
    }
}

 

ViewController의 View에서 ScrollView를 반환해주는 함수 정의

extension UIViewController {
    func panView() -> UIView {
        if let scroll = self.view.subviews.first(where: { $0 is UIScrollView }) {
            return scroll
        } else {
            return self.view
        }
    }
}

 

 

ProfileViewController 내부에 들어갈 View 정의. scrolView의 contentInsetAdjustmentBehavior는 scrollView의 top이 view의 safeArea보다 위에 있을 경우의 contentOffset을 어찌할 것인지를 묻는 것. 여기서는 never로 contentOffset을 주지 않는 것으로 한다.

// ProfileViewController

   private let overlayScrollView: UIScrollView = {
        let scrollView = UIScrollView()
        scrollView.contentInsetAdjustmentBehavior = .never
        scrollView.showsVerticalScrollIndicator = false
        scrollView.bounces = false
        return scrollView
    }()
    
    private let containerScrollView: UIScrollView = {
        let scrollView = UIScrollView()
        scrollView.contentInsetAdjustmentBehavior = .never
        scrollView.showsVerticalScrollIndicator = false
        return scrollView
    }()
    
    private let headerViewController: UIViewController
    private let headerViewHeight: CGFloat
    private var headerView: UIView {
        return headerViewController.view
    }
    
   
    private let bottomViewController: CustomTabBarViewController = .init()

 

 

 

UI에 삽입 및 레이아웃 정의

override func viewDidLoad() {
        super.viewDidLoad()
        configureView()
        setConstraints()
        setTabBar()
    }
    
    private func addViewController(vc: UIViewController, at: UIView) {
        addChild(vc)
        at.addSubview(vc.view)
        vc.didMove(toParent: self)
    }
    
    private func configureView() {
        view.addSubview(outsideScrollView)
        addViewController(vc: headerViewController, at: outsideScrollView)
        addViewController(vc: bottomTabBarViewController, at: outsideScrollView)
        outsideScrollView.delegate = self
        outsideScrollView.showsVerticalScrollIndicator = false
    }
    
    private func setTabBar() {
        bottomTabBarViewController.delegate = self
        bottomTabBarViewController.dataSource = self
        bottomTabBarViewController.setTapBar(datasource: self)
    }
    
    private func setConstraints() {
        outsideScrollView.snp.makeConstraints { make in
            make.edges.equalTo(view.safeAreaLayoutGuide)
        }
        headerView.snp.makeConstraints { make in
            make.top.width.leading.trailing.equalToSuperview()
            make.height.equalTo(headerViewHeight)
        }
        bottomTabBarViewController.view.snp.makeConstraints { make in
            make.top.equalTo(headerView.snp.bottom)
            make.bottom.leading.trailing.width.height.equalToSuperview()
        }
    }

 

탭바의 datasource와 delegate를 채택하여 구현

protocol ProfileViewDataSource: AnyObject {
    func numberOfViewControllers() -> Int
    func viewController(at index: Int) -> UIViewController
    func barItem(at index: Int) -> String
}
extension ProfileViewController: TMBarDataSource, PageboyViewControllerDataSource {

    func numberOfViewControllers(in pageboyViewController: Pageboy.PageboyViewController) -> Int {
        guard let profileDatasource else { return 0 }
        return profileDatasource.numberOfViewControllers()
    }
    
    func viewController(for pageboyViewController: Pageboy.PageboyViewController, at index: Pageboy.PageboyViewController.PageIndex) -> UIViewController? {
        guard let profileDatasource else { return nil }
        return profileDatasource.viewController(at: index)
    }
    
    func defaultPage(for pageboyViewController: Pageboy.PageboyViewController) -> Pageboy.PageboyViewController.Page? {
        nil
    }
    
    func barItem(for bar: Tabman.TMBar, at index: Int) -> Tabman.TMBarItemable {
        guard let dataSource = self.profileDatasource else { return TMBarItem(title: "") }
        return TMBarItem(title: dataSource.barItem(at: index))
    }
}

 

탭바의 viewController의 view와 각 뷰의 contentOffset를 캐싱해 둘 변수 생성. 

// ProfileViewController

private var panViews: [Int: UIView] = [:]
private var contentOffsets: [Int: CGFloat] = [:]

contentOffsets [index]에 contentOsset이 존재한다면, overlaySrollView의 contentOffset을 해당하는 값으로 만들고, 값이 존재하지 않는다면 현재 containerScrollView의 containerOffset으로 값을 변경.

panViews에는 index값에 view가 존재하지 않는다면, 선택한 viewcontroller의 view를 캐싱하고, 존재한다면 overlayScrollView의 contentSize를 업데이트하는 함수를 실행.

 

즉 탭바로 인해 VC가 바뀐다면, overlayScrollView의 contentSize를 업데이트 해줘서 스크롤할 수 있는 방식.

extension ProfileViewController: CustomTabBarDelegate {
    func pageViewController(_ currentViewController: UIViewController?, didselectPageAt index: Int) {
        currentIndex = index
        if let offset = contentOffsets[index] {
            self.overlayScrollView.contentOffset.y = offset
        } else {
            self.overlayScrollView.contentOffset.y = containerScrollView.contentOffset.y
        }
        if let vc = currentViewController, panViews[index] == nil {
            self.panViews[index] = vc.panView()
        }
        if let panView = self.panViews[currentIndex] {
            updateOverlayScrollContentSize(with: panView)
        }
    }
    
}

 

overlay의 contentSize는 선택된 VC의 contentSize가 뷰에 딱 맞는지, 아니면 뷰보다 더 큰지에 따라 달라짐.

  • 뷰에 딱 맞는 경우: 선택된 VC의 contentSize가 스크롤할 필요 없이 작은 경우를 뜻함
  • 뷰보다 더 큰경우: 선택된 VC의 contentSize가 스크롤을 할 수 있을 정도로 큰 경우

tabman의 탭바 크기와 UITabBar를 쓸 경우의 탭바, 그리고 추가로 고려해야 할 spacing(minHeaderHeight)를 이용하여 contentSize를 계산

   private func updateOverlayScrollContentSize(with bottomView: UIView) {
        self.overlayScrollView.contentSize = getContentSize(for: bottomView)
    }
    
    private func getContentSize(for bottomView: UIView) -> CGSize {
        let tabBarHeight = bottomViewController.bar.frame.height
        let bottomInset = getBottomInset()
        if let scroll = bottomView as? UIScrollView{
            let bottomHeight = max(scroll.contentSize.height, self.view.frame.height - (delegate?.minHeaderHeight() ?? 0) - tabBarHeight - bottomInset )
            return CGSize(width: scroll.contentSize.width,
                          height: bottomHeight + headerView.frame.height + tabBarHeight + bottomInset )
        }else{
            let bottomHeight = self.view.frame.height - (delegate?.minHeaderHeight() ?? 0) - tabBarHeight
            return CGSize(width: bottomView.frame.width,
                          height: bottomHeight + headerView.frame.height + tabBarHeight + bottomInset)
        }
    }
    
    private func getBottomInset() -> CGFloat {
        guard let window = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return 0 }
        guard let keywindow = window.keyWindow else { return 0 }
        if let tabBarController = keywindow.rootViewController?.tabBarController {
            return tabBarController.tabBar.frame.height
        }
        return keywindow.safeAreaInsets.bottom 
    }

 

 

OverlayScrollView를 스크롤 할 경우,

  • 탭바의 ViewController의 minY 프레임 값보다 contentOffset이 더 작다면: 아직 headerView를 다 스크롤한 게 아니므로 containerScrollView의 contentOffset 값을 변경, 그리고 저장된 contentOffsets의 값을 모두 없애 탭바의 VC를 모두 위로 끌어올리도록.
  • 탭바의 ViewController의 minY 프레임 값보다 contentOffset이 더 크다면: HeaderView를 다 스크롤 한것이므로, 선택된 ViewController의 스크롤 뷰의 contentOffset 값을 조정한다.
extension ProfileViewController: UIScrollViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        contentOffsets[currentIndex] = scrollView.contentOffset.y
        let topHeight = bottomViewController.view.frame.minY - (delegate?.minHeaderHeight() ?? 0)
        if scrollView.contentOffset.y < topHeight{
            self.containerScrollView.contentOffset.y = scrollView.contentOffset.y
            self.panViews.forEach({ panView in
                let (_, value) = panView
                (value as? UIScrollView)?.contentOffset.y = 0
            })
            contentOffsets.removeAll()
        }else{
            self.containerScrollView.contentOffset.y = topHeight
            (self.panViews[currentIndex] as? UIScrollView)?.contentOffset.y = scrollView.contentOffset.y - self.containerScrollView.contentOffset.y
        }
        
    }
}

 

 

마지막으로 탭바의 선택된 viewController의 ScrollView의 contentSize가 변경된 경우, KVO를 이용해 overlayScrollView의 contentOffset을 업데이트. gesture의 require 함수를 이용해 하단 탭바의 ScrollView보다 OverlayScrollView의 스크롤 동작의 우선권을 부여

private var panViews: [Int: UIView] = [:] {
        didSet {
            if let scrollView = panViews[currentIndex] as? UIScrollView{
            	scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer)
                panScrollViewObservation = scrollView.observe(\.contentSize) { scroll, change in
                    self.updateOverlayScrollContentSize(with: scroll)
                }
            }
        }
    }