Custom Calendar 구현과 ViewBuilder에 대해서

오늘은 지금까지 수집한 데이터를 바탕으로 데이터 수집량에 관한 진행상황을 알 수 있는 주간 캘린더와 데이터를 시각화해 이해를 도울 수 있는 작업을 진행하였다.

 

먼저 캘린더 구현된 내용을 살펴보면 날짜를 1주일 단위로 보여주고 그 밑에 일자별로 데이터 수집량을 확인할 수 있도록 구현하였다.

컴포넌트화 하기 위해 수집량을 파악하는 Progress 뷰와 캘린더뷰를 별도로 구분했다.

 

CalendarView

먼저 캘린더뷰를 살펴보자

import SwiftUI

struct CalendarView: View {
    @State private var selectedDate = Date()
    @State private var showMonthPicker = false
    private let calendar = Calendar.current
    
    var body: some View {
        VStack(alignment: .center, spacing: 20) {
            monthView
            
            ZStack {
                dayView
                blurView
            }
            .frame(height: 70)
        }
    }
    
    private var monthView: some View {
        HStack(spacing: 30) {
            Button(
                action: {
                    changeMonth(-1)
                }, label: {
                    Image(systemName: "chevron.left")
                        .padding(.horizontal, 16)
                        .foregroundColor(.black)
                }
            )
            
            Button(
                action: {
                    showMonthPicker.toggle()
                }, label: {
                    Text(monthTitle(from: selectedDate))
                        .font(.title3)
                        .foregroundColor(.black)
                }
            )

            Button(
                action: {
                    changeMonth(1)
                }, label: {
                    Image(systemName: "chevron.right")
                        .padding(.horizontal, 16)
                        .foregroundColor(.black)
                }
            )
        }
    }
    
    @ViewBuilder
    private var dayView: some View {
        let startDate = calendar.date(from: Calendar.current.dateComponents([.year, .month], from: selectedDate))!
        
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 10) {
                let components = (
                    0..<calendar.range(of: .day, in: .month, for: startDate)!.count)
                    .map {
                        calendar.date(byAdding: .day, value: $0, to: startDate)!
                    }
                ForEach(components, id: \\.self) { date in
                    VStack {
                        Text(day(from: date))
                            .font(.caption)
                        Text("\\(calendar.component(.day, from: date))")
                            .font(.subheadline)
                        
                        CircularProgressView(progress: 0.4)
                            .frame(width: 30, height: 30)
                    }
                    .frame(width: 30, height: 70)
                    .padding(9)
                    .background(calendar.isDate(selectedDate, equalTo: date, toGranularity: .day) ? Color.green : Color.clear)
                    .cornerRadius(16)
                    .foregroundColor(calendar.isDate(selectedDate, equalTo: date, toGranularity: .day) ? .white : .black)
                    .onTapGesture {
                        selectedDate = date
                    }
                }
            }
        }
    }
    
    private var blurView: some View {
        HStack {
            LinearGradient(
                gradient: Gradient(
                    colors: [
                        Color(UIColor.systemGroupedBackground).opacity(1),
                        Color(UIColor.systemGroupedBackground).opacity(0)
                    ]
                ),
                startPoint: .leading,
                endPoint: .trailing
            )
            .frame(width: 60)
            .edgesIgnoringSafeArea(.leading)
            
            Spacer()
            
            LinearGradient(
                gradient: Gradient(
                    colors: [
                        Color(UIColor.systemGroupedBackground).opacity(1),
                        Color(UIColor.systemGroupedBackground).opacity(0)
                    ]
                ),
                startPoint: .trailing,
                endPoint: .leading
            )
            .frame(width: 60)
            .edgesIgnoringSafeArea(.leading)
        }
    }
}

extension CalendarView {
    func monthTitle(from date: Date) -> String {
        let dateFormatter = DateFormatter()
        dateFormatter.setLocalizedDateFormatFromTemplate("MMMM yyyy")
        return dateFormatter.string(from: date)
    }
    
    func changeMonth(_ value: Int) {
        guard let date = calendar.date(
            byAdding: .month,
            value: value,
            to: selectedDate
        ) else {
            return
        }
        
        selectedDate = date
    }
    
    func day(from date: Date) -> String {
        let dateFormatter = DateFormatter()
        dateFormatter.setLocalizedDateFormatFromTemplate("E")
        return dateFormatter.string(from: date)
    }
}

#Preview {
    CalendarView()
}

캘린더 뷰 내 월을 담당하는 부분, 일을 담당하는 부분, 캘린더를 보다 자연스럽게 사용하기 위해 양 옆에 블러를 넣어 준 부분으로 나눠져 있다.

@State private var selectedDate = Date()
@State private var showMonthPicker = false
private let calendar = Calendar.current

프로퍼티를 설정한 내용은 현재 선택된 날짜를 저장하고, 월 선택을 위한 플래그를 넣어주었다. 그리고 날짜 계산에 사용하기 위한 Calendar.current를 넣어주었다.

private var monthView: some View {
    HStack(spacing: 30) {
        Button(action: { changeMonth(-1) }) {
            Image(systemName: "chevron.left")
                .padding(.horizontal, 16)
                .foregroundColor(.black)
        }
        
        Button(action: { showMonthPicker.toggle() }) {
            Text(monthTitle(from: selectedDate))
                .font(.title3)
                .foregroundColor(.black)
        }
        
        Button(action: { changeMonth(1) }) {
            Image(systemName: "chevron.right")
                .padding(.horizontal, 16)
                .foregroundColor(.black)
        }
    }
}

Month 부분을 나타내는 부분을 살펴보면 먼저 버튼을 눌러 액션을 실행하는 changeMonth 부분이 정의가 되어야 하는데 가독성을 챙기기 위해 extension을 활용해 해당 부분은 구분해 주었다.

func changeMonth(_ value: Int) {
    guard let date = calendar.date(
        byAdding: .month,
        value: value,
        to: selectedDate
    ) else {
        return
    }
    
    selectedDate = date
}

파라미터는 누른 횟수를 지정하고 버튼이 눌림에 따라 해당 Value가 변동되도록 만들었다.

func monthTitle(from date: Date) -> String {
    let dateFormatter = DateFormatter()
    dateFormatter.setLocalizedDateFormatFromTemplate("MMMM yyyy")
    return dateFormatter.string(from: date)
}

그 다음 월을 나타내는 DateFormatter를 설정한 다음 해당 값을 현재 기준으로 예를들어 2024년 11월로 표기될 수 있도록 만들게 되면 캘린더의 상단 월을 나타내는 부분 구현이 완료 된다.

 

DayView

@ViewBuilder
private var dayView: some View {
    let startDate = calendar.date(from: Calendar.current.dateComponents([.year, .month], from: selectedDate))!
    
    ScrollView(.horizontal, showsIndicators: false) {
        HStack(spacing: 10) {
            let components = (0..<calendar.range(of: .day, in: .month, for: startDate)!.count)
                .map { calendar.date(byAdding: .day, value: $0, to: startDate)! }
            
            ForEach(components, id: \\.self) { date in
                VStack {
                    Text(day(from: date))
                        .font(.caption)
                    Text("\\(calendar.component(.day, from: date))")
                        .font(.subheadline)
                    
                    CircularProgressView(progress: 0.4) // 예시로 40% 설정
                        .frame(width: 30, height: 30)
                }
                .frame(width: 30, height: 70)
                .padding(9)
                .background(calendar.isDate(selectedDate, equalTo: date, toGranularity: .day) ? Color.green : Color.clear)
                .cornerRadius(16)
                .foregroundColor(calendar.isDate(selectedDate, equalTo: date, toGranularity: .day) ? .white : .black)
                .onTapGesture {
                    selectedDate = date
                }
            }
        }
    }
}

다음엔 일자를 나타내는 부분을 살펴보자 먼저 가장 눈에 띄는 건 @ViewBuilder 가 보이는데 이 뷰 빌더를 넣어주는 이유는 SwiftUI의 View프로토콜과 관련이 있다.

 

ViewBuilder

SwiftUI에서 View는 단일 뷰를 반환해야한다. 하지만 @ViewBuilder 를 사용하게 되면 여러개의 뷰를 포함한 블록을 반환할 수 있게 된다. 이 경우 dayView 안에서 여러 VStack 을 조건에 따라 ForEach를 통해 반복적으로 렌더링하므로 뷰빌더가 필요하다!

 

또한 if문이나 ForEach문 등 특정 상황에 따라 뷰를 다르게 해야한다면, @ViewBuilder 가 없을 경우 컴파일 에러가 발생하게 된다. 예를 들어 날짜를 특정 조건에 따라 다르게 보여줄 때 @ViewBuilder가 없으면 여러 조건에서 반환 되는 뷰를 단일 뷰로 처리하지 못하게 된다는 것이다!

 

정리하자면 @ViewBuilder를 사용하면 SwiftUI에서 여러 개의 하위 뷰를 하나의 View로 간주하게 만들어줘서 SwiftUI의 조건부 렌더링과 반복문을 포함한 복잡한 뷰 계정 구조를 간단하게 작성할 수 있다. dayView에서 ForEach를 통해 여러 VStack을 반환하기 때문에 @ViewBuilder를 사용한 것!!!

let startDate = calendar.date(from: Calendar.current.dateComponents([.year, .month], from: selectedDate))!

다시 dayView로 넘어가서 날짜를 가로로 스크롤 할 수 있도록 selectedDate가 포함된 달의 첫번째 날짜를 의미하고 selectedDate 에서 연도와 월의 정보를 가져와 해당하는 월의 첫 번째 날짜를 계산한다.

calendar.range(of: .day, in: .month, for: startDate)!.count

components는 selectedDate의 월에 해당하는 날짜들을 배열로 생성한 것이다. 상기 코드로 현재 달의 일 수를 계산한다.

.map { calendar.date(byAdding: .day, value: $0, to: startDate)! }

상기 코드를 통해 선택된 값은 순차적으로 생성된다.

 

그 다음 ForEach를 활용해서 각 날짜를 순회해 날짜별 뷰를 생성한다. 각 날짜는 VStack을 사용해 요일과 일자를 표시한다.

이렇게 월, 일을 구현하게 되었고 그 다음 하단에 수집 데이터량을 확인할 수 있는 ProgressView를 만들어주면

import SwiftUI

struct CircularProgressView: View {
    var progress: Double
    var color: Color = .blue
    
    var body: some View {
        ZStack {
            Circle()
                .stroke(Color.gray.opacity(0.3), lineWidth: 4)
            Circle()
                .trim(from: 0, to: CGFloat(progress))
                .stroke(color, lineWidth: 4)
                .rotationEffect(.degrees(90))
        }
    }
}

이렇게 Circle을 활용해 안채워진 부분을 회색으로 처리하고 그 다음 채워지는 부분을 Progress 값을 바탕으로 그 값 만큼 색이 들어가 추가될 수 있도록 ZStack을 활용해 겹쳐서 표현한다.

 

이렇게 구현한 캘린더는 이와 같이 보여진다 🙂

 

상당히 완성도 있게 구현된 것 같아 아주 만족스럽다!!! 이 캘린더를 구현하기 위해 참고한 블로그는 하단에 남겨두기로 하고 다음 이시간에는 차트 구현한 내용에 대해서 다뤄보도록 하자!

* 참조 : https://green1229.tistory.com/369

 

오늘은 여기까지!!