→ Swift Archive

[SwiftUI] 커스텀 달력 만들기

Swift librarian 2024. 2. 6. 09:32

커스텀 달력

내가 직접 만든 달력! 기존의 DatePickerUICalendarView 는 제약되어 있는게 너무 많아서 하나하나 커스텀 하기 위해서 직접 달력을 만들었다.

 

넣은 기능

  • TabView 로 만들어 page 형태로 드래그가 가능하다.
  • 우측 상단의 < > 버튼을 통해서도 자연스럽게 이동이 가능하다.
  • 오늘 버튼을 누르게 되면 이번달로 이동하면서 오늘이 선택된다.
  • 날짜 선택이 가능하고 다시 선택하면 날짜 선택이 해제된다.
  • 날짜마다 아래쪽에 내가 원하는 View를 넣을 수 있다.
  • Frame 이 일정하다. 어느 달로 가더라도 4주, 5주를 계산하여 높이가 320 으로 일정하다.

코드

CalendarView, Date+Extension 두가지 파일로 구성했다. CalendarView 의 경우 172줄로 좀 긴 감이 있지만... 찢어놓는게 더 이해하기 힘들것 같아서 우선 붙여두었다.

CalendarView

생각보다 TabView 쪽이 까다로워서 좀 애를 먹었는데, 0, 1, 2 페이지가 있고 0 페이지로 간다면 0 페이지의 앞의 달을 생성해주고 2 페이지에 해당하는 달을 months 배열에서 빼준다. 그렇게 되면 이전달로 계속 스크롤이 가능해진다!

import SwiftUI

struct CalendarView: View {
    @State private var selectedDate: Date?
    @State private var months: [[Month]] = [[],[],[]]
    @State private var selection = 1
    
    private let dayOfWeek: [String] = ["일", "월", "화", "수", "목", "금", "토"]
    private let columns = Array(repeating: GridItem(.flexible()), count: 7)
    private let calendarHeight: CGFloat = 320
    
    var body: some View {
        NavigationStack {
            ScrollView {
                VStack {
                    CalendarHeader()
                    CalendarBody()
                }
            }
            .onAppear {
                fetchMonths()
            }
            .navigationTitle("달력")
        }
    }
    
    @ViewBuilder
    private func CalendarHeader() -> some View {
        HStack(spacing: 16) {
            VStack(alignment: .leading) {
                if let date = months[1].first?.date {
                    Text(date.format("YYYY"))
                        .font(.footnote)
                        .fontWeight(.semibold)
                    Text(date.format("MMMM"))
                        .font(.title.bold())
                }
            }
            
            Spacer()
            
            Button("오늘") {
                fetchMonths()
                selectedDate = Date()
            }
            .font(.callout)
            .buttonStyle(.bordered)
            
            Button {
                selectedDate = nil
                withAnimation {
                    selection = 0
                }
            } label: {
                Image(systemName: "chevron.left")
                    .font(.title2)
            }
            
            Button {
                selectedDate = nil
                withAnimation {
                    selection = 2
                }
            } label: {
                Image(systemName: "chevron.right")
                    .font(.title2)
            }
        }
        .padding()
        
        HStack(spacing: 0) {
            ForEach(dayOfWeek, id: \.self) { day in
                Text(day)
                    .font(.callout)
                    .fontWeight(.semibold)
                    .frame(maxWidth: .infinity)
            }
        }
        .padding(.horizontal, -5)
    }
    
    @ViewBuilder
    private func CalendarBody() -> some View {
        TabView(selection: $selection) {
            ForEach(months.indices, id: \.self) { index in
                let month = months[index]
                DateGrid(month)
                    .tag(index)
                    .onDisappear {
                        paginateMonth()
                    }
                    .onAppear {
                        selection = 0
                        selection = 1
                    }
            }
        }
        .tabViewStyle(.page(indexDisplayMode: .never))
        .frame(height: calendarHeight)
    }
    
    @ViewBuilder
    private func DateGrid(_ month: [Month]) -> some View {
        LazyVGrid(columns: columns, spacing: 0) {
            ForEach(month) { value in
                DateCell(value: value)
                    .onTapGesture {
                        if selectedDate == value.date {
                            selectedDate = nil
                        } else {
                            selectedDate = value.date
                        }
                    }
            }
        }
    }
    
    @ViewBuilder
    private func DateCell(value: Month) -> some View {
        VStack(spacing: 0) {
            if value.day != -1 {
                VStack {
                    Text("\(value.day)")
                        .fontWeight(value.date.isToday ? .bold : .semibold)
                        .foregroundStyle(value.date.isToday ? .blue : .gray.opacity(0.8))
                    Circle()
                        .foregroundStyle(.clear)
                }
                .background {
                    if let selectedDate {
                        Capsule()
                            .foregroundStyle(.blue.opacity(0.2))
                            .opacity(value.date.isSameDay(selectedDate) ? 1 : 0)
                            .frame(width: 36)
                            .padding(.vertical, value.date.numberOfWeeks == 6 ? 1 : 2)
                            .offset(y: -2)
                    }
                }
            }
        }
        .frame(height: calendarHeight/value.date.numberOfWeeks, alignment: .top)
    }
    
        private func fetchMonths() {
        months[0] = Date().createPreviousMonth()
        months[1] = Date().createMonth()
        months[2] = Date().createNextMonth()
    }
    
    private func paginateMonth() {
        guard let date = months[selection].first?.date else { return }
        if selection == 0 {
            selectedDate = nil
            months.insert(date.createPreviousMonth(), at: 0)
            months.removeLast()
            selection = 1
        }
        
        if selection == 2 {
            selectedDate = nil
            months.append(date.createNextMonth())
            months.removeFirst()
            selection = 1
        }
    }
}
struct Month: Identifiable {
    var id = UUID().uuidString
    var day: Int
    var date: Date
}

Date+Extension

간단하게 사용하기 위해서 Extension 을 활용하였다. createMonth() 가 여기서의 메인 함수라고 볼 수 있는데, 날짜를 받아서 그 날짜에 해당되는 달의 날을 반환해주는 함수이다.

import SwiftUI

extension Date {
    func format(_ format: String) -> String {
        let formatter = DateFormatter()
        formatter.dateFormat = format
        
        return formatter.string(from: self)
    }
    
    func isSameDay(_ date: Date) -> Bool {
        Calendar.current.isDate(self, inSameDayAs: date)
    }
    
    var isToday: Bool {
        Calendar.current.isDateInToday(self)
    }
    
    var startOfDay: Date {
        Calendar.current.startOfDay(for: self)
    }
    
    var startOfMonth: Date {
        let calendar = Calendar.current
        
        return calendar.date(from: calendar.dateComponents([.year, .month], from: self)) ?? Date()
    }
    
    var numberOfWeeks: CGFloat {
        guard let range = Calendar.current.range(of: .weekOfMonth, in: .month, for: self) else { return 0 }
        
        return CGFloat(range.count)
    }
    
    func createMonth(_ date: Date = Date()) -> [Month] {
        let calendar = Calendar.current
        
        guard let startDate = calendar.date(from: calendar.dateComponents([.year, .month], from: date)) else { return [] }
        
        guard let range = calendar.range(of: .day, in: .month, for: startDate) else { return [] }
        
        var days = range.compactMap { day -> Month in
            let date = calendar.date(byAdding: .day, value: day - 1, to: startDate)!
            return Month(day: calendar.component(.day, from: date), date: date)
        }
        
        let firstWeekday = calendar.component(.weekday, from: days.first?.date ?? Date())
        
        for _ in 0..<(firstWeekday - calendar.firstWeekday) {
            days.insert(Month(day: -1, date: date), at: 0)
        }
        
        return days
    }
    
    func createPreviousMonth() -> [Month] {
        let calendar = Calendar.current
        let startOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: self)) ?? Date()
        guard let startDate = calendar.date(byAdding: .month, value: -1, to: startOfMonth) else { return [] }
        
        return createMonth(startDate)
    }
    
    func createNextMonth() -> [Month] {
        let calendar = Calendar.current
        let startOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: self)) ?? Date()
        guard let startDate = calendar.date(byAdding: .month, value: +1, to: startOfMonth) else { return [] }
        
        return createMonth(startDate)
    }
}