이번 포스팅에선 다음과 같이 날짜의 선택이 가능한 Custom Calendar를 구현해 보자. 

 

 

전체 코드는 여기에서 확인해볼 수 있다. 

https://github.com/jungseokyoung-cloud/iOS-Study/tree/main/Custom%20Calendar

 

iOS-Study/Custom Calendar at main · jungseokyoung-cloud/iOS-Study

iOS Study Archive. Contribute to jungseokyoung-cloud/iOS-Study development by creating an account on GitHub.

github.com

 

 

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
  )
}

 

이렇게 되면, 외부에서 itemSizeitemSpacing, 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
}()

 

이후, UICollectionViewDelegateFlowLayoutsizeForItemAt메서드를 통해 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로 전달해 주면 다음과 같이 완성된다. 

 

복사했습니다!