안녕하세요.
오늘은 간단한 캘린더 기능을 만들어보겠습니다. 구현하려는 기능의 요구사항은 다음과 같습니다.
- 년도, 월, 일을 달력 형태로 표시.
- 앞, 뒤 버튼을 통해 월을 변경
- 일정 정보를 달력에 표시 (노션처럼)
달력과 일정을 표시하기 전에, 먼저 필요한 코드를 작성하겠습니다.
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 위에 일정 기간을 표시해주도록 구현해보았습니다.
이렇게하면 일정 이름도 잘 보여줄 수 있지만, 이 상태면 같은 주에 짧은 일정이 두 개 이상 있는 경우도 두 줄로 보여주게 됩니다.
어떻게 하면 일정을 더 깔끔하게 보여줄 수 있을지는 다음 포스팅에서 다뤄보겠습니다.
'iOS > SwiftUI' 카테고리의 다른 글
[SwiftUI] 프로그래머스 과제관 (K-MOOC 강좌정보 서비스) (0) | 2022.04.06 |
---|---|
[SwiftUI] "손쉬운 사용" 버튼 스타일 (0) | 2022.03.03 |
[SwiftUI] NavigationLink 버그 (2) | 2022.02.23 |
[SwiftUI] Naver Map에 Custom Marker 띄우기 (2) | 2022.02.16 |
[SwiftUI] Custom Navigation Bar의 Back Swipe 액션 활성화하기 (2) | 2022.02.15 |