본문 바로가기

iOS

[iOS] Calendar 직접 구현해보기 (1) - 한 달 달력

이번 프로젝트에서 달력을 개발해야 하는 일이 생겼어요.

그것도 일반 달력이 아니라 주간에서 월간으로 월간에서 주간으로 변하는 기능을 가진 달력을 만들어야 했습니다~

또한 이전 달, 다음 달로 이동하기 위해서 버튼을 누르는 것이 아니라 스크롤로 이동시키려고 합니다! 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의 데이터들을 업데이트하면 돼요! 하지만 저희는 스크롤을 해야합니다! 이건 다음 글에서 써보도록 할게요! 

참고