출처
소개
아래와 같은 달력을 그리고자 합니다.
달력을 보면 일요일 ~ 토요일 순으로 해당 날짜가 적혀 있습니다. 괄호 안은 해당 달에 속하지 않는 이전 달 또는 다음달 날짜가 적혀있습니다. 예를 들어 2023년 2월(February)
을 보면 첫 주의 (29) (30) (31)
은 1월
에 속해있으며 마지막 주의 (1) (2) (3) (4)
는 3월
에 속해 있습니다. (각 월간 달력과 비교해보세요.)
이러한 달력을 그릴 수 있는 방법에 대해 소개해드리겠습니다. 이번 포스트는 달력에 대한 Metadata를 분석하고 생성하는 방법에 관한 내용을 다루겠습니다.
Xcode에서 새로운 플레이그라운드에서 새로운 파일을 생성합니다.
달력 데이터 분석
사전 준비
한 달을 표시하려면 일(days) 목록이 필요합니다. Day
구조체를 추가합니다.
struct Day { /// Date 인스턴스. let date: Date /// 화면에 표시될 숫자. /// 예) Date 인스턴스가 2022년 1월 25일이라면 -> 25 let number: String /// 이 날짜가 선택되었는지 여부. let isSelected: Bool /// 이 날짜가 현재 달 내에 있는지 추적. /// 예) 1월 달력을 그리고자 할 떄 Date 인스턴스가 1월 25일이라면 true, 2월 1일이라면 false let isWithinDisplayedMonth: Bool }
Day
구조체는 특정 일에 대한 각종 정보를 나타냅니다.- 어느 특정 월에 대한 데이터가
[Days]
형태로 관리됩니다.
그 밑에 MonthMetadata
구조체를 추가합니다.
struct MonthMetadata { /// 해당 달의 총 일수, 예를 들어 1월은 31일까지 있으므로 31 let numberOfDays: Int /// 해당 달의 첫 Date let firstDay: Date /// 해당 달의 첫 Date가 무슨 요일인지 반환, 일 ~ 토 => 1 ~ 7 /// 예) 수요일이라면 4 let firstDayWeekday: Int }
- 이 구조체는 기준 데이트(Base Date; 보통 현재 시각에 대한 Date 인스턴스를 기준 데이트로 합니다.)를 토대로 월별 메타데이터를 생성하는데 사용됩니다..
그 밑에 CalendarDataError
를 추가합니다. 메타데이터 생성에 실패했을 때 발생하는 에러로, 나중에 사용될 것입니다.
enum CalendarDataError: Error { case metadataGeneration }
달력 생성에 사용할 날짜 관련 변수를 추가합니다.
private let baseDate = Date() private let calendar = Calendar(identifier: .gregorian) private let selectedDate: Date = baseDate private var dateFormatter_OnlyD: DateFormatter = { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "d" return dateFormatter }() private var dateFormatter_CalendarTitle: DateFormatter = { let dateFormatter = DateFormatter() dateFormatter.calendar = Calendar(identifier: .gregorian) dateFormatter.locale = Locale.autoupdatingCurrent dateFormatter.setLocalizedDateFormatFromTemplate("MMMM y") return dateFormatter }()
- baseDate
- 달력을 생성하는 기준 날짜입니다. 예를 들어
baseDate
가 2023년 2월 3일이라면 2023년 2월 달력을 생성하게 됩니다. - 현재 날짜
Date()
를 기준으로 하겠습니다.
- 달력을 생성하는 기준 날짜입니다. 예를 들어
- calendar
baseDate
를 기준으로 한달 전, 한달 후 등 날짜를 계산할 때 사용할Calendar
인스턴스입니다.
- selectedDate는 날짜를 선택했을 때 해당 날짜를 저장하는 변수로, 다음 포스트에서 다룰 것입니다
- .dateFormatter
- 날짜를
String
포맷으로 반환합니다. - 첫번째는 요일 숫자만 반환하고, 두번째는
"January 2023"
형태의 달력 제목에 사용할 포맷을 반환합니다.
- 날짜를
메타데이터 생성
다음 함수들을 이용해 메타데이터를 생성할 것입니다.
/// 1. Date를 기준으로 월별 메타데이터인 MonthMetaData 인스턴스를 생성. func monthMetadata(for baseDate: Date) throws -> MonthMetadata { } /// 2. Adds or subtracts an offset from a Date to produce a new one, and return its result. func generateDay(offsetBy dayOffset: Int, for baseDate: Date, isWithinDisplayedMonth: Bool) -> Day { } /// 3. Takes the first day of the displayed month and returns an array of Day objects. func generateStartOfNextMonth(using firstDayOfDisplayedMonth: Date) -> [Day] { } /// 4. Takes in a Date and returns an array of Days. func generateDaysInMonth(for baseDate: Date) -> [Day] { }
- 현재
baseDate
를 기준으로MonthMetadata
를 생성합니다. Date
에서 오프셋을 더하거나 빼서 산출한 새로운Day
를 생성하고 반환합니다.- 표시된 월의 첫 번째 날(
firstDayOfDisplayedMonth
)을 바탕으로 Day 객체의 배열을 반환합니다. 이 함수를 통해 매월 마지막 주를 어떻게 처리할 것인지를 정의할 수 있습니다. - 현재
baseDate
를 기준으로 달력 표시에 사용될[Days]
배열을 반환합니다.
이 중 1 ~ 3번은 내부 계산에 사용되며, 실제로 사용하는 함수는 마지막 generateDaysInMonth(...)
함수입니다.
월별 메타데이터 생성
현재 baseDate
를 기준으로 MonthMetadata
를 생성합니다. monthMetadata(...)
함수를 추가합니다.
/// Date를 기준으로 월별 메타데이터인 MonthMetaData 인스턴스를 생성. func monthMetadata(for baseDate: Date) throws -> MonthMetadata { // You ask the calendar for the number of days in baseDate‘s month, then you get the first day of that month. guard let numberOfDaysInMonth = calendar.range(of: .day, in: .month, for: baseDate)?.count, let firstDayOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: baseDate)) else { // Both of the previous calls return optional values. If either returns nil, the code throws an error and returns. throw CalendarDataError.metadataGeneration } // You get the weekday value, a number between one and seven that represents which day of the week the first day of the month falls on. // weekday: 주일, 평일: 일요일 이외의 6일간을 가리키는 경우와 토·일요일 이외의 5일간을 가리키는 경우가 있음. let firstDayWeekday: Int = calendar.component(.weekday, from: firstDayOfMonth) // Finally, you use these values to create an instance of MonthMetadata and return it. return MonthMetadata( numberOfDays: numberOfDaysInMonth, firstDay: firstDayOfMonth, firstDayWeekday: firstDayWeekday) }
- guard ~ else
numberOfDaysInMonth
:baseDate
를 기반으로 달력의 월의 일수firstDayOfMonth
: 해당 월의 첫 번째 날을 얻습니다.- 둘 중 하나라도
nil
을 반환하면 코드에서CalendarDataError.metadataGeneration
오류가 발생하고 반환됩니다.
- 월의 첫 번째 날이 해당하는 요일(일요일 ~ 토요일)을 나타내는
1
에서7
사이의 숫자인 값을 얻습니다. - 이러한 값들을 사용하여
MonthMetadata
의 인스턴스를 생성하고 반환합니다.
해당 달의 [Days] 목록 생성
이제 가장 중요한 Day
목록을 생성하는 단계입니다.
먼저 generateDay
함수를 추가합니다.
/// Adds or subtracts an offset from a Date to produce a new one, and return its result. func generateDay(offsetBy dayOffset: Int, for baseDate: Date, isWithinDisplayedMonth: Bool) -> Day { let date = calendar.date(byAdding: .day, value: dayOffset, to: baseDate) ?? baseDate return Day( date: date, number: dateFormatter_OnlyD.string(from: date), isSelected: calendar.isDate(date, inSameDayAs: selectedDate), isWithinDisplayedMonth: isWithinDisplayedMonth) }
Date
에서 오프셋을 더하거나 빼서 산출한 새로운Day
를 생성하고 반환합니다.- date
- 예를 들어 현재 날짜
baseDate
가 2월 3일이라면,offset
이-1
인 경우 2월 2일,+1
인 경우 2월 3일을 반환합니다.
- 예를 들어 현재 날짜
- return Day(…)
number
:Date
의 일 숫자isSelected
:selectedDate
와 같은 날짜인지 여부isWithinDisplayedMonth
: 예를 들어 2월의 달력을 그린다고 하면, 첫번째 섹션의 2월 달력 그림에서 괄호 안에 있는 29 ~ 31일은 1월달의 Date 인스턴스이므로false
가 됩니다.
다음 generateStartOfNextMonth
함수를 추가합니다.
/// Takes the first day of the displayed month and returns an array of Day objects. func generateStartOfNextMonth(using firstDayOfDisplayedMonth: Date) -> [Day] { // Retrieve the last day of the displayed month. If this fails, you return an empty array. guard let lastDayInMonth = calendar.date( byAdding: DateComponents(month: 1, day: -1), to: firstDayOfDisplayedMonth) else { return [] } // Calculate the number of extra days you need to fill the last row of the calendar. // For instance, if the last day of the month is a Saturday, the result is zero and you return an empty array. let additionalDays = 7 - calendar.component(.weekday, from: lastDayInMonth) guard additionalDays > 0 else { return [] } /* Create a Range<Int> from one to the value of additionalDays, as in the previous section. Then, it transforms this into an array of Days. This time, generateDay(offsetBy:for:isWithinDisplayedMonth:) adds the current day in the loop to lastDayInMonth to generate the days at the beginning of the next month. */ let days: [Day] = (1...additionalDays) .map { generateDay(offsetBy: $0, for: lastDayInMonth, isWithinDisplayedMonth: false) } return days }
- 이 함수를 통해 매월 마지막 주를 어떻게 처리할 것인지를 정의합니다.
- guard let lastDayInMonth = …
firstDayOfDisplayedMonth
의 마지막 날을 찾습니다. 예를 들어 1월이라면 1월 31일의 Date 인스턴스입니다.- 실패하면 빈 배열을 반환합니다.
- additionalDays
- 달력의 마지막 행을 채우는 데 필요한 추가 일수를 계산합니다.
- 예를 들어 월의 마지막 날이 토요일인 경우 결과는
0
이고 빈 배열을 반환합니다. - 월의 마지막 날이 화요일인 경우 결과는
4
(수, 목, 금, 토)이며 해당하는 배열을 반환합니다.
- days
1
에서additionalDays
값까지Range<Int>
를 만듭니다.- 그런 다음 이를
Days
의 배열로 변환합니다. - 앞에서 추가한 g
enerateDay(offsetBy:for:isWithinDisplayedMonth:)
가 루프의 현재 날짜를lastDayInMonth
에 추가하여 다음 달 시작 날짜를 생성합니다.
- 이 방법의 결과는
generateDaysInMonth(for:)
에서 생성한Days
배열과 결합하여 사용됩니다.
다음 실제로 사용할 함수인 monthMetadata(for:)
를 추가합니다.
/// Takes in a Date and returns an array of Days. func generateDaysInMonth(for baseDate: Date) -> [Day] { // Retrieve the metadata you need about the month, using monthMetadata(for:). // If something goes wrong here, the app can’t function. As a result, it terminates with a fatalError. guard let metadata = try? monthMetadata(for: baseDate) else { fatalError("An error occurred when generating the metadata for \(baseDate)") } let numberOfDaysInMonth = metadata.numberOfDays let offsetInInitialRow = metadata.firstDayWeekday let firstDayOfMonth = metadata.firstDay /* If a month starts on a day other than Sunday, you add the last few days from the previous month at the beginning. This avoids gaps in a month’s first row. Here, you create a Range<Int> that handles this scenario. For example, if a month starts on Friday, offsetInInitialRow would add five extra days to even up the row. You then transform this range into [Day], using map(_:). */ var days: [Day] = (1..<(numberOfDaysInMonth + offsetInInitialRow)) .map { day in // Check if the current day in the loop is within the current month or part of the previous month. let isWithinDisplayedMonth = day >= offsetInInitialRow // Calculate the offset that day is from the first day of the month. If day is in the previous month, this value will be negative. let dayOffset = isWithinDisplayedMonth ? day - offsetInInitialRow : -(offsetInInitialRow - day) // Call generateDay(offsetBy:for:isWithinDisplayedMonth:), which adds or subtracts an offset from a Date to produce a new one, and return its result. return generateDay(offsetBy: dayOffset, for: firstDayOfMonth, isWithinDisplayedMonth: isWithinDisplayedMonth) } days += generateStartOfNextMonth(using: firstDayOfMonth) return days }
- guard let metadata = …
monthMetadata(for:)
를 사용하여 해당 월에 대해 필요한 메타데이터를 검색합니다. 여기서 문제가 발생하면 앱이 작동하지 않습니다. 그 결과,fatalError
로 종료됩니다.
- days
- 한 달이 일요일이 아닌 다른 날에 시작하는 경우 이전 달의 마지막 며칠을 시작 부분에 추가합니다.
- 이렇게 하면 한 달의 첫 번째 행에 공백이 생기는 것을 방지할 수 있습니다.
- 여기에서 이 시나리오를 처리하는
Range<Int>
를 만듭니다. - 예를 들어 한 달이 금요일에 시작하는 경우
offsetInInitialRow
는5
일을 더 추가하여 해당 라인을 고르게 만듭니다. (밑의 다이어그램 참조) map(_:)
을 사용하여 이 범위를[Day]
로 변환합니다.
- 루프의 현재 날짜가 현재 달 또는 이전 달의 일부인지 확인합니다.
- 해당 날짜가 해당 월의 1일부터 얼마나 떨어져 있는지 오프셋을 계산합니다. 일이 이전 달인 경우 이 값은 음수가 됩니다.
- 마지막 주에 해당하는 새로운 날짜를 생성하기 위해 날짜에서 오프셋을 더하거나 빼는
generateDay(offsetBy:for:isWithinDisplayedMonth:)
를 호출하고 그 결과를 반환합니다.
아래는 이해를 돕기 위한 다이어그램입니다.
달력을 그리기 위한 데이터 생성 부분이 완성되었습니다.
달력 그리기
이를 바탕으로 콘솔에 print
하는 방식으로 임시로 달력을 그려보겠습니다.
let targetMonths = [ calendar.date(byAdding: .month, value: -1, to: baseDate), baseDate, calendar.date(byAdding: .month, value: +1, to: baseDate), ] for month in targetMonths { guard let baseDate = month else { fatalError() } let days = generateDaysInMonth(for: baseDate) let lineText = "-------------------------------" // 제목 print("\(dateFormatter_CalendarTitle.string(from: baseDate))") print() // 요일 let weekdayText = ["일", "월", "화", "수", "목", "금", "토"] print(weekdayText.joined(separator: "\t")) print(lineText) // 숫자 for (index, day) in days.enumerated() { print(day.isWithinDisplayedMonth ? day.number : "(\(day.number))", terminator: "\t") if (index + 1) % 7 == 0 { print("\n\(lineText)") } } print("\n\n") }
- targetMonths
baseDate
를 바탕으로 그 이전달 및 다음달을 타깃으로 합니다. 현재2월
인 경우1월, 2월, 3월
이 대상입니다.
- days = generateDaysInMonth(for: baseDate)
- 현재
baseDate
를 기준으로 달력에 표시할 Day 목록을 생성합니다.
- 현재
- \t
String
에서 사용하며, 일정 너비의 공간(탭
)을 추가합니다.
- day.isWithinDisplayedMonth ? … : …
- 예를 들어 2월 달력을 그리는 경우 2월에 해당하면 그대로
day.number
를 표시하고, 그 외의 경우는 이전 달 또는 다음 달의 일수이므로 괄호를 쳐서day.number
를 표시합니다.
- 예를 들어 2월 달력을 그리는 경우 2월에 해당하면 그대로
- if (index + 1) % 7 == 0
- 한 라인 당 일요일 ~ 토요일 총 7개의 칸이 표시되어야 합니다.
- 7개 숫자를 표시하였다면, 새로운 라인으로 이동합니다.
다음 포스트는 위의 내용을 기반으로 스토리보드의 UICollectionView
에서 달력을 표시하는 방법에 대해 알아보겠습니다.
전체 코드
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import UIKit | |
struct Day { | |
/// Date 인스턴스. | |
let date: Date | |
/// 화면에 표시될 숫자. | |
/// 예) Date 인스턴스가 2022년 1월 25일이라면 -> 25 | |
let number: String | |
/// 이 날짜가 선택되었는지 여부. | |
let isSelected: Bool | |
/// 이 날짜가 현재 달 내에 있는지 추적. | |
/// 예) 1월 달력을 그리고자 할 떄 Date 인스턴스가 1월 25일이라면 true, 2월 1일이라면 false | |
let isWithinDisplayedMonth: Bool | |
} | |
struct MonthMetadata { | |
/// 해당 달의 총 일수, 예를 들어 1월은 31일까지 있으므로 31 | |
let numberOfDays: Int | |
/// 해당 달의 첫 Date | |
let firstDay: Date | |
/// 해당 달의 첫 Date가 무슨 요일인지 반환, 일 ~ 토 => 1 ~ 7 | |
/// 예) 수요일이라면 4 | |
let firstDayWeekday: Int | |
} | |
enum CalendarDataError: Error { | |
case metadataGeneration | |
} | |
// MARK: – Member Variables | |
private let baseDate = Date() | |
private let calendar = Calendar(identifier: .gregorian) | |
private let selectedDate: Date = baseDate | |
// lazy | |
private var dateFormatter_OnlyD: DateFormatter = { | |
let dateFormatter = DateFormatter() | |
dateFormatter.dateFormat = "d" | |
return dateFormatter | |
}() | |
// lazy | |
private var dateFormatter_CalendarTitle: DateFormatter = { | |
let dateFormatter = DateFormatter() | |
dateFormatter.calendar = Calendar(identifier: .gregorian) | |
dateFormatter.locale = Locale.autoupdatingCurrent | |
dateFormatter.setLocalizedDateFormatFromTemplate("MMMM y") | |
return dateFormatter | |
}() | |
// MARK: – Generating a Month’s Metadata | |
/// Date를 기준으로 월별 메타데이터인 MonthMetaData 인스턴스를 생성. | |
func monthMetadata(for baseDate: Date) throws -> MonthMetadata { | |
// You ask the calendar for the number of days in baseDate‘s month, then you get the first day of that month. | |
guard | |
let numberOfDaysInMonth = calendar.range(of: .day, in: .month, for: baseDate)?.count, | |
let firstDayOfMonth = calendar.date(from: calendar.dateComponents([.year, .month], from: baseDate)) | |
else { | |
// Both of the previous calls return optional values. If either returns nil, the code throws an error and returns. | |
throw CalendarDataError.metadataGeneration | |
} | |
// You get the weekday value, a number between one and seven that represents which day of the week the first day of the month falls on. | |
// weekday: 주일, 평일: 일요일 이외의 6일간을 가리키는 경우와 토·일요일 이외의 5일간을 가리키는 경우가 있음. | |
let firstDayWeekday: Int = calendar.component(.weekday, from: firstDayOfMonth) | |
// Finally, you use these values to create an instance of MonthMetadata and return it. | |
return MonthMetadata( | |
numberOfDays: numberOfDaysInMonth, | |
firstDay: firstDayOfMonth, | |
firstDayWeekday: firstDayWeekday) | |
} | |
/// Adds or subtracts an offset from a Date to produce a new one, and return its result. | |
func generateDay(offsetBy dayOffset: Int, for baseDate: Date, isWithinDisplayedMonth: Bool) -> Day { | |
let date = calendar.date(byAdding: .day, value: dayOffset, to: baseDate) ?? baseDate | |
return Day( | |
date: date, | |
number: dateFormatter_OnlyD.string(from: date), | |
isSelected: calendar.isDate(date, inSameDayAs: selectedDate), | |
isWithinDisplayedMonth: isWithinDisplayedMonth) | |
} | |
/// Takes the first day of the displayed month and returns an array of Day objects. | |
func generateStartOfNextMonth(using firstDayOfDisplayedMonth: Date) -> [Day] { | |
// Retrieve the last day of the displayed month. If this fails, you return an empty array. | |
guard let lastDayInMonth = calendar.date( | |
byAdding: DateComponents(month: 1, day: -1), | |
to: firstDayOfDisplayedMonth) else { | |
return [] | |
} | |
// Calculate the number of extra days you need to fill the last row of the calendar. | |
// For instance, if the last day of the month is a Saturday, the result is zero and you return an empty array. | |
let additionalDays = 7 – calendar.component(.weekday, from: lastDayInMonth) | |
guard additionalDays > 0 else { | |
return [] | |
} | |
/* | |
Create a Range<Int> from one to the value of additionalDays, as in the previous section. | |
Then, it transforms this into an array of Days. | |
This time, generateDay(offsetBy:for:isWithinDisplayedMonth:) adds the current day in the loop to lastDayInMonth | |
to generate the days at the beginning of the next month. | |
*/ | |
let days: [Day] = (1…additionalDays) | |
.map { | |
generateDay(offsetBy: $0, for: lastDayInMonth, isWithinDisplayedMonth: false) | |
} | |
return days | |
} | |
/// Takes in a Date and returns an array of Days. | |
func generateDaysInMonth(for baseDate: Date) -> [Day] { | |
// Retrieve the metadata you need about the month, using monthMetadata(for:). | |
// If something goes wrong here, the app can’t function. As a result, it terminates with a fatalError. | |
guard let metadata = try? monthMetadata(for: baseDate) else { | |
fatalError("An error occurred when generating the metadata for \(baseDate)") | |
} | |
let numberOfDaysInMonth = metadata.numberOfDays | |
let offsetInInitialRow = metadata.firstDayWeekday | |
let firstDayOfMonth = metadata.firstDay | |
/* | |
If a month starts on a day other than Sunday, you add the last few days from the previous month at the beginning. | |
This avoids gaps in a month’s first row. Here, you create a Range<Int> that handles this scenario. | |
For example, if a month starts on Friday, offsetInInitialRow would add five extra days to even up the row. | |
You then transform this range into [Day], using map(_:). | |
*/ | |
var days: [Day] = (1..<(numberOfDaysInMonth + offsetInInitialRow)) | |
.map { day in | |
// Check if the current day in the loop is within the current month or part of the previous month. | |
let isWithinDisplayedMonth = day >= offsetInInitialRow | |
// Calculate the offset that day is from the first day of the month. If day is in the previous month, this value will be negative. | |
let dayOffset = isWithinDisplayedMonth ? day – offsetInInitialRow : -(offsetInInitialRow – day) | |
// Call generateDay(offsetBy:for:isWithinDisplayedMonth:), which adds or subtracts an offset from a Date to produce a new one, and return its result. | |
return generateDay(offsetBy: dayOffset, for: firstDayOfMonth, isWithinDisplayedMonth: isWithinDisplayedMonth) | |
} | |
days += generateStartOfNextMonth(using: firstDayOfMonth) | |
return days | |
} | |
// MARK: – 달력 그리기 | |
let targetMonths = [ | |
calendar.date(byAdding: .month, value: -1, to: baseDate), | |
baseDate, | |
calendar.date(byAdding: .month, value: +1, to: baseDate), | |
] | |
for month in targetMonths { | |
guard let baseDate = month else { fatalError() } | |
// lazy | |
let days = generateDaysInMonth(for: baseDate) | |
let lineText = "——————————-" | |
// 제목 | |
print("\(dateFormatter_CalendarTitle.string(from: baseDate))") | |
print() | |
// 요일 | |
let weekdayText = ["일", "월", "화", "수", "목", "금", "토"] | |
print(weekdayText.joined(separator: "\t")) | |
print(lineText) | |
// 숫자 | |
for (index, day) in days.enumerated() { | |
print(day.isWithinDisplayedMonth ? day.number : "(\(day.number))", terminator: "\t") | |
if (index + 1) % 7 == 0 { | |
print("\n\(lineText)") | |
} | |
} | |
print("\n\n") | |
} |
0개의 댓글