본문 바로가기
감정일기(가칭)

커스텀 캘린더 ViewModel, 이렇게 만들면 됩니다 (CalendarViewModel 완전 해부)

by 밤새는 탐험가89 2025. 11. 10.
728x90
SMALL

목표

UIKit으로 무한 월 스크롤이 가능한 달력을 만들 때,
UI는 단순하게 유지하고 날짜 계산은 전부 ViewModel에서 처리하도록 설계합니다.

 

이 글에서는 제가 사용한 CalendarViewModel의 전체 구조와
각 함수가 정확히 무엇을 하는지, 초보자도 따라 할 수 있게 설명합니다.


전체 코드 (최종본)

// MARK: - Enum
enum CalendarMode {
    case dateOnly          // 날짜만 표시
    case withEmotion       // 날짜 아래 감정 이모티콘 표시 (추후 확장용)
}


// MARK: - ViewModel
@MainActor
final class CalendarViewModel: ObservableObject {
    
    // MARK: - Published Properties
    @Published private(set) var months: [Date] = []     // [이전달, 현재달, 다음달]
    @Published private(set) var currentMonth: Date      // 현재 월
    
    let calendar: Calendar
    
    // MARK: - DateFormatter (캐싱)
    private static let headerFormatter: DateFormatter = {
        let f = DateFormatter()
        f.locale = Locale(identifier: "ko_KR")
        f.dateFormat = "yyyy년 M월"
        return f
    }()
    
    // MARK: - 표시 모드
    @Published private(set) var mode: CalendarMode

    // MARK: - Init
    init(initialDate: Date = Date(), mode: CalendarMode = .dateOnly) {
        var cal = Calendar(identifier: .gregorian)
        cal.locale = .autoupdatingCurrent
        cal.timeZone = .autoupdatingCurrent
        cal.firstWeekday = 1 // 한국(일요일 시작) 기준. 필요시 2(월요일 시작)로 바꿔도 됨.
        self.calendar = cal
        self.mode = mode
        
        // ✅ 초기 기준 월 (선택한 날짜 or 오늘)
        self.currentMonth = cal.startOfMonth(for: initialDate)
        
        // ✅ 초기 anchor 설정
        anchor(to: initialDate)
    }
    
    // MARK: - Setup (기준 날짜로 anchor 이동)
    /// 전달받은 날짜가 속한 "그 달"을 기준으로 [이전, 현재, 다음] 3개월 배열을 구성합니다.
    func anchor(to date: Date) {
        let baseMonth = calendar.startOfMonth(for: date)
        let prev = calendar.date(byAdding: .month, value: -1, to: baseMonth)!
        let next = calendar.date(byAdding: .month, value: +1, to: baseMonth)!
        currentMonth = baseMonth
        months = [prev, baseMonth, next]
    }
    
    // MARK: - Month 이동 (무한 스크롤 핵심)
    /// 페이지가 왼쪽/오른쪽 끝에 도달했을 때 호출합니다.
    /// 내부적으로 months 배열을 "슬라이드" 시켜 항상 중앙이 현재 달이 되도록 유지합니다.
    func moveMonth(isForward: Bool) {
        if isForward {
            let newCurrent = months[2]
            let newNext = calendar.date(byAdding: .month, value: 1, to: newCurrent)!
            months = [months[1], newCurrent, newNext]
            currentMonth = newCurrent
        } else {
            let newCurrent = months[0]
            let newPrev = calendar.date(byAdding: .month, value: -1, to: newCurrent)!
            months = [newPrev, newCurrent, months[1]]
            currentMonth = newCurrent
        }
    }
    
    // MARK: - Helpers
    /// 특정 월의 "달력 그리드" 데이터를 만듭니다.
    /// 첫째 날의 요일을 계산해 앞쪽에 nil(빈칸)을 채운 뒤, 1일부터 말일까지의 Date를 이어 붙입니다.
    func days(in month: Date) -> [Date?] {
        guard let range = calendar.range(of: .day, in: .month, for: month),
              let startOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: month))
        else { return [] }
        
        // 요일 오프셋 계산 (locale-safe)
        // e.g. 첫날이 수요일이고, firstWeekday=1(일요일 시작)라면 앞쪽에 3칸(nil) 필요
        let weekdayOffset = (calendar.component(.weekday, from: startOfMonth) - calendar.firstWeekday + 7) % 7
        
        var days: [Date?] = Array(repeating: nil, count: weekdayOffset)
        for day in range {
            if let date = calendar.date(byAdding: .day, value: day - 1, to: startOfMonth) {
                days.append(date)
            }
        }
        return days
    }
    
    /// "2025년 11월" 형태의 헤더 텍스트를 만듭니다. (한국어 고정)
    func headerTitle(for month: Date) -> String {
        Self.headerFormatter.string(from: month)
    }
    
    /// 오늘 날짜인지 여부
    func isToday(_ date: Date) -> Bool {
        calendar.isDateInToday(date)
    }
    
    /// 두 날짜가 같은 "월"에 속하는지 여부
    func isSameMonth(_ date: Date, as month: Date) -> Bool {
        calendar.isDate(date, equalTo: month, toGranularity: .month)
    }
}


// MARK: - Calendar Extension
extension Calendar {
    /// 어떤 날짜가 오든 그 달의 1일 00:00 으로 정규화
    func startOfMonth(for day: Date) -> Date {
        let comps = dateComponents([.year, .month], from: day)
        return self.date(from: comps)!
    }
}

 

 역할과 책임 (What & Why)

CalendarViewModel은 “달력 데이터 생성기”예요.

- 화면에 뿌릴 월 배열 [이전, 현재, 다음] 과 현재 월을 관리.

- 어떤 달의 그리드(날짜 칸) 를 만들 때, 앞쪽 빈 칸(nil)을 포함해서 배열로 반환.

- “오늘 여부, 같은 달 여부, 헤더 타이틀” 등의 판단/포맷 로직 제공.

 

UI 상태(선택된 날짜 표시, 스크롤 위치 등) 는 셀/VC에서 처리하고,

ViewModel은 순수한 날짜 계산에 집중 → MVVM 분리 깔끔.

 

한 달 그리드 생성(days(in:))

func days(in month: Date) -> [Date?] {
    guard let range = calendar.range(of: .day, in: .month, for: month),
          let startOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: month))
    else { return [] }

    // 요일 오프셋 계산 (locale-safe)
    let weekdayOffset = (calendar.component(.weekday, from: startOfMonth) - calendar.firstWeekday + 7) % 7

    var days: [Date?] = Array(repeating: nil, count: weekdayOffset)
    for day in range {
        if let date = calendar.date(byAdding: .day, value: day - 1, to: startOfMonth) {
            days.append(date)
        }
    }
    return days
}

 

✅ 동작 단계별 설명

① range(of: .day, in: .month, for: month)

이 부분이 바로 “그 달이 며칠까지 있는가” 를 알려줍니다.

 

예시로 보자면:

month range 의미
2025-01-01 1..<32 1일부터 31일까지
2025-02-01 1..<29 1일부터 28일까지 (윤년엔 29일)
2025-04-01 1..<31 1일부터 30일까지

즉, 이 range를 기반으로 반복문이 1일~말일까지의 날짜를 만듭니다.

 

② startOfMonth

calendar.date(from: calendar.dateComponents([.year, .month], from: month))

 

→ 전달받은 Date(month)가 2025-01-15 같은 중간 날짜라도
→ “해당 달의 첫째 날(2025-01-01 00:00)”로 변환합니다.

 

이게 기준점이 돼요.

 

③ weekdayOffset

(calendar.component(.weekday, from: startOfMonth) - calendar.firstWeekday + 7) % 7

 

그 달의 첫째 날이 무슨 요일인지 계산해서,
그 앞에 몇 개의 빈칸(nil)을 채워야 하는지를 구합니다.

예시 (일요일 시작 달력 기준):

첫째 날 요일  weekdayOffset  설명
일요일 0 빈칸 없음
월요일 1 일요일 자리 하나 비워둠
수요일 3 일~화 3칸 비워둠

이걸로 달력 첫 줄 정렬이 맞춰집니다.

 

④ 날짜 배열 생성

for day in range {
    if let date = calendar.date(byAdding: .day, value: day - 1, to: startOfMonth) {
        days.append(date)
    }
}

 

- day는 1부터 31(또는 30, 28, 29)까지 반복.

- 각 숫자를 기준으로 startOfMonth에 (day - 1)일 더한 Date 생성.

- 즉, 2025-01-01, 2025-01-02, ..., 2025-01-31 까지의 Date가 들어갑니다.

 

✅ 정리하자면

단계 역할
range(of:.day...) 그 달의 일 수 (1~30/31/28/29) 계산
startOfMonth 기준점을 “그 달의 첫날”로 맞춤
weekdayOffset 달력 첫 줄 앞부분 빈칸(nil) 개수 결정
for day in range 해당 달의 모든 Date를 만들어 배열에 추가
결과 [nil, nil, ..., 2025-01-01, ..., 2025-01-31]

 

 

Calendar 확장: startOfMonth

 

extension Calendar {
  func startOfMonth(for day: Date) -> Date {
    let comps = dateComponents([.year, .month], from: day)
    return self.date(from: comps)!
  }
}

 

- 어떤 날짜가 오더라도 그 달의 1일 00:00로 정규화.

- 월 앵커, 월 비교 등 대부분의 월 단위 연산의 기준점.

 

728x90
LIST