생성일: 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
함수를 사용하여 섹션 헤더를 추가해야 한다.
실행 화면
- 의도대로 잘 동작하는 것을 확인할 수 있다.
발견한 점
- 열심히 콜렉션 뷰로 구현을 했고 문제가 없었다.
- 문제는 같은 뷰를 콜렉션 뷰를 테이블 뷰를 이용하여 구현하면 조금 다른 점이 발생한다.
테이블 뷰로 구현
- 테이블 뷰 헤더는
[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
- 테이블 뷰를 코드로 선언해 줄 때 기본 타입이 Plain인데 이때는 Sticky header이다. 따라서 테이블 뷰를 init 할 때 type을 Grouped로 바꾸어 주어야 한다.
private let tableView = UITableView(frame: CGRect.zero, style: .grouped)
결론
- 콜렉션 뷰는 기본적으로 header가 sticky가 아니다.
- 테이블 뷰는 기본적으로 header가 sticky이다.
'iOS > UI' 카테고리의 다른 글
[Swift] iOS 네이버 지도 SDK - 마커 커스텀 (0) | 2023.07.12 |
---|---|
iOS UIScrollView 키보드 show/hide 시 스크롤 처리 (0) | 2023.03.15 |
CollectionView MultipleSelection (0) | 2023.02.15 |
FSCalendar 를 이용하여 캘린더 뷰 만들기 (0) | 2023.02.11 |
.grouped, .insetGrouped style tableView에서 위아래 여백 제거하기 (0) | 2023.02.11 |
댓글