본문 바로가기
iOS/UI

TableView vs. CollectionView Section Header

by 바등쪼 2022. 4. 3.

생성일: 2022년 7월 2일 오전 12:56

iOS의 시계 앱의 알람 부분을 구현하던 중에 뜻하지 않은 문제?를 발견했다.

스냅킷과 Then을 사용하여 코드 베이스 UI 구현을 연습하고 있었는데 위의 사진을 보면 cell이 반복되기 때문에 TableView 또는 CollectionView로 구현을 해야겠다고 마음 먹고 CollectionView 사용 경험이 적어서 연습할겸 CollectionView를 선택해서 구현을 했다.

구현 아이디어

  • 콜렉션뷰의 섹션을 2개로 나눈다.
    • 수면|기상 섹션
    • 기타 섹션
  • 섹션 헤더를 이용하여 “수면|기상" 부분과 “기타” 부분을 구현한다. 하나의 Swift 파일로 만들어 재사용하고 두번째 섹션에서는 “알람"이라고 크게 적힌 라벨과 침대 이미지를 제거하는 방식을 사용한다.

  • 나머지는 cell로 구현한다.

구현 코드

AlarmSectionHeaderCollectionViewReusableView.swift

import UIKit

enum AlarmSectionHeaderType {
    case sleepAlarm
    case normalAlarm
}

class AlarmSectionHeaderCollectionReusableView: UICollectionReusableView {

    //MARK: - Properties
    static let identifier = "AlarmSectionHeaderCollectionReusableView"

    var type: AlarmSectionHeaderType = .sleepAlarm {
        didSet {
            configureUI()
        }
    }

    let pageNameLabel = UILabel().then {
        $0.text = "알람"
        $0.font = UIFont.systemFont(ofSize: 36, weight: .bold)
        $0.textColor = .white
    }

    private let sectionIconImageView = UIImageView().then {
        $0.image = UIImage(systemName: "bed.double.fill")
        $0.contentMode = .scaleAspectFill
        $0.clipsToBounds = true
        $0.backgroundColor = .black
        $0.tintColor = .white
    }

    private let sectionNameLabel = UILabel().then {
        $0.text = "수면 | 기상"
        $0.font = UIFont.systemFont(ofSize: 20, weight: .medium)
        $0.textColor = .white
    }

    private let cellDivider = UIView().then {
        $0.backgroundColor = .darkGray
    }

    //MARK: - Lifecycle
    override init(frame: CGRect) {
        super.init(frame: frame)
        backgroundColor = .black

        addSubview(pageNameLabel)
        pageNameLabel.snp.makeConstraints { make in
            make.leading.top.equalToSuperview().offset(10)
        }

        addSubview(sectionIconImageView)
        sectionIconImageView.snp.makeConstraints { make in
            make.leading.equalToSuperview().offset(10)
            make.top.greaterThanOrEqualTo(pageNameLabel).offset(10)
            make.bottom.equalToSuperview().inset(10)
        }

        addSubview(sectionNameLabel)
        sectionNameLabel.snp.makeConstraints { make in
            make.leading.equalTo(sectionIconImageView.snp.trailing).offset(4)
            make.centerY.equalTo(sectionIconImageView)
        }

        addSubview(cellDivider)
        cellDivider.snp.makeConstraints { make in
            make.top.equalTo(snp.bottom).inset(1)
            make.leading.trailing.equalToSuperview()
            make.height.equalTo(1)
        }
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    //MARK: - Helpers
    private func configureUI() {
                // .normalAlarm이라면 pageNameLabel과 sectionIconImageView 숨기기
        if type == .normalAlarm {
            pageNameLabel.snp.makeConstraints { make in
                make.height.equalTo(0)
            }

            sectionIconImageView.isHidden = true
            sectionNameLabel.snp.remakeConstraints { make in
                make.leading.equalToSuperview().offset(10)
                make.bottom.equalToSuperview().inset(10)
            }
            sectionNameLabel.text = "기타"
        }
    }
}

구글링을 해서 콜렉션 뷰에서 섹션 헤더는 UICollectionReusableView로 만들어야한다는 사실을 배웠다. 그리고 위와 같이 구현하였다.

  • Cell을 구현한 코드는 흔히 아는 UICollectionViewCell을 상속받아 구현하였기 때문에 생략

AlarmController.swift

class AlarmController: UIViewController {
        ... 생략 ...
        private let alarmCollectionView: UICollectionView = {
            let layout = UICollectionViewFlowLayout()
            layout.scrollDirection = .vertical
            layout.minimumLineSpacing = 1
            layout.minimumInteritemSpacing = 1
            let cv = UICollectionView(frame: .zero, collectionViewLayout: layout)
            cv.backgroundColor = .black
            return cv
        }()

        //MARK: - Lifecycle
    override func viewDidLoad() {
        super.viewDidLoad()
        configureDelegate()
        configureCollectionView()

        self.view.addSubview(headerView)
        headerView.delegate = self
        headerView.snp.makeConstraints { make in
            make.leading.top.trailing.equalTo(view.safeAreaLayoutGuide)
            make.height.equalTo(50)
        }

        self.view.addSubview(alarmCollectionView)
        alarmCollectionView.snp.makeConstraints { make in
            make.top.equalTo(headerView.snp.bottom)
            make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide)
        }
    }

        //MARK: - Helpers
    private func configureDelegate() {
        alarmCollectionView.delegate = self
        alarmCollectionView.dataSource = self
    }

    private func configureCollectionView() {
        alarmCollectionView.register(SleepAlarmCollectionViewCell.self, forCellWithReuseIdentifier: SleepAlarmCollectionViewCell.identifier)
        alarmCollectionView.register(AlarmCollectionViewCell.self, forCellWithReuseIdentifier: AlarmCollectionViewCell.identifier)
        alarmCollectionView.register(AlarmSectionHeaderCollectionReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: AlarmSectionHeaderCollectionReusableView.identifier)
    }
}

//MARK: - UICollectionViewDataSource
extension AlarmController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
        if kind == UICollectionView.elementKindSectionHeader {
            guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: AlarmSectionHeaderCollectionReusableView.identifier, for: indexPath) as? AlarmSectionHeaderCollectionReusableView else { return UICollectionReusableView() }

            if indexPath.section == 1 {
                header.type = .normalAlarm
            }

            if header.pageNameLabel.frame.height != 0 {
                pageNameLabelHeight = header.pageNameLabel.frame.height
            }

            return header
        } else {
            return UICollectionReusableView()
        }
    }


    func numberOfSections(in collectionView: UICollectionView) -> Int {
        return 2
    }

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        if section == 0 {
            return 1
        } else {
            return 10
        }
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {

        if indexPath.section == 0 {
            guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: SleepAlarmCollectionViewCell.identifier, for: indexPath) as? SleepAlarmCollectionViewCell else { return UICollectionViewCell() }

            return cell
        } else {
            guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: AlarmCollectionViewCell.identifier, for: indexPath) as? AlarmCollectionViewCell else { return UICollectionViewCell() }

            return cell
        }
    }
}

//MARK: - UICollectionViewDelegateFlowLayout
extension AlarmController: UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {

        if indexPath.section == 0 {
            return CGSize(width: collectionView.frame.width, height: 60)
        } else {
            return CGSize(width: collectionView.frame.width, height: 100)
        }
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
        if section == 0 {
            return CGSize(width: view.frame.width, height: 100)
        }
        return CGSize(width: view.frame.width, height: 60)
    }
}
  • 콜렉션뷰는 viewForSupplementaryElementOfKind 함수를 사용하여 섹션 헤더를 추가해야 한다.

실행 화면

  • 의도대로 잘 동작하는 것을 확인할 수 있다.

발견한 점

  • 열심히 콜렉션 뷰로 구현을 했고 문제가 없었다.
  • 문제는 같은 뷰를 콜렉션 뷰를 테이블 뷰를 이용하여 구현하면 조금 다른 점이 발생한다.

테이블 뷰로 구현

Apple Developer Documentation

  • 테이블 뷰 헤더는 [UITableViewHeaderFooterView](https://developer.apple.com/documentation/uikit/uitableviewheaderfooterview) 로 구현해야 한다.
  • [tableView(_:viewForHeaderInSection:)](https://developer.apple.com/documentation/uikit/uitableviewdelegate/1614901-tableview) 함수를 사용하여 섹션 헤더를 추가한다.

실행 화면

  • 셀은 임의로 흰색 배경 기본 셀로 넣어서 테스트를 해봤는데 콜렉션 뷰와 다른 점을 발견했다.
  • 섹션 헤더가 Sticky가 되어서 스크롤을 해도 다음 섹션 헤더가 상단으로 올라오기 전까지 위치가 바뀌지 않았다.
  • 내가 원한건 콜렉션 뷰에서의 실행 화면처럼 다같이 스크롤이 되어야 하는데 헤더가 고정되어 있어서 놀랐다.
  • 구현 방식은 조금 달랐지만 콜렉션 뷰, 테이블 뷰 모두 섹션 헤더로 구현을 했지만 이 둘의 작동 방식이 다른 것 같다.
  • 위의 문제를 해결 해보고자 구글링을 열심히 한 결과 다음과 같은 해결책을 찾았다.

관련 링크

TIL/TableView section에서 sticky header 해제하기.md at master · sujinnaljin/TIL

iOS ) UITableView달라진 점!!

  • 테이블 뷰를 코드로 선언해 줄 때 기본 타입이 Plain인데 이때는 Sticky header이다. 따라서 테이블 뷰를 init 할 때 type을 Grouped로 바꾸어 주어야 한다.
private let tableView = UITableView(frame: CGRect.zero, style: .grouped)

결론

  • 콜렉션 뷰는 기본적으로 header가 sticky가 아니다.
  • 테이블 뷰는 기본적으로 header가 sticky이다.

댓글