이번 포스팅에선 다음과 같이 날짜의 선택이 가능한 Custom Calendar를 구현해 보자.
전체 코드는 여기에서 확인해볼 수 있다.
https://github.com/jungseokyoung-cloud/iOS-Study/tree/main/Custom%20Calendar
Month CollectionView
우선, 하나의 달만 표시하는 View부터 만들어보자.
여기서 각 달의 날짜들은 선택이 가능하기 때문에, 이를 CollectionView로 구현을 했다.
DayCell
우선 각 DayCell들을 init단계에서 다음과 같이 Layout을 잡아준다.
final class DayCell: UICollectionViewCell {
...
// MARK: - Initializers
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
...
}
// MARK: - UI Methods
private extension DayCell {
func setupUI() {
setViewHierarchy()
setConstraints()
}
func setViewHierarchy() {
contentView.addSubview(circularView)
circularView.addSubview(label)
}
func setConstraints() {
label.snp.makeConstraints { $0.center.equalToSuperview() }
circularView.snp.makeConstraints { $0.edges.equalToSuperview() }
}
}
이후, 선택했을 때의 UI를 변경하기 위해서 다음과 같이 isSelected 프로퍼티를 오버라이드해, 다음과 같이 UI를 변경해 주자.
final class DayCell: UICollectionViewCell {
override var isSelected: Bool {
didSet {
self.setupUI(for: isSelected)
}
}
...
}
Layout
func collectionViewLayout() -> UICollectionViewCompositionalLayout {
let itemSize = NSCollectionLayoutSize(
widthDimension: .absolute(itemHeight),
heightDimension: .fractionalHeight(1)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(itemHeight)
)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
group.interItemSpacing = .fixed(itemSpacing)
let section = NSCollectionLayoutSection(group: group)
section.interGroupSpacing = lineSpacing
return UICollectionViewCompositionalLayout(section: section)
}
우선, Layout의 경우에는 다음과 같다. 위에서 보았듯이, 각 cell들이 원형이기 때문에, 높이와 넓이는 같아야 한다.
다음으로 CollectionView의 넓이와 높이를 구해보면 다음과 같다.
가로축으로는 7개의 DayCell이 들어가게 되고, 각 itemSpacing만큼 공간이 생긴다.
세로축으로는 5개의 DayCell이 들어가게 되고, 각 lineSpacing만큼 공간이 생긴다.
이를 코드로 옮겨보면 다음과 같다.
private var calendarSize: CGSize {
return CGSize(
width: itemHeight * 7 + itemSpacing * 6,
height: itemHeight * 5 + lineSpacing * 4
)
}
이렇게 되면, 외부에서 itemSize와 itemSpacing, lineSpacing을 설정할 수 있게 되며, 이를 바탕으로 동적인 사이즈의 CollectionView가 만들어진다.
여기까지 오게 되면 다음과 같다.
Calendar Collection View
각 달의 달력을 좌우로 넘겨 볼 수 있게 하기 위해, CollectionView의 Paging 기능을 활용하기로 했다.
즉, 위에서 구현한 MonthCollectionView를 Cell로 만들어야 한다.
Calendar Cell
다음과 같이 Cell을 생성하고 위에서 작성한 코드를 다음과 같이 아래로 이동시켜 주자.
/// 특정한 달의 달력을 표시하는 Cell입니다.
final class CalendarCell: UICollectionViewCell {
private var itemHeight: CGFloat = 0
private var itemSpacing: CGFloat = 0
private var lineSpacing: CGFloat = 0
// MARK: - UI Components
private let monthCollectionView: UICollectionView = {
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewLayout())
collectionView.register(DayCell.self, forCellWithReuseIdentifier: "DayCell")
collectionView.isScrollEnabled = false
return collectionView
}()
// MARK: - Initializers
override init(frame: CGRect) {
super.init(frame: frame)
monthCollectionView.dataSource = self
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
super.init(coder: coder)
}
// MARK: - Configure Method
func configure(
itemHeight: CGFloat,
itemSpacing: CGFloat,
lineSpacing: CGFloat
) {
self.itemHeight = itemHeight
self.itemSpacing = itemSpacing
self.lineSpacing = lineSpacing
monthCollectionView.collectionViewLayout = collectionViewLayout()
}
}
이후, 다음과 같이 DataSource 메서드를 구현해 준다.
// MARK: - UICollectionViewDataSource
extension CalendarCell: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 35
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: "DayCell",
for: indexPath
) as? DayCell else {
return UICollectionViewCell()
}
cell.configure("0")
return cell
}
}
CollectionView
이제 Paging 기능을 활용한 CollectionView를 구현해야 한다.
// MARK: - UI Components
private let calendarCollectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal // Scroll 방향 설정
layout.minimumLineSpacing = 0 // Page간의 간격 없애기 위함
let collectionView = UICollectionView(
frame: .zero,
collectionViewLayout: layout
)
collectionView.register(CalendarCell.self, forCellWithReuseIdentifier: "CalendarCell")
collectionView.showsHorizontalScrollIndicator = false
collectionView.isPagingEnabled = true
return collectionView
}()
이후, UICollectionViewDelegateFlowLayout의 sizeForItemAt메서드를 통해 Cell의 size를 collectionView와 동일하게 지정해 주자.
extension ViewController: UICollectionViewDelegateFlowLayout {
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath
) -> CGSize {
return collectionView.frame.size
}
}
WeekView
다음으로 요일을 나타내는 View를 추가해 주자. 해당 View는 Paging이 되면 안 되기 때문에, CalendarCell와 분리했다.
final class WeekView: UIStackView {
private let daysOfWeek = ["일", "월", "화", "수", "목", "금", "토"]
// MARK: - Initalizers
init(spacing: CGFloat = 0) {
super.init(frame: .zero)
axis = .horizontal
alignment = .center
distribution = .fillEqually
self.spacing = spacing
setupUI()
}
@available(*, unavailable)
required init(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
// MARK: - UI Methods
private extension WeekView {
func setupUI() {
setViewHierarchy()
}
func setViewHierarchy() {
daysOfWeek.forEach { addArrangedSubview(label($0)) }
self.arrangedSubviews.forEach {
$0.snp.makeConstraints {
$0.width.equalTo(14)
}
}
}
}
// MARK: - Private Methods
private extension WeekView {
func label(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.textColor = .systemGray3
label.font = .systemFont(ofSize: 14)
return label
}
}
다음으로 각 label의 center가 CalendarCell의 열과 일치해야 한다.
즉, StackView의 horizontal spacing은 다음과 같이 정의된다.
private var weekLabelSpacing: CGFloat {
return itemHeight + itemSpacing - 14 // label width = 14
}
이후 레이아웃을 잡아주게 되면, 다음과 같이 완성된다.
calendarCollectionView.snp.makeConstraints {
$0.top.equalTo(weekView.snp.bottom)
$0.size.equalTo(calendarSize)
$0.centerX.bottom.equalToSuperview()
}
weekView.snp.makeConstraints {
$0.top.centerX.equalToSuperview()
$0.leading.equalTo(calendarCollectionView).offset(itemHeight/2 - 7)
}
여기까지 완료하게 되면, 다음과 된다.
CalendarDate
다음으로 0으로 채워진 것을 실제 달력 데이터로 변환해주자.
CalendarDate는 Calendar를 구현하는 데 있어서 사용되는 데이터 타입니다.
우선적으로 CalendarDate는 DateType을 다음과 같이 구분한다.
/// 날짜의 타입입니다.
enum DateType {
/// 시작 날짜
case startDate
/// 선택이 가능한 날짜
case `default`
/// 선택이 불가능한 날짜
case disabled
}
struct CalendarDate {
var year: Int
var month: Int
var day: Int
var type: DateType
var date: Date {
let string = "\(year)-\(month)-\(day)"
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
let date = dateFormatter.date(from: string) ?? .now
return date
}
}
// MARK: - Initalizers
extension CalendarDate {
init(date: Date) {
let calendar = Calendar.current
let components = calendar.dateComponents([.year, .month, .day], from: date)
self.year = components.year ?? 0
self.month = components.month ?? 0
self.day = components.day ?? 0
self.type = .default
}
}
이후 다음과 같은, 유틸 메서드를 추가한다.
// MARK: - Methods
extension CalendarDate {
/// 다음날짜의 `CalendarDate`를 리턴합니다.
func nextDay() -> CalendarDate {
if day == self.daysOfMonth() {
return nextMonth()
} else {
return CalendarDate(year: year, month: month, day: day + 1, type: type)
}
}
/// 다음달의 `CalendarDate`를 리턴합니다.
func nextMonth() -> CalendarDate {
if month == 12 {
return CalendarDate(year: year + 1, month: 1, day: day, type: type)
} else {
return CalendarDate(year: year, month: month + 1, day: day, type: type)
}
}
/// 이전달의 `CalendarDate`를 리턴합니다.
func previousMonth() -> CalendarDate {
if month == 1 {
return CalendarDate(year: year - 1, month: 12, day: day, type: type)
} else {
return CalendarDate(year: year, month: month - 1, day: day, type: type)
}
}
/// 현재 `Date`가 paramter의 `date`보다 더 작다면 true를 리턴합니다.
func compareYearAndMonth(with date: CalendarDate) -> Bool {
if year < date.year {
return true
} else if year == date.year && month <= date.month {
return true
} else {
return false
}
}
/// `year`, `month`의 첫번째 요일을 정수형으로 리턴합니다.
func startDayOfWeek() -> Int {
let calendar = Calendar.current
let components = DateComponents(year: year, month: month)
let date = calendar.date(from: components) ?? Date()
return calendar.component(.weekday, from: date) - 1
}
/// `year`, `month`의 총 날짜 수를 리턴합니다.
func daysOfMonth() -> Int {
let calendar = Calendar.current
let components = DateComponents(year: year, month: month)
let date = calendar.date(from: components) ?? Date()
return calendar.range(of: .day, in: .month, for: date)?.count ?? 0
}
}
DayCell
DayCell의 경우에는, DateType별로 UI가 달라지기 때문에 다음과 같은 메서드를 정의한다.
// MARK: - Configure
func configure(
_ day: String,
type: DateType
) {
self.label.text = day
self.type = type
switch type {
case .default:
self.isUserInteractionEnabled = true
case .disabled, .startDate:
self.isUserInteractionEnabled = false
}
setupUI(for: type, isSelected: isSelected)
}
func setupUI(for type: DateType, isSelected: Bool) {
label.textColor = textColor(for: type, isSelected: isSelected)
circularView.backgroundColor = backgroundColor(for: type, isSelected: isSelected)
}
CalendarCell
이에 맞춰 CalendarCell의 경우에도 다음과 같이 변경해 준다.
final class CalendarCell: UICollectionViewCell {
...
private var dataSource: [CalendarDate] = [] {
didSet {
monthCollectionView.reloadData()
}
}
// MARK: - UI Components
private let monthCollectionView: UICollectionView = {
...
}()
...
// MARK: - Configure Method
func configure(
_ dataSource: [CalendarDate],
itemHeight: CGFloat,
itemSpacing: CGFloat,
lineSpacing: CGFloat
) {
self.dataSource = dataSource
self.itemHeight = itemHeight
self.itemSpacing = itemSpacing
self.lineSpacing = lineSpacing
monthCollectionView.collectionViewLayout = collectionViewLayout()
}
}
// MARK: - UICollectionViewDataSource
extension CalendarCell: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return dataSource.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: "DayCell",
for: indexPath
) as? DayCell else {
return UICollectionViewCell()
}
let calendarDate = dataSource[indexPath.row]
cell.configure("\(calendarDate.day)", type: calendarDate.type)
return cell
}
}
날짜 구하기
이제 마지막으로 CalendarCell에 들어갈 데이터들을 생성한다.
func dataSource(from startDate: Date, to endDate: Date) -> [[CalendarDate]] {
var dataSource = [[CalendarDate]]()
// 시작 날짜
let startCalendarDate = CalendarDate(date: startDate)
// 종료 날짜
let endCalendarDate = CalendarDate(date: endDate)
var currentCalendarDate = startCalendarDate
currentCalendarDate.day = 1
var lastMonthCalendarDate = startCalendarDate.previousMonth()
...
}
다음으로 각 달 별로 CalendarDate를 생성한다.
func dataSource(from startDate: Date, to endDate: Date) -> [[CalendarDate]] {
...
while currentCalendarDate.compareYearAndMonth(with: endCalendarDate) {
var daysOfMonth = [CalendarDate]()
let firstDayOfWeek = currentCalendarDate.startDayOfWeek()
let totalDays = currentCalendarDate.daysOfMonth()
let lastMonthTotalDays = lastMonthCalendarDate.daysOfMonth()
// 첫 주의 빈 공간을 저번달로 채웁니다.
for count in (0..<firstDayOfWeek) {
var calendarDate = currentCalendarDate
calendarDate.day = lastMonthTotalDays - firstDayOfWeek + count + 1
calendarDate.type = .disabled
daysOfMonth.append(calendarDate)
}
// 이번달을 채웁니다.
var calendarDate = currentCalendarDate
calendarDate.day = 1
for _ in (0..<totalDays) {
if calendarDate < startCalendarDate {
calendarDate.type = .disabled
} else if calendarDate == startCalendarDate {
calendarDate.type = .startDate
} else if calendarDate >= endCalendarDate {
calendarDate.type = .disabled
} else {
calendarDate.type = .default
}
daysOfMonth.append(calendarDate)
calendarDate = calendarDate.nextDay()
}
lastMonthCalendarDate = currentCalendarDate
currentCalendarDate = currentCalendarDate.nextMonth()
currentCalendarDate.day = 1
// 마지막 주 빈 공간을 다음달로 채웁니다.
calendarDate = currentCalendarDate
calendarDate.type = .disabled
while daysOfMonth.count < 35 {
daysOfMonth.append(calendarDate)
calendarDate = calendarDate.nextDay()
}
dataSource.append(daysOfMonth)
}
return dataSource
}
코드는 길지만, 로직은 간단하다.
우선, 앞에 빈 공간은 이전 달의 데이터로 채운다.
이때, 이전 달의 데이터는 선택이 되면 안 되기 때문에, DateType을 disabled로 저장한다.
다음으로 이번달의 데이터를 채운다. 이때, startDate를 기준으로 이전의 날짜는 선택이 안되기 때문에,
startDate가 있는 달의 경우, startDate이전은 DateType을 disabled로 저장한다.
마지막으로 마지막 주에 빈 공간이 생긴다면, 다음 달의 달력으로 이를 채운다.
이렇게 생성된 데이터를 DataSource로 전달해 주면 다음과 같이 완성된다.
'iOS > iOS' 카테고리의 다른 글
[iOS] Custom 영상길이 조절 SliderBar 구현하기 (2) | 2024.12.14 |
---|---|
[iOS] Custom Calendar 구현(2) (1) | 2024.05.15 |
[iOS] Custom Drop Down (2) - firstResponder 활용해 리팩토링하기 (0) | 2024.03.27 |
[iOS] Core Animation(2) - CAAnimation (0) | 2024.03.07 |
[iOS] Core Animation(1) - Concept (0) | 2024.03.05 |