
ChatGPT 를 토대로 스크롤 가능한 캘린더를 만든 내용을 GPT로 정리하여 남깁니다.

“ScrollView 안에서 오프셋을 재고, 스스로 페이징을 판단하느라 삽질했는데 TabView 한 줄로 끝났습니다.”
이번 글은 그 시행착오를 압축해서 원리 → 페이지 관리 → 필수 코드만 정리한 메모입니다.
TL;DR
결국 “스와이프 = 페이지 단위 전환” 인 요구엔 TabView 가 가장 경제적이었다.
1. 배열은 항상 3 페이지 – [이전, 현재, 다음]
2. 사용자가 오른쪽으로 넘기면
핵심 : 배열만 갈아끼우고 스크롤 위치는 ‘중앙 페이지’로 리셋 → 메모리는 3 달만, UX 는 무한.
3-1. ViewModel – 페이지 재배치
final class CalendarVM: ObservableObject {
@Published private(set) var pages: [MonthPage] // always 3
@Published var currentMonth: Date // pages[1]
private let cal = Calendar.current
init(reference: Date = .now) {
let base = cal.startOfMonth(for: reference)
pages = (-1...1).map { offset in
MonthPage(monthStart: cal.month(byAdding: offset, to: base))
}
currentMonth = base
}
enum Shift { case previous, next }
func shift(_ dir: Shift) {
switch dir {
case .next:
pages.removeFirst()
pages.append(MonthPage(monthStart: cal.month(byAdding: 1,
to: pages.last!.monthStart)))
case .previous:
pages.removeLast()
pages.insert(MonthPage(monthStart: cal.month(byAdding: -1,
to: pages.first!.monthStart)), at: 0)
}
currentMonth = pages[1].monthStart
}
}
3-2. View – TabView + 디바운스
struct PagedCalendarView: View {
@StateObject private var vm = CalendarVM()
@State private var pageIdx = 1 // 0·**1**·2
var body: some View {
TabView(selection: $pageIdx) {
ForEach(Array(vm.pages.enumerated()), id: \.element.id) { i, page in
MonthGridView(monthStart: page.monthStart, vm: vm)
.tag(i)
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
// 빠른 슬라이드 시 0.3s 동안 변화가 더 없을 때만 처리
.onChangeDebounced(of: pageIdx, delay: 0.3) { new in
switch new {
case 2: vm.shift(.next); pageIdx = 1
case 0: vm.shift(.previous); pageIdx = 1
default: break
}
}
}
}
onChangeDebounced 는 DispatchWorkItem 기반 1-줄 Modifier:
“x ms 안에 값이 또 바뀌면 이전 작업 취소, 마지막 값만 전달” – 스크롤이 빠를 때 중복 호출을 완전히 막는다.
import Foundation
import SwiftUI
extension Calendar {
func startOfMonth(for date: Date) -> Date {
dateInterval(of: .month, for: date)!.start
}
func month(byAdding value: Int, to base: Date) -> Date {
date(byAdding: .month, value: value, to: base)!
}
}
// MARK: 1. 데이터 계층 (Model)
struct MonthPage: Identifiable, Equatable {
let id = UUID()
let monthStart: Date // 해당 달의 1일 00:00
}
// MARK: 2. 뷰모델 (ViewModel)
final class TestCalendarViewModel: ObservableObject {
@Published private(set) var pages: [MonthPage] // 항상 3개
@Published var currentMonth: Date // 화면에 보이는 달
@Published var selectedDate: Date? // 유저가 탭한 날짜
private let calendar = Calendar.current
init(reference: Date = .now) {
let current = calendar.startOfMonth(for: reference)
pages = [
MonthPage(monthStart: calendar.month(byAdding: -1, to: current)),
MonthPage(monthStart: current),
MonthPage(monthStart: calendar.month(byAdding: +1, to: current))
]
currentMonth = current
}
/// 스크롤 오프셋으로 방향 판단 후 데이터 재배치
func shiftMonths(direction: ShiftDirection) {
switch direction {
case .next:
let newNext = MonthPage(monthStart: calendar.month(byAdding: +1, to: pages[2].monthStart))
pages = [pages[1], pages[2], newNext]
case .previous:
let newPrev = MonthPage(monthStart: calendar.month(byAdding: -1, to: pages[0].monthStart))
pages = [newPrev, pages[0], pages[1]]
}
currentMonth = pages[1].monthStart // 가운데가 항상 ‘현재’ 페이지
}
enum ShiftDirection { case previous, next }
// 특정 month(Date)에 대한 모든 날짜 배열(6주 고정)
func days(for month: Date) -> [Date] {
guard let monthInterval = calendar.dateInterval(of: .month, for: month),
let firstWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start),
let lastWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.end.addingTimeInterval(-1))
else { return [] }
var buffer: [Date] = []
var date = firstWeek.start
while date <= lastWeek.end {
buffer.append(date)
date = calendar.date(byAdding: .day, value: 1, to: date)!
}
return buffer
}
}
// MARK: 4. 뷰 (View)
struct PagedCalendarView: View {
@StateObject private var vm = TestCalendarViewModel()
@State private var pageIndex = 1 // 0:이전, 1:현재, 2:다음
var body: some View {
TabView(selection: $pageIndex) {
ForEach(Array(vm.pages.enumerated()), id: \.element.id) { idx, page in
MonthGridView(monthStart: page.monthStart,
selected: $vm.selectedDate,
vm: vm)
.tag(idx)
.frame(maxWidth: .infinity) // 폭 제한 없으면 자동 맞춤
}
}
.tabViewStyle(.page(indexDisplayMode: .never))
.onChangeDebounced(of: pageIndex, delay: 0.3) { newIndex in
switch newIndex {
case 2: // → 다음 달
vm.shiftMonths(direction: .next)
pageIndex = 1
case 0: // ← 이전 달
vm.shiftMonths(direction: .previous)
pageIndex = 1
default:
break
}
}
}
}
struct MonthGridView: View {
let monthStart: Date
@Binding var selected: Date?
@ObservedObject var vm: TestCalendarViewModel
private let columns = Array(repeating: GridItem(.flexible()), count: 7)
private let calendar = Calendar.current
var body: some View {
LazyVGrid(columns: columns, spacing: 6) {
ForEach(vm.days(for: monthStart), id: \.self) { date in
DayCell(date: date,
month: monthStart,
isSelected: calendar.isDate(date, inSameDayAs: selected ?? .distantPast))
.onTapGesture { selected = date }
}
}
.padding(.horizontal)
}
}
struct DayCell: View {
let date: Date // 렌더링할 실제 날짜
let month: Date // 해당 셀을 포함하는 달의 1일
let isSelected: Bool // 현재 선택 상태
private let calendar = Calendar.current
// 날짜 포맷터는 정적 프로퍼티로 재사용하면 더 효율적입니다.
private static let dayFormatter: DateFormatter = {
let df = DateFormatter()
df.locale = Locale(identifier: "ko_KR")
df.dateFormat = "d"
return df
}()
var body: some View {
// 오늘 여부 계산
let isToday = calendar.isDateInToday(date)
// 월 안/밖 판별
let isInCurrentMonth = calendar.isDate(date, equalTo: month, toGranularity: .month)
Text(Self.dayFormatter.string(from: date))
.font(.system(size: 14, weight: .semibold))
.foregroundColor(foregroundColor(isInCurrentMonth: isInCurrentMonth,
isToday: isToday,
isSelected: isSelected))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(4)
.background(
ZStack {
if isSelected { // 선택 배경
Circle()
.fill(Color.accentColor)
} else if isToday { // 오늘 테두리
Circle()
.stroke(Color.accentColor, lineWidth: 1.5)
}
}
)
.contentShape(Rectangle()) // 탭 범위 확장
}
/// 글자색 결정 로직
private func foregroundColor(isInCurrentMonth: Bool,
isToday: Bool,
isSelected: Bool) -> Color {
if isSelected {
return .white
} else if !isInCurrentMonth {
return .secondary
} else if isToday {
return .accentColor
} else {
return .primary
}
}
}
extension View {
/// onChange + debounce 한 번에
func onChangeDebounced<Value: Equatable>(
of value: Value,
delay: TimeInterval,
perform action: @escaping (Value) -> Void
) -> some View {
modifier(DebouncedOnChangeModifier(target: value,
delay: delay,
action: action))
}
}
private struct DebouncedOnChangeModifier<Value: Equatable>: ViewModifier {
let target: Value
let delay: TimeInterval
let action: (Value) -> Void
// 내부 상태
@State private var latestValue: Value
@State private var workItem: DispatchWorkItem?
init(target: Value, delay: TimeInterval, action: @escaping (Value) -> Void) {
self.target = target
self.delay = delay
self.action = action
_latestValue = State(initialValue: target)
}
func body(content: Content) -> some View {
content
.onChange(of: target) { new in
latestValue = new
workItem?.cancel()
let task = DispatchWorkItem {
action(latestValue)
}
workItem = task
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: task)
}
}
}