욤찌의 개발 일기
[SwiftUI] Custom Calendar을 만들어보자 본문
드디어 모공모공🫧의 첫 출시 앱 💖SYM-Speak Your Mind💖 의 개발이 거의 끝났댜 ㅎㅅㅎ!
이번에 감정일기 앱 프로젝트를 하면서 아무래도 일기인만큼 캘린더 기능이 필수적이라고 생각했고,
거기에 우리는 외부 라이브러리 없이 Custom으로 캘린더를 만들어보는게 목표였움
사실 내가 핸드폰에서 제일 많이 쓰는 기능이 캘린더📅이기도 하고
이전부터 캘린더는 한 번 만들어보고 싶어서 ,, 캘린더UI 구현해보겠다고 자원하긴 했눈데,,
생각보다 어렵고 까다로운 ,,,ㅜ 그리고 참고할만한 자료가 많이 없기도 했었다🥹
다행히 좋은 레퍼런스들을 찾아서👼🏻 어찌저찌 완성한 캘린더✨✨✨
어떻게 구현했는지 보면서 핵심들을 한 번 쇽쇽 뽑아봅시당🚀
일단 캘린더가 우째 생겼는지 먼저 보시죠잉

일단 캘린더의 구조를 보자면👀
1️⃣ 현재 보여지는 캘린더의 연도(year)와 월(month)가 상단에 위치함.
연도와 월 레이블 옆에 아래로 향하는 화살표 버튼을 탭하면 Picker가 나타나서 다른 연도와 월로 이동할 수 있음.
2️⃣ 요일(weekday) 레이블이 위치함. 일요일일 경우에는 foregroundColor를 빨간색으로 설정함
3️⃣ 해당 연도의 월에 맞는 날짜가 캘린더 모습으로 나타남.
4️⃣ 우리는 일기를 작성하고 캘린더에서 확인할 수 있는 앱이라서 일기를 작성한 날과 아닌 날짜의 글자색을 다르게 설정함.
⭕️ 해당 날짜에 일기가 있는 경우 : 글자색을 진하게 표시하게 날짜 아래 red dot이 나타남
❌ 해당 날짜에 일기가 없는 경우 : 글자색을 연하게 표시하고 dot 나타나지 않음(white로 표시)
5️⃣ 오늘 날짜일 경우에는 날짜 위에 "오늘" 레이블 표시함
6️⃣ 날짜를 선택하면 선택된 날짜 배경에 pink circle 표시됨
7️⃣ 캘린더 Drag Gesture로 양 옆으로 넘어감
이렇게 정리해볼 수 있겠음!
이 글에서는 캘린더의 큰 뼈대가 되는 부분들만 한 번 훑어볼게용!
1. 연도 & 월 헤더뷰

먼저, 사용자가 캘린더의 날짜를 변경하기 전 가장 먼저 보이는 캘린더는 현재 날짜의 캘린더가 보여야겠죠잉
그래서 현재 날짜의 Year와 Month를 한 번 뽑아봅시댜
저희는 MVVM으로 구현했기 때문에 View와 ViewModel이 분리되어있어서 좀 코드가 복잡시럽지만 나눠서 써봅니댜
ViewModel 먼저 보자면,
@Published var currentDate: Date = Date()
/// 현재 연도, 월 String으로 변경하는 formatter로 배열 구하는 함수
func getYearAndMonthString(currentDate: Date) -> [String] {
let formatter = DateFormatter()
formatter.dateFormat = "YYYY MMMM"
formatter.locale = Locale(identifier: "ko_kr")
let date = formatter.string(from: currentDate)
return date.components(separatedBy: " ")
}
📌 func getYearAndMonthString
이 함수는 Date를 파라미터로 받아서 [String]을 리턴하는 함수죠잉
파라미터로 받는 currentDate를 viewModel에 프로퍼티로 생성하고 Date()로 해서 현재 날짜로 초기화함다
DateFormatter를 이용해서 dateFormat과 locale을 설정해준 후에
currentDate를 설정해준 formatter를 이용해서 string으로 변환하고
components 함수를 이용해서 연도와 월을 나눠 각각 배열의 아이템으로 만들어줍니닷
함수를 이용해서 Date()의 연도와 월을 알아내면 이런 결과가 나타납니다!
(locale을 설정하지 않으면 3월이 March로 나온답니당)

그리고 View에서
Text("\(calendarViewModel.getYearAndMonthString(currentDate: calendarViewModel.currentDate)[0])년
\(calendarViewModel.getYearAndMonthString(currentDate: calendarViewModel.currentDate)[1])")
위에서 만든 함수를 뷰모델에서 가져옵니다. 파라미터 역시 뷰모델에서 생성해준 currentDate 프로퍼티를 넣어줍니다
그러면 연도와 월의 헤더뷰는 완성!
(Picker 부분은 좀 ,, 많이 복잡시러워서 나중에 추가로 적어놓을게요!)
2. Weekday 헤더뷰

weekday 헤더뷰는 참 쉬어가는 타임입니당ㅎ
struct WeekdayHeaderView: View {
private let weekday: [String] = ["일", "월", "화", "수", "목", "금", "토"]
var body: some View {
HStack {
ForEach(weekday, id: \.self) { day in
Text(day)
.font(.callout)
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
// 만약 일요일이라면 글씨색 red
.foregroundStyle(day == "일" ? Color.errorRed : Color.symBlack)
}
}
.padding(.bottom, 5)
}
}
weekday 라는 문자열 배열을 만들어 요일을 담아줍니다
그리고 ForEach로 weekday를 하나씩 꺼내서 Text에 담아주고
"일"요일 일때만 foregroundStyle을 바꿔주면 됩니당
3. Date(날짜) View

하핫 여기서부터 조금 ,, 많이 ,,, 까다롭쥬 ,,~?
일단 코드부터 봅시다잉
📍
struct DateValue: Identifiable {
var id: String = UUID().uuidString
var day: Int
var date: Date
}
📍
final class CalendarViewModel {
...
@Published var currentDate: Date = Date()
@Published var currentMonth: Int = 0
...
/// 현재 캘린더에 보이는 month 구하는 함수
func getCurrentMonth(addingMonth: Int) -> Date {
// 현재 날짜의 캘린더
let calendar = Calendar.current
// 현재 날짜의 month에 addingMonth의 month를 더해서 새로운 month를 만들어
// 만약 오늘이 1월 27일이고 addingMonth에 2를 넣으면 3월 27일이됨
guard let currentMonth = calendar.date(
byAdding: .month,
value: addingMonth,
to: Date()
) else { return Date() }
return currentMonth
}
/// 해당 월의 모든 날짜들을 DateValue 배열로 만들어주는 함수, 모든 날짜를 배열로 만들어야 Grid에서 보여주기 가능
func extractDate(currentMonth: Int) -> [DateValue] {
let calendar = Calendar.current
// getCurrentMonth가 리턴한 month 구해서 currentMonth로
let currentMonth = getCurrentMonth(addingMonth: currentMonth)
// currentMonth가 리턴한 month의 모든 날짜 구하기
var days = currentMonth.getAllDates().compactMap { date -> DateValue in
// 여기서 date = 2023-12-31 15:00:00 +0000
let day = calendar.component(.day, from: date)
// 여기서 DateValue = DateValue(id: "6D2CCF74-1217-4370-B3AC-1C2E2D9566C9", day: 1, date: 2023-12-31 15:00:00 +0000)
return DateValue(day: day, date: date)
}
// days로 구한 month의 가장 첫날이 시작되는 요일구하기
// Int값으로 나옴. 일요일 1 ~ 토요일 7
let firstWeekday = calendar.component(.weekday, from: days.first?.date ?? Date())
// month의 가장 첫날이 시작되는 요일 이전을 채워주는 과정
// 만약 1월 1일이 수요일에 시작된다면 일~화요일까지 공백이니까 이 자리를 채워주어야 수요일부터 시작되는 캘린더 모양이 생성됨
// 그래서 만약 수요일(4)이 시작이라고 하면 일(1)~화(3) 까지 for-in문 돌려서 공백 추가
// 캘린더 뷰에서 월의 첫 주를 올바르게 표시하기 위한 코드
for _ in 0 ..< firstWeekday - 1 {
// 여기서 "day: -1"은 실제 날짜가 아니라 공백을 표시한 개념, "date: Date()"도 임시
days.insert(DateValue(day: -1, date: Date()), at: 0)
}
return days
}
...
}
📍
extension Date {
// 현재 월의 날짜를 Date 배열로 만들어주는 함수
func getAllDates() -> [Date] {
// 현재날짜 캘린더 가져오는거
let calendar = Calendar.current
// 현재 월의 첫 날(startDate) 구하기 -> 일자를 지정하지 않고 year와 month만 구하기 때문에 그 해, 그 달의 첫날을 이렇게 구할 수 있음
let startDate = calendar.date(from: Calendar.current.dateComponents([.year, .month], from: self))!
// 현재 월(해당 월)의 일자 범위(날짜 수 가져오는거)
let range = calendar.range(of: .day, in: .month, for: startDate)!
// range의 각각의 날짜(day)를 Date로 맵핑해서 배열로!!
return range.compactMap { day -> Date in
// to: (현재 날짜, 일자)에 day를 더해서 새로운 날짜를 만듦
calendar.date(byAdding: .day, value: day - 1, to: startDate) ?? Date()
}
}
}
하핫 저도 이해하기가 너무 어려워서,, 주석을 조금 많이 남겼답니다 ^,^ 하핫
사실 주석만 보셔도 어느정도 이해할 수 있지만 좀 더 자세히 써보겠숨다
일단, 참고한 영상에서는 DateValue라는 구조체를 만들어서 날짜 하나하나를 DateValue로 만들더라구용!
그래서 날짜 하나하나의 실제 Date와 day(날짜만 의미)를 DateValue로 담게 됩니다
📌 func getCurrentMonth(addingMonth: Int) -> Date
Calendar.current 를 통해 현재 날짜의 캘린더 구조체를 가져와서 현재 month 를 얻을 수 있습니당
여기서는 addingMonth를 파라미터로 갖게 되는데, 이는 현재 month에서 얼마나 add 되는 month를 구할것인지를 의미합니다.
예를 들어, 현재가 3월 11일이라고 가정한다면 Calendar.current를 통해 현재 날짜를 가져오고
date(byAdding component: Calendar.Component, value: Int, to date: Date) -> Date?
함수를 통해서 month에 addingMonth를 더해줍니다.
만약 addingMonth를 2로 준다면 3월에 2가 더해져서 5월을 얻게 되어 5월 11일을 리턴할거에옹
저희 앱에서는 drag gesture로 오른쪽으로 넘기면 이전 month로, 왼쪽으로 넘기면 다음 month로 넘어가게 구현했어용
(참고한 영상에서는 버튼으로 넘기더라구용!)
이 함수는 바로 밑의 extractDate 함수에서 사용됩니당.
📌 func extractDate(currentMonth: Int) -> [DateValue]
이 함수가 핵심이라고 생각하는데용! 이 함수로 month 의 실제 날짜들을 가져올 수 있슴다
여기는 좀,, 뭔가 많아서 나눠서 설명을 해볼게용!
여기서도 먼저 Calendar.current로 현재 날짜의 캘린더를 가져온 후에
파라미터로 받는 currentMonth를 이용해서 위에서 만든 getCurrentMonth 함수로 현재 캘린더에서 보여지는 month를 구합니다
let currentMonth = getCurrentMonth(addingMonth: currentMonth)
(생각해보니까 파라미터명이랑 함수 내부에서 사용되는 currentMonth 상수랑 이름이 같네요ㅠ 고쳐야겠어요)
그런 후에, month가 정해지면 그 month의 days들을 불러올거에용!
// currentMonth가 리턴한 month의 모든 날짜 구하기
var days = currentMonth.getAllDates().compactMap { date -> DateValue in
// 여기서 date = 2023-12-31 15:00:00 +0000
let day = calendar.component(.day, from: date)
// 여기서 DateValue = DateValue(id: "6D2CCF74-1217-4370-B3AC-1C2E2D9566C9", day: 1, date: 2023-12-31 15:00:00 +0000)
return DateValue(day: day, date: date)
}
여기서 사용되는데 가장 아래에 있는 Date extension으로 만들어준 getAllDates() 입니당
해당 날짜가 속한 month의 모든 날을 Date 형식의 배열로 만들어서 가져오는 것이죵
예를 들어, 3월이면 1일부터 31일까지의 날짜를 [Date]에 담아서 리턴합니다!
이걸 이용해서 우리가 구한 currentMonth의 날짜들을 다 가져올거에옹
currentMonth.getAllDates()를 compactMap으로 Date를 DateValue로 맵핑해줄거에여
그래서 calendar.component를 이용해서 date의 .day만 가져와서 day로 만들어
DateValue의 day로 받습니당
이게 뭔 ,,,, 소리쥬,,,? 하시쥬? 저만 이해하기 어렵나유?ㅎ
예를 들어, 3월의 모든 날을 가져온다고 하면 1일부터 31일까지의 날들을 compactMap으로 맵핑해줍니다
그래서 3월 1일을 가져와서 "1일"만 빼서 day에 넣고 DateValue로 맵핑을 하면
DateValue(id: "여기는UUID자리", day: 1, date: 2023-12-31 15:00:00 +0000)
요로케 바뀝니다. 그렇게 2일도,,3일도,,, 쭈욱 ,,, 31일까지 다 맵핑이 되어 DateValue의 배열로 생성되는 것이지용!
근데 왜 DateValue(day: 1, date: 2024-02-29 15:00:00 +0000) day랑 date가 다를까용?
day는 절대적 날짜인 3월 1일에서 "1"만 가져온 것이지만, date는 UTC를 기준으로 하기 때문에 다릅니당~~!
그래서 locale과 TimeZone 설정만 잘해주면 되겠죠잉
그렇다면 이제 날짜를 구했으니!! 각 날짜마다의 요일을 정해줍시당
let firstWeekday = calendar.component(.weekday, from: days.first?.date ?? Date())
for _ in 0 ..< firstWeekday - 1 {
days.insert(DateValue(day: -1, date: Date()), at: 0)
}
여기서도 역시 calendar.component를 통해 위에서 구한 days의 첫번째의 date에서 요일만 쏙 뽑아옵니당
days의 first니까 1일을 뜻하겠죠잉
그니까 결국 각 month들의 1일의 요일을 가져오는것입니당
여기서 firstWeekday는 Int 타입을 가질거에요
왜냐면 component의 .weekday는 일요일부터 시작해서 토요일까지 1~7 숫자로 표현되기 때문이에용
(일요일 = 1, 월요일 = 2, 화요일 = 3, ... , 토요일 = 7)
근데 .. 밑에 for-in 구문은 무엇이쥬? 이것은 바로 month의 가장 첫날의 요일 이전을 채워주는 과정입니다
이게 무슨 소리냐하면!

이렇게 2월을 예시로 들자면, 2월 1일이 목요일부터 시작하니까 캘린더에서 앞에 일요일부터 수요일까지가 비어있어요
비어있는 이 부분을 채워줘야 우리가 원하는 목요일에 1일이 시작되는 캘린더 모양을 가질 수가 있어용
비어있는 부분은 0으로 나타나게 되고 1일이 시작되어야 1, 2, 3 .. 이렇게 날짜로 표시됩니당
그래서 for-in 문으로 요일들을 반복해서 해당 요일들에 비어있는 0 부분이 있다면 -1 로 채우겠다~는 뜻이에용
(여기서 -1은 아무의미 없고 그냥 공백을 표시한 개념이라고 생각해주시면 되공 date: Date()도 큰 의미 없어용)
그렇게 해서 days를 return 해주면!
드 ⭐️ 디 ⭐️ 어
캘린더 모양에 쇽쇽 넣어줄 준비가 완료됩니당
그렇다면 캘린더에 넣어줍시당
struct DatesGridView: View {
@ObservedObject var calendarViewModel: CalendarViewModel
private let columns = Array(repeating: GridItem(.flexible()), count: 7)
var body: some View {
// 달력 그리드
LazyVGrid(columns: columns, spacing: 10) {
ForEach(calendarViewModel.extractDate(currentMonth: calendarViewModel.currentMonth)) { value in
if value.day != -1 {
DateButton(value: value,
calendarViewModel: calendarViewModel,
selectDate: $calendarViewModel.selectDate)
.onTapGesture {
calendarViewModel.checkingDate = value.date
calendarViewModel.popupDate = true
calendarViewModel.checkingDateFuture()
}
} else {
// 날짜 공백때문에 -1이 있을경우 숨긴다
Text("\(value.day)").hidden()
}
}
}
}
}
어떻게 넣어주었냐아!!!!!하면
바로 LazyVGrid를 사용해서 넣어주었습니당
columns 상수를 만들어서 GridItem을 7개씩 넣어주겠다고 선언해서 사용했습니당
그래서 위에서 우리가 열심히 만든 extractDate를 ForEach로 돌려서 value 값을 사용하는 것이죵
이 value는 DateValue니까 DateValue 구조체의 day와 date를 야무지게 사용할 수 있습니당.
if value.day != -1 을 보면 이것이 바러바러 빈공간 채워주기 했던!! 그것이에용
만약 -1이라면 (== 빈공간이라면, 1일 이전이라면!!) hidden 처리해서 아예 안보이게 했어용
저는 날짜 하나하나를 button으로 만들어서 사용해서 DateButton으로 해놓았어용
요기는 이제 커스텀하기 나름이겠죠잉!
그래서 LazyVGrid로 아이템을 7개씩 넣어주어서 요일에 맞게 날짜를 넣어줄수가 있어용!
이렇게 하면 핵심적인 캘린더 만들기는 오나료입니당‼️‼️
사실 한 번 정리해보는게 목적이었는데 ,, 혹시라도 누가 참고하실 수도 있다는 생각에
아주 구구절절쓰 설명이 길어졌네유,,~
혹시라도 참고하시는 분이 계시다면 ,,, 이해 안가는 부분이 있으시다면 ,,!!
댓글 남겨주시면 아는 선에서 저도 답글 남기겠습니다😚
아래 레퍼런스를 남기겠지만 좋은 영상과 블로그를 찾아서 많이 도움받고 참고했습니당
원글과 영상 원본을 참고하시면 더 명확히 도움 받으실거에옹ㅎㅎ
자세한 코드는 저희 모공모공 깃허브 놀러와서 봐주시면 됩니댜 >,<!
(그리고 이참에 우리 💖SYM💖 앱 한 번 다운로드 받으셔서 사용해주시는 것도 ,,ㅎㅎ🫶)
📖 reference(늘 감사합니당) ♥️
https://green1229.tistory.com/362
SwiftUI로 캘린더 직접 구현하기
안녕하세요. 그린입니다🍏 이번 포스팅에서는 오랜만에 SwiftUI로 뚝닥뚝닥 해보는 시간입니다🙋🏻 뭘 뚝닥뚝닥 해볼지 고민하다가 그냥 밑도 끝도 없이 캘린더를 간단하게 직접 만들어보고
green1229.tistory.com
https://youtu.be/UZI2dvLoPr8?si=Bjw7mq8FK7HnExh4
'SwiftUI' 카테고리의 다른 글
[SwiftUI] @FocusState 와의 초면 (0) | 2023.07.10 |
---|---|
[SwiftUI] .onChange 와의 초면 (1) | 2023.07.05 |
[SwiftUI] Log in 화면을 만들어 보쟈📱 (0) | 2023.06.23 |