이번 프로젝트에서 달력을 개발해야 하는 일이 생겼어요.
그것도 일반 달력이 아니라 주간에서 월간으로 월간에서 주간으로 변하는 기능을 가진 달력을 만들어야 했습니다~
또한 이전 달, 다음 달로 이동하기 위해서 버튼을 누르는 것이 아니라 스크롤로 이동시키려고 합니다! wow 너무나 설레네요! ㅋㅋㅋ ㅠ FSCalendar, CVCalendar 등과 같이 이미 완성된 달력을 제공하는 라이브러리들이 있지만 저는 UI구성에 라이브러리를 쓰는 것을 별로 선호하지 않는 관계로 직접 만들어보려구 해요 :)
먼저 지금 만들 달력의 특징은 두가지입니다. 위에 쓰긴 했지만 다시 생각해보자면 무한으로 스크롤이 되어야 한다는 것과 주간에서 월간으로 월간에서 주간으로 달력모드가 있어야 한다는 점입니다. 하지만 이 두가지를 하기 전에 달력 먼저 만들어야 했습니다.
한 달 달력 만들기
위 기능들을 다 재쳐두고 달력을 만들어 봅시다! 한달은 30,31로 이루어져 있고, 2월은 28일, 윤년에만 29일로 이루어져 있습니다. 그리고 매달 시작하는 주가 다릅니다. 따라서 달의 첫 번째 날이 어느 요일에 시작하는 지 구하고 윤년 계산을 해주면 한 달을 구할 수가 있습니다.
1️⃣ 달의 첫번째 날이 어느 요일에 시작하는 지 구하기
extension String {
static var dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
var date: Date? {
return String.dateFormatter.date(from: self)
}
}
String 타입에서 Date타입으로 간편하게 바꾸게 할 수 있습니다.
extension Date {
var weekday: Int {
get {
Calendar.current.component(.weekday, from: self)
}
}
var firstDayOfTheMonth: Date {
get {
Calendar.current.date(from: Calendar.current.dateComponents([.year,.month],from: self))!
}
}
}
Date타입에서 어느 요일을 가리키는지, 달의 첫번째 날(Date)을 구할 수 있습니다.
private func getCurrentFirstWeekday() -> Int {
let day = ("\(currentYear)-\(currentMonth)-01".date?.firstDayOfTheMonth.weekday)!
return day
}
이렇게 하면 달의 첫번째 날의 요일을 구할 수 있지만, 사실 위 함수는 두번을 체크해준 것입니다.
이것만 알면 로직은 끝이더라구요! 이제 UI를 구성해야 합니다 🙃
2️⃣ MonthView, WeekdayView 만들기
저는 달력을 총 세 가지의 View로 만들었습니다. (사실 이것도 다시 정리하고 싶어요. 나중에 가면 ScrollView를 담는 ContentView까지 만들어서 네 개로 바꾸고 싶네요..)
달력의 년도와 월을 나타내는 MonthView 하나, 월요일, 화요일, 수요일 등 요일을 나타내는 WeekdayView 하나, 1,2,3,4,5...31 의 일을 나타내는 CalendarView 하나 이렇게 총 세개입니다. 단지 예시를 위해서 만들었으니 참고만 해주세요.
1) MonthView
class MonthView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
configureAll()
presentedYear = Calendar.current.component(.year, from: Date())
presentedMonth = Calendar.current.component(.month, from: Date())
}
private func configureAll() {
setUpViews()
}
private func setUpViews() {
addSubview(monthLabel)
monthLabel.topAnchor.constraint(equalTo: topAnchor, constant: 0).isActive = true
monthLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0).isActive =true
monthLabel.leftAnchor.constraint(equalTo: leftAnchor, constant: 28).isActive =true
monthLabel.widthAnchor.constraint(equalToConstant: 200).isActive = true
monthLabel.text = "2021.06"
}
func updateYearAndMonth(to date: MyDateExpression) {
let year = date.year
let month = date.month
yearAndMonth = "\(year).\(month)"
}
// MARK: - Property
var yearAndMonth: String = "0000.00" {
didSet {
let year = yearAndMonth.split(separator: ".")[0]
var month = yearAndMonth.split(separator: ".")[1]
if Int(month)! < 10 {
month = "0" + month
}
yearAndMonth = String(year + "." + month)
monthLabel.text = yearAndMonth
}
}
var presentedMonth: Int = 0
var presentedYear: Int = 0
// MARK: - Views
var monthLabel: UILabel = {
let label = UILabel()
label.text = ""
label.textColor = UIColor.black
label.textAlignment = .left
label.font = UIFont.boldSystemFont(ofSize: 20)
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
required init?(coder: NSCoder) {
fatalError("DO Not Use on Storyboard")
}
}
monthLabel은 년도와 월을 나타내는 label입니다. updateYearAndMonth는 나중에 월이 바뀌면 label을 형식에 맞춰 업데이트 해주도록 만들었습니다.
2) WeekdayView
class WeekdayView: UIView {
// MARK: - Property
var weekdayArray: [String] = ["SUN","MON","TUE","WED","THU","FRI","SAT"]
// MARK: - Views
override init(frame: CGRect) {
super.init(frame: frame)
setWeekCollectionView()
}
private func setWeekCollectionView() {
addSubview(weekdayCollectionView)
weekdayCollectionView.topAnchor.constraint(equalTo: topAnchor).isActive = true
weekdayCollectionView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive =true
weekdayCollectionView.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
weekdayCollectionView.rightAnchor.constraint(equalTo: rightAnchor).isActive =true
}
lazy var weekdayCollectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.sectionInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
let collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout:layout)
collectionView.backgroundColor = UIColor.white
collectionView.showsVerticalScrollIndicator = false
collectionView.showsHorizontalScrollIndicator = false
collectionView.allowsSelection = false
collectionView.isScrollEnabled = false
collectionView.delegate = self
collectionView.dataSource = self
collectionView.register(WeekdayCVCell.self, forCellWithReuseIdentifier:WeekdayCVCell.identifier)
collectionView.translatesAutoresizingMaskIntoConstraints = false
return collectionView
}()
required init?(coder: NSCoder) {
fatalError("DO Not Use on Storyboard")
}
}
extension WeekdayView: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSectionsection: Int) -> Int {
return 7
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath:IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier:WeekdayCVCell.identifier, for: indexPath) as? WeekdayCVCell else { returnUICollectionViewCell() }
cell.configureWeekday(to: weekdayArray[indexPath.row])
return cell
}
}
extension WeekdayView: UICollectionViewDelegateFlowLayout, UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout:UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
return 0
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout:UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
return 0.0
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout:UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let width = (collectionView.frame.width - 32) / 7
let height: CGFloat = 11
return CGSize(width: width, height: height)
}
}
class WeekdayCVCell: UICollectionViewCell {
static let identifier: String = "WeekdayCVCell"
// MARK: - Views
let weekdayLabel: UILabel = {
var label = UILabel()
label.text = "MON"
label.textAlignment = .center
label.textColor = .lightGray
label.font = UIFont.systemFont(ofSize: 10)
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
setUpView()
}
private func setUpView() {
addSubview(weekdayLabel)
weekdayLabel.topAnchor.constraint(equalTo: topAnchor).isActive = true
weekdayLabel.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true
weekdayLabel.leftAnchor.constraint(equalTo: leftAnchor).isActive = true
weekdayLabel.rightAnchor.constraint(equalTo: rightAnchor).isActive = true
}
func configureWeekday(to week: String) {
weekdayLabel.text = week
}
required init?(coder: NSCoder) {
fatalError("do not use in storyboard")
}
}
WeekdayView는 CollectionView로 주간을 만들었습니다. StackView로 만들 수는 있으나 나중에 CalendarView 구성을 CollectionView로 하기 때문에 맞추면 constraint를 잡기 더 편할 것 같아서 같은 것으로 구성했습니다.
3️⃣ CalenarView 만들기
CalendarView는 코드가 쫌 길어서 부분부분 설명할게요.
먼저, MonthView와 WeekdayView를 넣어줍니다. 이건 생략하도록 할게요!
그리고 CalendarView안에는 한 달이 몇 일로 이루어져있는지 알아야 하므로 아래 배열을 만들어줍니다.
var numOfDaysInMonth = [31,28,31,30,31,30,31,31,30,31,30,31]
다음 CollectionView를 만들어줄거예요.
lazy var calendarCollectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.sectionInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
let collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: layout)
collectionView.backgroundColor = .white
collectionView.showsHorizontalScrollIndicator = false
collectionView.showsVerticalScrollIndicator = false
collectionView.translatesAutoresizingMaskIntoConstraints = false
collectionView.allowsMultipleSelection = false
collectionView.delegate = self
collectionView.dataSource = self
collectionView.register(DateCVCell.self, forCellWithReuseIdentifier: "DateCVCell")
collectionView.collectionViewLayout.invalidateLayout()
return collectionView
}()
extension CalendarView: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
// 한달 수 + 앞에 이전 달 수
let minimumCellNumber = numOfDaysInMonth[presentedMonth-1] + firstWeekdayOfPresentedMonth - 1
// 7의 배수여야 하므로
let dateNumber = minimumCellNumber % 7 == 0 ? minimumCellNumber : minimumCellNumber + (7 - (minimumCellNumber%7))
return dateNumber
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: DateCVCell.identifier, for: indexPath) as? DateCVCell else { return UICollectionViewCell() }
let startWeekdayOfMonthIndex = firstWeekdayOfPresentedMonth - 1
let minimumCellNumber = numOfDaysInMonth[presentedMonth-1] + firstWeekdayOfPresentedMonth - 1
if indexPath.item < startWeekdayOfMonthIndex {
// 이전 달의 부분
// 색깔 회색 처리
let previousMonth = presentedMonth < 2 ? 12 : presentedMonth - 1
let previousMonthDate = numOfDaysInMonth[previousMonth-1]
let date = previousMonthDate - (startWeekdayOfMonthIndex-1) + indexPath.row
cell.configureDate(to: date)
cell.isPreviousMonthDate()
} else if indexPath.item >= minimumCellNumber {
// 다음 달의 부분
let date = indexPath.item - minimumCellNumber + 1
cell.configureDate(to: date)
cell.isFollowingMonthDate()
} else {
let date = indexPath.row - startWeekdayOfMonthIndex + 1
cell.configureDate(to: date)
}
return cell
}
}
이 부분은 한 달을 나타내는 달력을 만들 때,
시작하는 요일이 수요일이라면 일,월,화는 아무 것도 회색처리를 하고 이전 달의 일을 보여주기 위해서,
또 31일인데 7의 배수로 깔끔하게 끝내고 싶기 때문에 남은 부분은 다음 달의 일을 보여주고 회색처리를 해주는 코드입니다.
다른 코드들은 생략하도록 할게요! 너무 똥같기 때문에 부끄러워서...
이렇게 한다면 한 달 달력을 만들 수 있습니다 ㅎㅎ 하지만 이건 달력이 아니라 한달만 보여주는 달력이라 완성이 아직 아닙니다.
사실 이전 달, 다음 달 버튼이 있다면 UI를 구성하는데 더 쉬울거예요. 위처럼 만든 달력에서 버튼을 두개 추가해주고 버튼을 누르면 위의 collectionView의 데이터들을 업데이트하면 돼요! 하지만 저희는 스크롤을 해야합니다! 이건 다음 글에서 써보도록 할게요!
참고
'iOS' 카테고리의 다른 글
[WWDC 19] 디퍼블 데이터 소스 (0) | 2021.12.30 |
---|---|
[iOS] Calendar 직접 만들어보기 (2) - 무한 수평 스크롤, 주간 월간 캘린더 전환 (2) | 2021.07.08 |
[CoreAnimation] 애니메이션 구현 - CoreBasicAnimation (0) | 2021.05.02 |
[Moya] 란? (0) | 2021.04.18 |
[NMapsMap] Naver 지도 API 사용해보자 (3) | 2020.12.13 |