목표
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로 정규화.
- 월 앵커, 월 비교 등 대부분의 월 단위 연산의 기준점.
'감정일기(가칭)' 카테고리의 다른 글
| 📘 텍스트뷰가 키보드에 가리지 않도록 자동 스크롤하는 방법 (0) | 2025.11.14 |
|---|---|
| 커스텀 캘린더 만들기 구조 설계 (0) | 2025.11.10 |
| 📐 Compositional Layout에서 interGroupSpacing vs interItemSpacing 완벽 정리 (0) | 2025.10.29 |
| 🧩 HomeSection은 어디에 두는 게 맞을까? (0) | 2025.10.28 |
| 🏠 HomeViewModel에 HappinessViewModel을 통합한 이유와 구조 정리 (0) | 2025.10.28 |