SwiftUI - Custom Calendar

Marble·2025년 1월 3일

Calendar

목록 보기
2/3

이번 글에서는 커스텀 달력을 만들어 보겠습니다. 우선 전체 코드부터 보여드리겠습니다.

//CalendarView.swift
import SwiftUI

struct CalendarView: View {
    @State var month: Date = Date()

    var body: some View {
        VStack {
            CalendarHeaderView(month: $month)
            CalendarBodyView()
        }
    }
}
// CalendarHeaderView.swift
import SwiftUI

struct CalendarHeaderView: View {
    @Binding var month: Date
    
    var body: some View {
        HStack {
            Button {
                changeMonth(by: -1)
            } label: {
                Image(systemName: "arrow.left")
            }
            
            Spacer()
            
            Text(month, formatter: Self.dateFormatter)
            
            Spacer()
            
            Button {
                changeMonth(by: 1)
            } label: {
                Image(systemName: "arrow.right")
            }
        }
    }
}

private extension CalendarHeaderView {
    func changeMonth(by value: Int) { // 달 변경
        let calendar = Calendar.current
        if let newMonth = calendar.date(byAdding: .month, value: value, to: month) {
            self.month = newMonth
        }
    }
    
    static let dateFormatter: DateFormatter = { // 달력에 달에 해당하는 부분만 출력하기 위한 포메터
        let formatter = DateFormatter()
        formatter.dateFormat = "M"
        return formatter
    }()
}
//CalendarBodyView
import SwiftUI

struct CalendarBodyView: View {
    @Binding var month: Date
    
    private var daysInMonth: Int {
        numberOfDays(in: month)
    }
    private var firstWeekday: Int {
        firstWeekdayOfMonth(in: month) - 1
    }
    
    var body: some View {
        VStack {
            LazyVGrid(columns: Array(repeating: GridItem(), count: 7), spacing: 30) {
                ForEach(0 ..< daysInMonth + firstWeekday, id: \.self) { index in
                    if index < firstWeekday {
                        Text("")
                            .opacity(0)
                    } else {
                        let day = index - firstWeekday + 1
                        
                        VStack(spacing: 10) {
                            Rectangle()
                                .opacity(0)
                                .overlay(
                                    checkMonth() && checkDate(date: day) ?
                                    Text(String(day))
                                        .foregroundStyle(Color.accentColor) :
                                        Text(String(day))
                                )
                                .frame(height: 10)
                        }
                    }
                }
            }
        }
        .padding(.bottom, 20)
    }
}

private extension CalendarBodyView {
    // 해당 월에 총 날짜 수
    func numberOfDays(in date: Date) -> Int {
        return Calendar.current.range(of: .day, in: .month, for: date)?.count ?? 0
    }
    
    // 해당 월의 첫 날짜가 갖는 요일
    func firstWeekdayOfMonth(in date: Date) -> Int {
        let components = Calendar.current.dateComponents([.year, .month], from: date)
        let firstDayOfMonth = Calendar.current.date(from: components)!
        
        return Calendar.current.component(.weekday, from: firstDayOfMonth)
    }
    
    // 해당 월인지 체크
    func checkMonth() -> Bool {
        let today = Date()
        return Calendar.current.isDate(today, equalTo: self.month, toGranularity: .month)
    }
    
    // 오늘과 일이 같은지 날짜 체크
    func checkDate(date : Int) -> Bool {
        return date == Calendar.current.component(.day, from: Date())
    }
}

구현은 CalendarView에서 달력에 해더와 바디부분을 나뉘어서 호출하는식으로 구현했습니다. 이때 월은 헤더와 바디가 동일해야 하기 때문에 상위 뷰인 CalendarView에서 선언 후 인자로 전달해줬고 월을 바꿀 수도 있기 때문에 State를 사용했습니다.

CalendarHeaderView에서는 달력의 상단 부분 달력의 월과 월을 변경하는 부분을 구현했습니다. changeMonth 함수를 통해서 월을 변경할 수 있습니다.

func changeMonth(by value: Int) { // 달 변경
    let calendar = Calendar.current
    if let newMonth = calendar.date(byAdding: .month, value: value, to: month) {
        self.month = newMonth
    }
}

코드를 설명하자면 Calendar.current를 통해 현재의 Date 정보를 calendar 변수에 저장한 후 calendar.date(byAdding: .month, value: value, to: month) 함수를 통해 현재 기준 value 월만큼 추가된 Date값을 self.month에 저장하면서 달을 변경했습니다. 이때 value를 추가하는 단위를 정하는 부분이 byAdding: .month입니다. 이 부분에 .day, .month, .year 등 시간 단위를 변경하면 해당 단위만큼 추가됩니다. 기본적으로 해당 함수는 더하기가 되기 때문에 현재보다 과거로 가고 싶을 때는 value에 음수의 값을 전달하면 됩니다.

CalendarBodyView에서는 달력의 몸통 부분 요일과 날짜 부분을 구현했습니다.
우선 numberOfDays 함수를 통해 해당 월의 날짜가 총 몇일인지 알아냅니다. Calendar.current.range(of: <Calendar.Component>, in: <Calendar.Component>, for: <Date>) 함수는 Swift의 Calendar 클래스에서 제공하는 메서드로, 특정 날짜 범위 내에서 주어진 단위의 범위를 반환합니다. of는 구하고자 하는 날짜 단위를 나타내고, in은 그 단위의 상위 범위를 지정하는 데 사용됩니다.위 코드에서는 Calendar.current.range(of: .day, in: .month, for: date)?.count ?? 0처럼 사용했는데 이는 현재 날짜인 date를 포함한 월(.month)에서 일(.day)을 나타낼 수 있는 범위를 반환합니다. date가 1월인 경우 반환값은 1..<31이며 이의 count인 31이 반환됩니다.

다음은 firstWeekdayOfMonth 함수를 통해 해당 월의 첫 번째 날이 무슨 요일인지 알아냅니다. Calendar.current.dateComponents([.year, .month], from: date) 함수를 통해 date가 몇 년, 몇 월인지 알아냅니다. 이후 Calendar.current.date(from: components)! 함수를 통해 추출한 연도와 월을 사용하여 해당 월의 첫 번째 날(즉, 1일)을 나타내는 Date 객체를 생성합니다. 마지막으로 Calendar.current.component(.weekday, from: firstDayOfMonth) 함수를 사용하여 생성된 첫 번째 날의 요일을 계산하고 반환합니다. 이때 반환값은 1부터 7까지의 정수로, 1은 일요일, 2는 월요일, ...7은 토요일을 나타냅니다.

LazyVGrid(columns: Array(repeating: GridItem(), count: 7), spacing: 30) {
	ForEach(0 ..< daysInMonth + firstWeekday, id: \.self) { index in
		if index < firstWeekday {
        	Text("")
            	.opacity(0)
            } else {
            	let day = index - firstWeekday + 1
                
                VStack(spacing: 10) {
                	Rectangle()
                    	.opacity(0)
                        .overlay(
                        	checkMonth() && checkDate(date: day) ?
                            Text(String(day)).foregroundStyle(Color.accentColor) :
                            Text(String(day))
                            )
                            .frame(height: 10)
			}
		}
	}
}

위 두 함수를 통해 얻은 정보를 이용하여 달력을 그립니다. 이때 checkMonth 함수와 checkDate 함수를 통해 오늘인지 체크하고 다른색으로 표시되도록 구현했습니다. 코드를 실행한 화면을 마지막으로 이번 글을 마치겠습니다.

profile
개발자가 되고 싶은 공돌이

0개의 댓글