Skip to content

FSCalendar 걷어내기

DOHYUN CHUNG edited this page Sep 2, 2022 · 6 revisions

FSCalendar를 걷어낸 이유

FSCalendar를 걷어낸 이유는 FSCalendar로 디자인을 맞추는 것이 어려웠기 때문이다. FSCalendar는 달력을 이용할 때 편리한 라이브러리이지만, MOMO를 개발할 때는 맞지 않았고, 그에 따라 달력을 만들었다.

달력화면

달력

CalendarUseCase

달력에 필요한 로직은 CalendarUseCase 객체에 존재한다. 달력을 만들때, Raywenderlich의 달력만들기 코드를 참고해서 만들었다.

현재 달의 메타데이터를 구하기

현재 달의 메타데이터의 구성은 다음과 같다. 메타데이터를 활용해서 달력을 만들 수 있다. 첫째 날이 무슨 요일인지 확인하는 이유는 달력의 경우 해당 달의 첫째 날 앞에 전 달의 날짜를 붙여주기 때문이다.

  • 현재 달의 총 날짜 Int
  • 현재 달의 첫째 날 Date
  • 첫째 날이 무슨 요일인지 Int
  private func monthMetadata(for baseDate: Date) throws -> MonthMetadata {
    guard let numberOfDaysInMonth = calendar.range(of: .day, in: .month, for: baseDate)?.count, let firstDayOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: baseDate)) else {
      throw CalendarDataError.metadataGeneration
    }
    
    let firstDayWeekday = calendar.component(.weekday, from: firstDayOfMonth) // 첫째날이 무슨요일인지 구하는것은 앞에 요일을 붙여주기 위한 과정
    
    return MonthMetadata(numberOfDays: numberOfDaysInMonth, firstDay: firstDayOfMonth, firstDayWeekday: firstDayWeekday)
    
  }

Day 객체 만들기

Day 객체는 달력의 칸 안에 들어있는 날짜의 객체이다. Day의 객체의 구성요소는 다음과 같다.

  • date Date 날짜
  • number String 달력에서 보여줘야하는 숫자
  • isSelected Bool 눌렸을 때
  • isWithDisplayMonth Bool 현재 달력에서 보이는지
  • mood DiaryEmotion 감정 (모모에서는 감정일기이고, 달력에서 그날의 감정을 보여줘야하기 때문에 존재한다.) 이러한 Day 객체를 만들때 private func generatedDay(offsetBy dayOffset: Int, for baseDate: Date, isWithinDisplayedMonth: Bool) -> Day 함수를 사용한다.
  private func generatedDay(offsetBy dayOffset: Int, for baseDate: Date, isWithinDisplayedMonth: Bool) -> Day {
    let date = calendar.date(byAdding: .day, value: dayOffset, to: baseDate) ?? baseDate
    
    return Day(date: date, number: dateFormatter.string(from: date), isSelected: calendar.isDate(date, inSameDayAs: selectedDate), isWithDisplayMonth: isWithinDisplayedMonth, mood: .unknown)
    
  }

이달에 보여지는 Day의 배열을 찾기

Day의 배열을 찾을 때, numberOfDayInMonthoffSetInInitialRow을 더한다. offSetInInitialRow 은 첫번째 행에서 앞에 더 붙은 몇일을 뜻한다. 따라서 만약에 특정 달이 금요일부터 시작하면, offSetInInitialRow가 추가의 5일을 붙여줄 것이다. 그리고 map을 사용해서 [Day]로 range를 변경해준다.

var days: [Day] = (1..<(numberOfDaysInMonth + offSetInInitialRow))
      .map { day in
        // day가 offsetInInitalRow보다 크다면, 해당달에 존재한다는 의미
        let isWithinDisplayedMonth = day >= offSetInInitialRow
        
        // 달의 첫날과의 거리를 찾는다. 음수가 나오면 현재달에 없다는 것 이때는 -을 붙인다.
        let dayOffset = isWithinDisplayedMonth ? day - offSetInInitialRow : -(offSetInInitialRow - day)
        
        return generatedDay(offsetBy: dayOffset, for: firstDayOfMonth, isWithinDisplayedMonth: isWithinDisplayedMonth)
      }

마지막날이 토요일로 끝나지 않을 경우, 다음 달의 날짜를 가져와서 뒤에 붙여줘야한다.

해당 달의 마지막 날을 찾아, 그날의 요일을 찾는다. 그날의 요일이 나왔을 경우, 7에서 빼서, 뒤에 붙여줘야하는지 파악할 수 있다. 그리고 추가적인 날을 찾아서 Day 객체로 만들어준다.

  private func generateStartOfNextMonth(using firstDayOfDisplayedMonth: Date) -> [Day] {
    
    guard let lastDayInMonth = calendar.date(byAdding: DateComponents(month: 1, day: -1), to: firstDayOfDisplayedMonth) else {return []}
    
    let additionalDays = 7 - calendar.component(.weekday, from: lastDayInMonth)
    // 토요일로 끝났으면 return []
    guard additionalDays > 0 else {return []}
    
    let days: [Day] = (1...additionalDays)
      .map { generatedDay(offsetBy: $0, for: lastDayInMonth, isWithinDisplayedMonth: false)}
    
    return days
  }

추가적인 날을 day 배열로 만든 다음에, 위에서 만든 day 배열 뒤에 붙여준다.

days += generateStartOfNextMonth(using: firstDayOfMonth)

만들어진 [day]에 Emotion 넣어주기

day 객체에는 Emotion이 필요하다. 이러한 Emotion을 가져오기 위해서 repository 객체에서 readEmotion 메서드를 사용해서 가져오고, day 객체에 추가해준다.[Day]에 전부 추가해준다음에, Observable로 래핑해준다음에 반환한다.

return self.repository.readEmotions(from: days.first!.date, to: days.last!.date)
      .map { arr  -> [Day] in
        var newDays = days
        arr.forEach { (date, mood) in
          if let index = newDays.firstIndex(where: { $0.date == date}) {
            newDays[index].mood = mood
          }
        }
        return newDays
      }
      .share()

해당 함수의 전체코드는 다음과 같다

 func generateDaysInMonth(for baseDate: Date) -> Observable<[Day]> {
    
    guard let metadata = try? monthMetadata(for: baseDate) else {
      fatalError("\(baseDate)가 애러를 발생시킴")
    }
    
    let numberOfDaysInMonth = metadata.numberOfDays
    let offSetInInitialRow = metadata.firstDayWeekday
    let firstDayOfMonth = metadata.firstDay

    //만약에 month가 sunday부터 시작을 하지 않는 이상, 전달의 days가 앞에 붙게 된다. 이를 해결하기위해서 range를 만드는데,
    // 예를 들어, Friday에서부터 시작하면, offsetInInitailRow가 추가의 5일을 부텨줄것이다. 그리고 map을 사용해서 [Day]로 range를 변경해준다
    
    var days: [Day] = (1..<(numberOfDaysInMonth + offSetInInitialRow))
      .map { day in
        let isWithinDisplayedMonth = day >= offSetInInitialRow
        
        // 달의 첫날과의 거리를 찾는다. 음수가 나오면 현재달에 없다는 것 이때는 -을 붙인다.
        let dayOffset = isWithinDisplayedMonth ? day - offSetInInitialRow : -(offSetInInitialRow - day)
        
        return generatedDay(offsetBy: dayOffset, for: firstDayOfMonth, isWithinDisplayedMonth: isWithinDisplayedMonth)
      }
      
    days += generateStartOfNextMonth(using: firstDayOfMonth)
  
    // days는 기본Emotion은 .unknown임, readEmotion을 통해서 기간동안의 date를 가져오고, 이를 통해서 기본 days에 date를 비교한뒤에 변경한다.
    return self.repository.readEmotions(from: days.first!.date, to: days.last!.date)
      .map { arr  -> [Day] in
        var newDays = days
        arr.forEach { (date, mood) in
          if let index = newDays.firstIndex(where: { $0.date == date}) {
            newDays[index].mood = mood
          }
        }
        return newDays
      }
      .share()

  }

protocol CalendarUseCase

Calendar를 보여줘야하는 View에는 Calendar의 로직이 필요하다. 그래서 해당 ViewModel에서 CalendarUsecase 객체를 주입받고 있어야한다. numberOfWeeksInBaseDate 의 존재 이유는 ViewModel이 객체를 주입받고, ViewModel은 numberOfWeeksInBaseDate를 ViewController에 데이터바인딩 할 것이다. 이는 후에 달력이 4주인지 5주인지를 파악해서 달력컬렉션뷰의 높이를 판단할 수 있게 만든다.

protocol CalendarUseCase {
  func generateDaysInMonth(for baseDate: Date) -> Observable<[Day]>
  var numberOfWeeksInBaseDate: Int { get }
}