본문 바로가기

iOS/SwiftUI

[SwiftUI] Custom Calendar 기능 만들어보기 - 1

안녕하세요.

오늘은 간단한 캘린더 기능을 만들어보겠습니다. 구현하려는 기능의 요구사항은 다음과 같습니다.

 

  • 년도, 월, 일을 달력 형태로 표시.
  • 앞, 뒤 버튼을 통해 월을 변경
  • 일정 정보를 달력에 표시 (노션처럼)

달력과 일정을 표시하기 전에, 먼저 필요한 코드를 작성하겠습니다.

 

import SwiftUI

/**
 날짜 칸 표시를 위한 일자 정보
 */
struct DateValue: Identifiable {
    var id = UUID().uuidString
    var day: Int
    var date: Date
    var isNotCurrentMonth: Bool = false
}

/**
 일정 정보
 */
struct Schedule: Decodable {
    var name: String
    var startDate: Date
    var endDate: Date
    
    /**
     일정 표시 색깔 지정
     */
    var color = Color(red: Double.random(in: 0.0...1.0), green: Double.random(in: 0.0...1.0), blue: Double.random(in: 0.0...1.0))
    
    enum CodingKeys: String, CodingKey {
        case name, startDate, endDate
    }
}

extension Date {
    /**
     년도
     */
    public var year: Int {
        return Calendar.current.component(.year, from: self)
    }
    
    /**
     월
     */
    public var month: Int {
        return Calendar.current.component(.month, from: self)
    }
    
    /**
     일
     */
    public var day: Int {
        return Calendar.current.component(.day, from: self)
    }
    
    /**
     요일
     */
    public var weekday: Int {
        return Calendar.current.component(.weekday, from: self)
    }
    
    /**
     이 날짜가 포함된 월의 모든 일자의 Date
     */
    func getAllDates() -> [Date] {
        let calendar = Calendar.current
        
        //getting start Date...
        let startDate = calendar.date(from: calendar.dateComponents([.year, .month], from: self))!
        
        let range = calendar.range(of: .day, in: .month, for: startDate)!
        
        return range.compactMap { day -> Date in
            return calendar.date(byAdding: .day, value: day - 1, to: startDate)!
        }
    }
    
    /**
     이 날짜가 포함된 월의 마지막 일
     */
    func getLastDayInMonth() -> Int {
        let calendar = Calendar.current
        
        return (calendar.range(of: .day, in: .month, for: self)?.endIndex ?? 0) - 1
    }
    
    /**
     이 날짜가 포함된 월의 첫 일
     */
    func getFirstDayInMonth() -> Int {
        let calendar = Calendar.current
        
        return (calendar.range(of: .day, in: .month, for: self)?.startIndex ?? 0)
    }
    
    /**
     시간 값을 제외한 Date 리턴
     */
    func withOutTime() -> Date {
        let dateComponents = DateComponents(year: self.year, month: self.month, day: self.day)
        
        return Calendar.current.date(from: dateComponents) ?? self
    }
}

extension Color {
    /**
     배경색에 따라 텍스트 색을 검은색 또는 흰색으로 설정
     */
    var textColor: Color {
        let uiColor = UIColor(self)
        
        var red: CGFloat = 0
        var green: CGFloat = 0
        var blue: CGFloat = 0
        var alpha: CGFloat = 0
        uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
        
        //0 to 1 scale이므로 255를 곱해준다
        let value = ( (red*255*299) + (green*255*587) + (blue*255*114) ) / 1000
        
        return value >= 125 ? .black : .white
    }
}

 

날짜 칸 표시를 위한 날짜 정보 struct 및 일정 정보 struct 를 작성하고,

년·월·일자 칸을 표시하기 위한 extension 코드와 일정 이름 색상을 정하기 위한 extension 코드를 작성하였습니다. 


이제 달력을 표시해줄 차례입니다.

달력 표시는 kavsoft 유튜브 채널을 참고하여 구현하였습니다.

 

//
//  CalendarView01.swift
//  CalendarApp
//
//  Created by MacBook on 2022/04/08.
//

import SwiftUI

struct CalendarView01: View {
    let testSchedule = Schedule(name: "일정 테스트 1입니다", startDate: Date(), endDate: Calendar.current.date(byAdding: .day, value: 7, to: Date()) ?? Date())
    
    @State var currentDate = Date()
    @State var daysList = [[DateValue]]()
    
    //화살표 클릭에 의한 월 변경 값
    @State var monthOffset = 0
    
    var body: some View {
        VStack(spacing: 0) {
            
            //년월 및 월 변경 버튼
            HStack(spacing: 20) {
                VStack(alignment: .leading) {
                    Text(year())
                        .font(.caption)
                        .fontWeight(.semibold)
                    Text(month())
                        .font(.title.bold())
                }
                
                Spacer()
                
                Button {
                    monthOffset -= 1
                } label: {
                    Image(systemName: "chevron.left")
                        .font(.title2)
                        .foregroundColor(.blue)
                }
                .buttonStyle(BasicButtonStyle())
                
                Button {
                    monthOffset += 1
                } label: {
                    Image(systemName: "chevron.right")
                        .font(.title2)
                        .foregroundColor(.blue)
                }
                .buttonStyle(BasicButtonStyle())
            } //HStack
            .padding(.horizontal)
            .padding(.bottom, 35)
            
            //요일
            Group {
                let days = ["일", "월", "화", "수", "목", "금", "토"]
                
                HStack(spacing: 0) {
                    ForEach(days, id: \.self) { day in
                        Text(day)
                            .font(.callout)
                            .fontWeight(.semibold)
                            .frame(maxWidth: .infinity)
                    }
                }
            } //Group
            
            //일
            Group {
                ForEach(daysList.indices, id: \.self) { i in
                    HStack(alignment:.top, spacing: 0) {
                        ForEach(daysList[i].indices, id: \.self) { j in
                            CardView(value: daysList[i][j], schedule: testSchedule)
                        }
                    }
                }
            }
            
            Spacer()
        } //VStack
        .onChange(of: monthOffset) { _ in
            // updating Month...
            currentDate = getCurrentMonth()
            daysList = extractDate()
        }
        .task {
            daysList = extractDate()
        }
    } //body
    
    /**
     각각의 날짜에 대한 달력 칸 뷰
     */
    @ViewBuilder
    func CardView(value: DateValue, schedule: Schedule) -> some View {
        VStack(spacing: 0) {
            HStack {
                if value.day > 0 {
                    if value.isNotCurrentMonth {
                        Text("\(value.day)")
                            .font(.title3.bold())
                            .foregroundColor(.gray)
                            .padding([.leading, .bottom], 10)
                    } else {
                        Text("\(value.day)")
                            .font(.title3.bold())
                            .foregroundColor(value.date.weekday == 1 ? .red : value.date.weekday == 7 ? .blue : .black) //일요일 red 토요일 blue
                            .padding([.leading, .bottom], 10)
                    }
                }
                Spacer()
            }
            
            if schedule.startDate.withOutTime() <= value.date && value.date <= schedule.endDate {
                if schedule.startDate.day == value.day {
                    Rectangle()
                        .frame(width: UIScreen.main.bounds.width / 7 + 1, height: 20)
                        .foregroundColor(schedule.color)
                        .padding(.bottom, 5)
                        .overlay(
                            Text(schedule.name)
                                .font(.custom("NotoSansCJKkr-Regular", size: 8))
                                .foregroundColor(schedule.color.textColor)
                                .lineLimit(1)
                        )
                } else {
                    Rectangle()
                        .frame(width: UIScreen.main.bounds.width / 7 + 1, height: 20)
                        .foregroundColor(schedule.color)
                        .padding(.bottom, 5)
                }
                
                Spacer()
            } else {
                Spacer()
            }
        }
        .frame(width: UIScreen.main.bounds.width / 7)
        .frame(maxHeight: .infinity)
        .contentShape(Rectangle())
        .background(Rectangle().stroke())
    }
    
    /**
     현재 날짜 년도
     */
    func year() -> String {
        let formatter = DateFormatter()
        formatter.calendar = Calendar(identifier: .gregorian)
        formatter.locale = Locale(identifier: "ko_KR")
        formatter.dateFormat = "YYYY"
        
        return formatter.string(from: currentDate)
    }
    
    /**
     현재 날짜 월
     */
    func month() -> String {
        let formatter = DateFormatter()
        formatter.calendar = Calendar(identifier: .gregorian)
        formatter.locale = Locale(identifier: "ko_KR")
        formatter.dateFormat = "MMMM"
        
        return formatter.string(from: currentDate)
    }
    
    /**
     현재 월 로드.
     monthOffset 값에 변경이 있을 경우 해당 월 로드
     */
    func getCurrentMonth() -> Date {
        let calendar = Calendar.current
        
        guard let currentMonth = calendar.date(byAdding: .month, value: self.monthOffset, to: Date()) else {
            return Date()
        }
        
        return currentMonth
    }
    
    /**
     현재 월의 일수 로드 (달력 남은 공간을 채우기 위한 이전달 및 다음달 일수 포함)
     */
    func extractDate() -> [[DateValue]] {
        let calendar = Calendar.current
        let currentMonth = getCurrentMonth()
        
        var days = currentMonth.getAllDates().compactMap { date -> DateValue in
            let day = calendar.component(.day, from: date)
            
            return DateValue(day: day, date: date)
        }
        
        //이전달 일수로 남은 공간 채우기
        let firstWeekDay = calendar.component(.weekday, from: days.first?.date ?? Date())
        let prevMonthDate = calendar.date(byAdding: .month, value: -1, to: days.first?.date ?? Date())
        let prevMonthLastDay = prevMonthDate?.getLastDayInMonth() ?? 0
        
        for i in 0..<firstWeekDay - 1 {
            days.insert(DateValue(day: prevMonthLastDay - i, date: calendar.date(byAdding: .day, value: -1, to: days.first?.date ?? Date()) ?? Date(), isNotCurrentMonth: true), at: 0)
        }
        
        //다음달 일수로 남은 공간 채우기
        let lastWeekDay = calendar.component(.weekday, from: days.last?.date ?? Date())
        let nextMonthDate = calendar.date(byAdding: .month, value: 1, to: days.first?.date ?? Date())
        let nextMonthFirstDay = nextMonthDate?.getFirstDayInMonth() ?? 0
        
        for i in 0..<7 - lastWeekDay {
            days.append(DateValue(day: nextMonthFirstDay + i, date: calendar.date(byAdding: .day, value: 1, to: days.last?.date ?? Date()) ?? Date(), isNotCurrentMonth: true))
        }
        
        //달력과 같은 배치의 이차원 배열로 변환하여 리턴
        var result = [[DateValue]]()
        days.forEach {
            if result.isEmpty || result.last?.count == 7 {
                result.append([$0])
            } else {
                result[result.count - 1].append($0)
            }
        }
        
        return result
    }
}

실행 결과

CardView 안에 일정 기간의 포함여부에 따라 Rectangle을 표시하고, 일정 시작일에 일정 이름을 표시하도록 하였습니다.

이렇게 하면 CardView 한 칸에 일정 이름이 노출되기때문에, 일정 이름이 조금만 길어져도 ellipsis 되어버리는 문제가 발생합니다.

또, CardView의 경계선이 보이는 경우가 있습니다. 이를 해결하기 위해서는 CardView 안에 일정을 표시하는게 아니라, CardView 위에 일정을 표시해주어야 합니다.

 

//
//  CalendarView02.swift
//  CalendarTest
//
//  Created by MacBook on 2022/04/08.
//

import SwiftUI

struct CalendarView02: View {
    var testScheduleList = [
        Schedule(name: "일정 테스트 001", startDate: Date(), endDate: Calendar.current.date(byAdding: .day, value: 7, to: Date()) ?? Date()),
        Schedule(name: "일정 테스트 002", startDate: Calendar.current.date(byAdding: .day, value: -3, to: Date()) ?? Date(), endDate: Calendar.current.date(byAdding: .day, value: 10, to: Date()) ?? Date()),
        Schedule(name: "일정 테스트 003", startDate: Calendar.current.date(byAdding: .day, value: -3, to: Date()) ?? Date(), endDate: Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date()),
        Schedule(name: "일정 테스트 004", startDate: Calendar.current.date(byAdding: .day, value: -5, to: Date()) ?? Date(), endDate: Calendar.current.date(byAdding: .day, value: -4, to: Date()) ?? Date()),
        Schedule(name: "일정 테스트 005", startDate: Calendar.current.date(byAdding: .day, value: -2, to: Date()) ?? Date(), endDate: Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date())
    ]
    
    @State var currentDate = Date()
    @State var daysList = [[DateValue]]()
    
    //화살표 클릭에 의한 월 변경 값
    @State var monthOffset = 0
    
    var body: some View {
        VStack(spacing: 0) {
            
            //년월 및 월 변경 버튼
            HStack(spacing: 20) {
                VStack(alignment: .leading) {
                    Text(year())
                        .font(.caption)
                        .fontWeight(.semibold)
                    Text(month())
                        .font(.title.bold())
                }
                
                Spacer()
                
                Button {
                    if monthOffset == 0 { return }
                    monthOffset -= 1
                } label: {
                    Image(systemName: "chevron.left")
                        .font(.title2)
                        .foregroundColor(monthOffset == 0 ? .gray : .blue)
                }
                .buttonStyle(BasicButtonStyle())
                .disabled(monthOffset == 0)
                
                Button {
                    monthOffset += 1
                } label: {
                    Image(systemName: "chevron.right")
                        .font(.title2)
                        .foregroundColor(.blue)
                }
                .buttonStyle(BasicButtonStyle())
            } //HStack
            .padding(.horizontal)
            .padding(.bottom, 35)
            
            //요일
            Group {
                let days = ["일", "월", "화", "수", "목", "금", "토"]
                
                HStack(spacing: 0) {
                    ForEach(days, id: \.self) { day in
                        Text(day)
                            .font(.callout)
                            .fontWeight(.semibold)
                            .frame(maxWidth: .infinity)
                    }
                }
            } //Group
            
            //일
            Group {
                ForEach(daysList.indices, id: \.self) { i in
                    Week(days: daysList[i], scheduleList: testScheduleList)
                }
            }
            
            Spacer()
        } //VStack
        .onChange(of: monthOffset) { _ in
            // updating Month...
            currentDate = getCurrentMonth()
            daysList = extractDate()
        }
        .task {
            daysList = extractDate()
        }
    } //body
    
    /**
     현재 날짜 년도
     */
    func year() -> String {
        let formatter = DateFormatter()
        formatter.calendar = Calendar(identifier: .gregorian)
        formatter.locale = Locale(identifier: "ko_KR")
        formatter.dateFormat = "YYYY"
        
        return formatter.string(from: currentDate)
    }
    
    /**
     현재 날짜 월
     */
    func month() -> String {
        let formatter = DateFormatter()
        formatter.calendar = Calendar(identifier: .gregorian)
        formatter.locale = Locale(identifier: "ko_KR")
        formatter.dateFormat = "MMMM"
        
        return formatter.string(from: currentDate)
    }
    
    /**
     현재 월 로드.
     monthOffset 값에 변경이 있을 경우 해당 월 로드
     */
    func getCurrentMonth() -> Date {
        let calendar = Calendar.current
        
        guard let currentMonth = calendar.date(byAdding: .month, value: self.monthOffset, to: Date()) else {
            return Date()
        }
        
        return currentMonth
    }
    
    /**
     현재 월의 일수 로드 (달력 남은 공간을 채우기 위한 이전달 및 다음달 일수 포함)
     */
    func extractDate() -> [[DateValue]] {
        let calendar = Calendar.current
        let currentMonth = getCurrentMonth()
        
        var days = currentMonth.getAllDates().compactMap { date -> DateValue in
            let day = calendar.component(.day, from: date)
            
            return DateValue(day: day, date: date)
        }
        
        //이전달 일수로 남은 공간 채우기
        let firstWeekDay = calendar.component(.weekday, from: days.first?.date ?? Date())
        let prevMonthDate = calendar.date(byAdding: .month, value: -1, to: days.first?.date ?? Date())
        let prevMonthLastDay = prevMonthDate?.getLastDayInMonth() ?? 0
        
        for i in 0..<firstWeekDay - 1 {
            days.insert(DateValue(day: prevMonthLastDay - i, date: calendar.date(byAdding: .day, value: -1, to: days.first?.date ?? Date()) ?? Date(), isNotCurrentMonth: true), at: 0)
        }
        
        //다음달 일수로 남은 공간 채우기
        let lastWeekDay = calendar.component(.weekday, from: days.last?.date ?? Date())
        let nextMonthDate = calendar.date(byAdding: .month, value: 1, to: days.first?.date ?? Date())
        let nextMonthFirstDay = nextMonthDate?.getFirstDayInMonth() ?? 0
        
        for i in 0..<7 - lastWeekDay {
            days.append(DateValue(day: nextMonthFirstDay + i, date: calendar.date(byAdding: .day, value: 1, to: days.last?.date ?? Date()) ?? Date(), isNotCurrentMonth: true))
        }
        
        var result = [[DateValue]]()
        days.forEach {
            if result.isEmpty || result.last?.count == 7 {
                result.append([$0])
            } else {
                result[result.count - 1].append($0)
            }
        }
        
        return result
    }
}

struct Week: View {
    
    var days: [DateValue]
    var scheduleList: [Schedule]
    let colWidth = UIScreen.main.bounds.width / 7
    
    init(days: [DateValue], scheduleList: [Schedule]) {
        self.days = days
        /**
         현재 주에 포함된 스케쥴 필터
         */
        self.scheduleList = scheduleList.filter({ ch in
            guard let weekFirst = days.first?.date,
                  let weekLast = days.last?.date else { return false }
            
            return (weekFirst <= ch.startDate && ch.startDate <= weekLast)
                    || (weekFirst <= ch.endDate && ch.endDate <= weekLast)
                    || (ch.startDate <= weekFirst && weekLast <= ch.endDate)
        })
    }
    
    var body: some View {
        HStack(spacing: 0) {
            ForEach(days.indices, id: \.self) { i in
                CardView(value: days[i], scheduleCount: scheduleList.count)
            }
        }
        .overlay(
            Group {
                if !scheduleList.isEmpty {
                    VStack(spacing: 0) {
                        Spacer()
                        
                        ForEach(scheduleList.indices, id: \.self) { i in
                            HStack(spacing: 0) {
                                if let weekFirst = self.days.first?.date, let weekLast = self.days.last?.date {
                                    if ( weekFirst <= scheduleList[i].startDate && scheduleList[i].startDate <= weekLast ) {
                                        Spacer()
                                            .frame(width: colWidth * CGFloat((scheduleList[i].startDate.day - self.days[0].day)), height: 20)
                                    }
                                } //if
                                
                                Text(scheduleList[i].name)
                                    .font(.custom("NotoSansCJKkr-Regular", size: 11))
                                    .foregroundColor(scheduleList[i].color.textColor)
                                    .lineLimit(1)
                                    .padding(.vertical, 2)
                                    .frame(maxWidth: .infinity)
                                    .background(scheduleList[i].color)
                                
                                if let weekFirst = self.days.first?.date, let weekLast = self.days.last?.date {
                                    if ( weekFirst <= scheduleList[i].endDate && scheduleList[i].endDate <= weekLast ) {
                                        Spacer()
                                            .frame(width: colWidth * CGFloat((self.days[self.days.count - 1].day - scheduleList[i].endDate.day)), height: 20)
                                    }
                                } //if
                            } //HStack
                            .padding(.bottom, 5)
                        } //ForEach
                    } //VStack
                } //if
                else {
                    EmptyView()
                }
            } //Group
        ) //.overlay
    } //body
    
    /**
     각각의 날짜에 대한 달력 칸 뷰
     */
    @ViewBuilder
    func CardView(value: DateValue, scheduleCount: Int) -> some View {
        VStack(spacing: 0) {
            HStack {
                if value.day > 0 {
                    if value.isNotCurrentMonth {
                        Text("\(value.day)")
                            .font(.title3.bold())
                            .foregroundColor(.gray)
                    } else {
                        Text("\(value.day)")
                            .font(.title3.bold())
                            .foregroundColor(value.date.weekday == 1 ? .red : value.date.weekday == 7 ? .blue : .black) //일요일 red 토요일 blue
                    }
                }
                Spacer()
            }
            .padding(.leading, 5)
            .frame(height: 30)
            
            Spacer()
                .frame(height: 25 * CGFloat(scheduleCount))
        }
        .frame(width: UIScreen.main.bounds.width / 7)
        .contentShape(Rectangle())
        .background(Rectangle().stroke())
    }
}

실행 결과

해당 주에 일정이 포함되어 있으면 CardView의 높이를 일정의 개수만큼 증가시켜주고, .overlay를 사용하여 CardView 위에 일정 기간을 표시해주도록 구현해보았습니다.

이렇게하면 일정 이름도 잘 보여줄 수 있지만, 이 상태면 같은 주에 짧은 일정이 두 개 이상 있는 경우도 두 줄로 보여주게 됩니다.

어떻게 하면 일정을 더 깔끔하게 보여줄 수 있을지는 다음 포스팅에서 다뤄보겠습니다.