SwiftUI 좌우 스크롤 가능한 캘린더 만들기

Minsang Kang·2025년 6월 17일

iOS Develop

목록 보기
7/12
post-thumbnail

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

SwiftUI로 ‘무한’ 월간 캘린더 만들기 – 핵심만 빠르게 훑어보기

“ScrollView 안에서 오프셋을 재고, 스스로 페이징을 판단하느라 삽질했는데 TabView 한 줄로 끝났습니다.”
이번 글은 그 시행착오를 압축해서 원리 → 페이지 관리 → 필수 코드만 정리한 메모입니다.

TL;DR

  • 오프셋 직접 계산보다 TabView(.page) 가 훨씬 쉽고 안정적
  • 3 페이지만 유지하되 스크롤 직후 배열을 재편성하여 ‘무한’ 효과
  • onChangeDebounced 로 중복 이벤트 차단 & 깜빡임 제거

1. 왜 ScrollView → TabView(.page) 로 갈아탔나?

  • ScrollView + GeometryReader
    • 각 페이지의 minX 값을 PreferenceKey 로 흘려보내 임계값(너비의 ½)을 넘으면 “다음 달”이라 판단.
    • 문제 : 스냅을 직접 구현해야 하고, 드래그 속도에 따라 이벤트가 두 번 이상 들어와 깜빡임·중복 호출이 잦음.
  • TabView(.page)
    • 시스템이 스와이프·감속·스냅을 모두 알아서 처리.
    • selection 바인딩만 관찰하면 “페이지가 확정적으로 넘어간 시점”을 정확히 받을 수 있어 로직이 극도로 단순해짐.

결국 “스와이프 = 페이지 단위 전환” 인 요구엔 TabView 가 가장 경제적이었다.


2. 무한 스크롤을 위한 페이지 관리 전략

1. 배열은 항상 3 페이지 – [이전, 현재, 다음]
2. 사용자가 오른쪽으로 넘기면

  • shiftMonths(.next) : 맨 앞 페이지 버리고 새로운 “다음” 달을 뒤에 붙임
  • 배열은 다시 [이전, 현재, 다음] 형태가 됨
  • selection(pageIndex)을 중앙(1) 으로 즉시 돌려 놓아 UI 는 변함없이 “연속적으로” 스크롤되는 느낌을 유지
  1. 반대로 왼쪽으로 넘기면 앞·뒤 교체 순서만 반대

핵심 : 배열만 갈아끼우고 스크롤 위치는 ‘중앙 페이지’로 리셋 → 메모리는 3 달만, UX 는 무한.


3. 핵심 코드만 추려보기

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 안에 값이 또 바뀌면 이전 작업 취소, 마지막 값만 전달” – 스크롤이 빠를 때 중복 호출을 완전히 막는다.


4. 마무리 & 확장 아이디어

  • 5 페이지 캐시(이전 2·현재·다음 2) → pages 길이·중앙 인덱스만 바꿔도 즉시 동작.
  • iOS 17 이상이라면 TabView 대신 ScrollView + scrollTargetBehavior(.paging) + ScrollPosition 로도 동일 패턴 구현 가능.
  • 주·일 단위 캘린더로 내려가려면 MonthGridView 를 WeekGridView / DayTimelineView 로 교체하면 구조는 그대로 재활용된다.

전체 코드

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)
            }
    }
}
profile
 iOS Developer

0개의 댓글