iOS
๐๏ธ์ฌ๊ธฐ์ด๋ Dateยท์ธ์ ์ ํ UI ๋ง๋ค๊ธฐ-SwiftUI Calendar Core & DateTravelerSelector ๊ตฌ์กฐ ์ฌ์ค๊ณ
๋ ์ด๋Raynor(์ต์ฑ์ฑ) / iOS ๊ฐ๋ฐํ์ฌ๊ธฐ์ด๋
2025๋ 12์ 17์ผ
์๋ฌธ์์ ๋ณด๊ธฐ โ์๋ ํ์ธ์. ์ฌ๊ธฐ์ด๋์ปดํผ๋ iOS ๊ฐ๋ฐํ ๋ ์ด๋์ ๋๋ค.
์ด๋ฒ ๊ธ์์๋ ์ค์ ์๋น์ค์์ ๊ฐ์ฅ ์์ฃผ ์ฌ์ฉ๋๋ UI ์ค ํ๋์ธ
โ๋ ์ง + ์ธ์ ์ ํ(DateTravelerSelector)โ ํ๋ฉด์ ์ด๋ป๊ฒ SwiftUI ๊ธฐ๋ฐ์ผ๋ก ์ฌ์ค๊ณํ๋์ง ๊ณต์ ํด๋ณด๋ ค ํฉ๋๋ค.
์ฌ๊ธฐ์ด๋ ์ฑ์๋ ํจํค์งยท๊ตญ๋ด์์ยทํด์ธ์์ยท์ฐ๋ชฉ๋ก ๋ฑ ๋ค์ํ ๋๋ฉ์ธ์ด ์กด์ฌํฉ๋๋ค.
๊ฒ์ผ๋ก ๋ณด๊ธฐ์ ๋น์ทํ UI์ง๋ง, ์ ์ฑ ์ด ์ ๋ถ ๋ค๋ฅด๊ธฐ ๋๋ฌธ์ ์์ ํ ๊ณตํตํํ๊ธฐ ์ด๋ ค์ด ์์ญ์ด๊ธฐ๋ ํฉ๋๋ค.
์๋ฅผ ๋ค์ด:
- ํจํค์ง๋ ์ถ๋ฐ์ผ๋ง ์ ํ
- ๊ตญ๋ด์์๋ ์ฒดํฌ์ธ/์ฒดํฌ์์ + ์ธ์
- ํด์ธ์์๋ ๊ตญ๋ด์ ๋น์ทํ์ง๋ง ์ด๋ฅธยท์์ดยท๊ฐ์ค ์๋ฅผ ๊ฐ๊ฐ ์กฐ์ ํด์ผ ํ๋ ์ธ์ ์ ์ฑ ์ด ์ถ๊ฐ
- ์ฐ ํ๋ฉด์ ๋ ๋ณ๋์ ์ ์ฑ ์ผ๋ก ์๋
์ด์ฒ๋ผ ํ๋ฉด ํํ๋ ๋น์ทํด ๋ณด์ฌ๋ ๋ก์ง์ ๋ชจ๋ ๋ค๋ฅด๊ธฐ ๋๋ฌธ์, ๋จ์ํ ํ๋์ ํตํฉ ๋ฌ๋ ฅ์ผ๋ก ๋ง๋ค๊ธฐ์๋ ๋ฌด๋ฆฌ๊ฐ ์์์ต๋๋ค.
๐ฏ ํด๊ฒฐํด์ผ ํ๋ ๋ฌธ์ ๋ค
SwiftUI๋ก Dateยท์ธ์ ์ ํ UI๋ฅผ ์ฌ์ค๊ณํ๋ฉด์, ๊ฒ์ผ๋ก ๋ณด๊ธฐ์๋ ๋จ์ํ ํ๋ฉด ๊ฐ์ง๋ง ์ค์ ๋ก๋ ๋๋ฉ์ธ๋ณ๋ก ์ ์ฑ ๋ ๋ค๋ฅด๊ณ ๊ธฐ์ ์ ์ผ๋ก๋ ํด๊ฒฐํด์ผ ํ ๋๊ด์ด ๋ง์์ต๋๋ค. ํนํ ์๋์ ๊ฐ์ ๋ถ๋ถ๋ค์ ์ค์ ๊ฐ๋ฐ ๊ณผ์ ์์ ๊น์ ๊ณ ๋ฏผ์ด ํ์ํ์ต๋๋ค.

1. ํ๋๋ก ํต์ผํ๋ ค ํ์ง๋งโฆ ๋๋ฉ์ธ๋ณ ์ ์ฑ ์ฐจ์ด๊ฐ ๋๋ฌด ์ปธ๋ ๋ฌธ์
์ฒ์์๋ ๋ชจ๋ ๋๋ฉ์ธ์ ํ๋์ โํตํฉ Dateยท์ธ์ ์ ํ ํ๋ฉดโ์ผ๋ก ๋ฌถ์ด๋ณด๋ ค๊ณ ํ์ต๋๋ค.
ํ์ง๋ง ์ค์ ๋ก ํจํค์งยท๊ตญ๋ด์์ยทํด์ธ์์ยท์ฐ ํ๋ฉด์ UI์ ์ ์ฑ ์ด ๋ชจ๋ ๋ฌ๋์ต๋๋ค.
- ํจํค์ง: ์ถ๋ฐ์ผ ๋จ์ผ ์ ํ(๋ฉํฐ ์ ํ ํ์ฉ), ์ธ์ ์ ํ UI๋ ๋ณ๊ฐ๋ก ์กด์ฌ
- ๊ตญ๋ด์์: ์ฒดํฌ์ธ/์ฒดํฌ์์ + ์ด ์ธ์(์ด๋ฅธ/์์ด ํฉ์ฐ)
- ํด์ธ์์: ์ฒดํฌ์ธ/์ฒดํฌ์์ + ์ฑ์ธ/์๋ ๋ถ๋ฆฌ + ๊ฐ์ค ์ ์ ํ
- ์ฐ: ํด์ธ์ ์ ์ฌํ์ง๋ง ๊ฐ์ค ์ ํ ์์
- ๋ฌ๋ ฅ ๊ธฐ๊ฐ ๋ฒ์๋ ๋ค๋ฆ: ๊ตญ๋ด 180์ผ, ํด์ธยท์ฐ 365์ผ
์ด ๋ชจ๋ ์ ์ฑ ์ ํ๋์ ๋ฌ๋ ฅ์์ ๋ถ๊ธฐ๋ก ์ฒ๋ฆฌํ๋ฉด
๋ก์ง์ด ๋๋ฌด ๋ณต์กํด์ง๊ณ ์ ์ง๋ณด์ ๋์ด๋๊ฐ ํฌ๊ฒ ์ฆ๊ฐํฉ๋๋ค.
๐ ๊ฒฐ๋ก : ์ ์ฒด ํ๋ฉด(DateTravelerSelector)์ ๋๋ฉ์ธ๋ณ ์ ์ฑ ์ฐจ์ด ๋๋ฌธ์ ๋ถ๋ฆฌํ๋ ๋ฌ๋ ฅ ์ ยท์คํ์ผ ๋ฑ UI ์ปดํฌ๋ํธ๋ YDS ๋์์ธ ์์คํ ์ ๊ธฐ๋ฐ์ผ๋ก ๊ณตํตํํ ๊ณ , ๊ทธ ์์์ ๋์ํ๋ ๋ ์ง ๊ณ์ฐ Core(์ ์์ฑยท์ ์์ฑยทRange ๊ณ์ฐ ๋ฑ ๋ก์ง)๋ง ๋ณ๋๋ก ์ถ์ํํด ๊ฐ ๋๋ฉ์ธ์์ ์ฌ์ฌ์ฉํ๋ ๊ตฌ์กฐ๋ก ์ ๋ฆฌํ์ต๋๋ค.
2. Range ๋ชจ๋ ๋ฐ consecutiveDays ์ ์ฑ ์ ๋ณต์ก์ฑ
๊ตญ๋ดยทํด์ธยท์ฐ ํ๋ฉด์ ๋ชจ๋ โRange(์ฒดํฌ์ธ/์ฒดํฌ์์)โ์ ์ฌ์ฉํ์ง๋ง, ์ธ๋ถ ์ ์ฑ ์ ๋ค๋ฅด๊ฒ ์๋ํฉ๋๋ค.
- ์ฒดํฌ์ธ ๊ธฐ์ค ยฑ30์ผ(๋๋ ์๋ฒ ์ ๊ณต ๊ฐ) ๋ด์์๋ง ์ฒดํฌ์์ ๊ฐ๋ฅ
- ์ค๊ฐ์ ์์ฝ ๋ถ๊ฐ์ผ์ด ํฌํจ๋๋ฉด ์ ํ ๋ถ๊ฐ
- ๋ ์ง ๋ฒ์๊ฐ ๋๋ฉ์ธ๋ณ๋ก ๋ค๋ฆ (180์ผ vs 365์ผ)
- ๊ฐ ๋๋ฉ์ธ๋ณ ์ธ์ ์ ์ฑ ์ด ๋ฌ๋ ฅ ํ์ฑ ์กฐ๊ฑด์ ์ํฅ์ ์ค
์ฆ, ๋จ์ํ range ๊ณ์ฐ์ด ์๋๋ผ ๋๋ฉ์ธ๋ณ ์ ์ฑ ์ด ํจ๊ป ์๋ํ๋ ๊ตฌ์กฐ ์ฌ์
Core์ Selector๊ฐ ๋ฐ๋์ ๋ถ๋ฆฌ๋์ด์ผ ํ์ต๋๋ค.
๐ ๊ฒฐ๋ก : DateViewData ์์ฑ ๋ก์ง์ Calendar Core๋ก ๋ถ๋ฆฌํ๊ณ ,
๋๋ฉ์ธ ์ ์ฑ ์ DateTravelerSelector์์๋ง ์ ์ดํ๋ ์ํคํ ์ฒ๊ฐ ํ์ํ์ต๋๋ค.
3. LazyVGrid์ iOS 16 ๋ฒ๊ทธ ๋ฐ ์ฑ๋ฅ ์ด์๋ก ์ธํ Grid ๊ตฌ์กฐ ์ฌ์ค๊ณ
์ด๊ธฐ ๋ฌ๋ ฅ ์๋์์๋ LazyVGrid๋ฅผ ์ฌ์ฉํ์ง๋ง,
- iOS16์์ offset ๊ณ์ฐ ์ค๋ฅ
- ์คํฌ๋กค ์ค ์ ํฌ์ง์ ์ด ํ๋ ํ์
- ์คํฌ๋กค ๋ฒ๋ฒ ์
- ์ฑ๋ฅ ์ ํ ๋ฐ layout pass ๋ฐ๋ณต
- iOS17/18์์๋ ์ ์์ธ๋ฐ 16์์๋ง ๋ถ์์
๊ฐ์ ๋ฌธ์ ๊ฐ ๋ฐ์ํ์ต๋๋ค.
๋ํ ๋ฌ๋ ฅ์ 180~365์ผ ๋ถ๋์ ํ ๋ฒ์ ๊ทธ๋ฆฌ๋ ๊ตฌ์กฐ๋ผ
LazyVGrid + GeometryReader ์กฐํฉ์ ์ฑ๋ฅ์ด ๋ ์ ํ๋์์ต๋๋ค.
๐ ์ต์ข ์ ํ์ ์๋ ์ธ ๊ฐ์ง ์ ๋ต์ด์์ต๋๋ค.
โ ์ง์ Grid ๊ตฌ์ฑ
- LazyVGrid๋ฅผ ๋ฒ๋ฆฌ๊ณ , ์ฐ๋ฆฌ๋ง์ 7์ด Grid ๊ตฌ์กฐ๋ก ์ฌ๊ตฌ์ฑ (iOS16 ์์ ํ + ๋ ๋๋ง ์ผ๊ด์ฑ ํ๋ณด)
โ GeometryReader ์ต์ํ
- GeometryReader๋ฅผ ๋จ๋ฐํ๋ฉด ์ฑ๋ฅ์ด ๊ธ๊ฒฉํ ์ ํ๋๋ฏ๋ก ํ์ํ ์์น์์๋ง ์ ํ์ ์ผ๋ก ์ฌ์ฉ
โ ์ด๊ธฐ ๋ ๋๋ง ์ต์ ํ โ Precompute & Cache Strategy
- ์ด๊ธฐ ์ง์ ์ ์ ์ฒด Month ร Date ์ ๋ณด(DateViewData)๋ฅผ ํ ๋ฒ์ ๊ณ์ฐํด ์บ์ฑ โ ๋ทฐ ๋ ๋๋ง ์ diffing ์ต์ํ
์ด ๊ตฌ์กฐ๋ก ๋ณ๊ฒฝํ ๋ค iOS 16์์๋ ์คํฌ๋กค ๋ฒ๋ฒ ์์ด ํฌ๊ฒ ๊ฐ์ํ์ต๋๋ค.
โญ Calendar Core ๊ตฌ์กฐ๋ก ํด๊ฒฐํ ๋ฐฉ์
์์ ๋งํ ๋ฌธ์ ๋ค์ ํด๊ฒฐํ๊ธฐ ์ํด ์ฐ๋ฆฌ๋ ์บ๋ฆฐ๋ ๊ธฐ๋ฅ์ ๋ค์ ๋ ์ถ์ผ๋ก ๋๋์ด ์ฌ์ค๊ณํ์ต๋๋ค.
- Calendar Core (๊ณตํต ๋ก์ง + ๋ ์ง ๊ณ์ฐ + ๋ ๋๋ง ๋ฐ์ดํฐ ์์ฑ)
- ScrollableCalendarView (UI ๋ ์ด์ด, SwiftUI ๊ณตํต ์บ๋ฆฐ๋ ์ปดํฌ๋ํธ)
์ด ๋ ๊ตฌ์กฐ๋ฅผ ํตํด ํจํค์งยท๊ตญ๋ด์์ยทํด์ธ์์ยท์ฐ ํ๋ฉด์ด ๋ชจ๋ ๊ฐ์ Calendar Core๋ฅผ ๊ณต์ ํ๋ฉด์๋, ๊ฐ ๋๋ฉ์ธ์ ์ ์ฑ ์ ๋ ๋ฆฝ์ ์ผ๋ก ์ ์งํ ์ ์๊ฒ ๋์์ต๋๋ค.
1. Calendar Core: ์ ๋จ์๋ก โ๋ฏธ๋ฆฌ ๊ณ์ฐ๋ ๋ ๋๋ง ๋ฐ์ดํฐโ๋ฅผ ๋ง๋๋ ๊ตฌ์กฐ
Calendar Core(DateTravelerSelectorUtils)๋ ๋ค์ ์ญํ ๋ค์ ๋ด๋นํฉ๋๋ค.
โ Month ๋ฆฌ์คํธ ๊ณ์ฐ (generateMonths)
Calendar Core์์๋ ๋จผ์ ์์์ผ ~ ์ข ๋ฃ์ผ ์ฌ์ด์ ํฌํจ๋ ๋ชจ๋ (YYYYMM) ์ ๋ง๋ค์ด ๋ก๋๋ค.
์ด Month ๋ฆฌ์คํธ๊ฐ ๋์ค์ Dictionary<YearMonth, [DateViewData]> ํํ์ ์บ์ ํค๊ฐ ๋ฉ๋๋ค.
์๋ฅผ ๋ค์ด:
startDate = 2025-01-10endDate = 2025-03-05
๋ผ๋ฉด, Core์์๋ ์๋์ ๊ฐ์ Month ๋ฆฌ์คํธ๋ฅผ ๋ง๋ญ๋๋ค.
[202501, 202502, 202503]
์ค์ ์ฝ๋๋ ๋ค์๊ณผ ๊ฐ์ด ๊ตฌ์ฑ๋์ด ์์ต๋๋ค.
typealias YearMonth = Int //์: 202501
static func generateMonths(from startDate: Date, to endDate: Date) -> [YearMonth] {
var months: [YearMonth] = []
var current = startDate
let calendar = Calendar(identifier: .gregorian)
// endDate๊ฐ ์ํ ๋ฌ๊น์ง ํฌํจ๋๋๋ก ๋ง์ง๋ง ๋ฌ์ ๋ง์ผ๋ก ๋ณด์
let endOfMonth = calendar.date(
byAdding: DateComponents(month: 1, day: -1),
to: endDate
)!
while current <= endOfMonth {
let year = calendar.component(.year, from: current)
let month = calendar.component(.month, from: current)
let yearMonth = year * 100 + month // 2025๋
1์ -> 202501
if !months.contains(yearMonth) {
months.append(yearMonth)
}
// ๋ค์ ๋ฌ๋ก ์ด๋
current = calendar.date(byAdding: .month, value: 1, to: current)!
}
return months
}
์ด๋ ๊ฒ ํด๋๋ฉด:
- ๊ตญ๋ด ์์์ฒ๋ผ 180์ผ์ง๋ฆฌ ๋ฌ๋ ฅ
- ํด์ธ/์ฐ์ฒ๋ผ 365์ผ์ง๋ฆฌ ๋ฌ๋ ฅ
์ฒ๋ผ ๋๋ฉ์ธ๋ณ๋ก ๊ธฐ๊ฐ์ด ๋ฌ๋ผ๋ , ์ generateMonths๋ง ํธ์ถํ๋ฉด ํญ์ ๊ณตํต๋ Month ๋ฆฌ์คํธ ๋ฅผ ์ป์ ์ ์์ด์ ๋ค์์ ์ค๋ช
ํ initDateViewData / updateMonthDateViewData ๋ก์ง์ด ๋ชจ๋ ๊ฐ์ ํ๋ฆ์ ํ๊ฒ ๋ฉ๋๋ค.
โก ๊ฐ ์์ Grid ์์ฑ (makeDateGridArray / makeCalendarGridItems)
Calendar Core์์๋ ํ ๋ฌ(month)์ ๊ฐ๋ก 7์ด(์ผ~ํ ), ์ต๋ 6ํ(์ด 42์นธ) ์ ๊ณ ์ ๋ Grid๋ก ์ ๊ทํํด์ ๋ค๋ฃน๋๋ค.
SwiftUI์์ ์ด๋ค ๋ ์ด์์์ ์ฌ์ฉํ๋๋ผ๋, ํญ์ ์ด 42์นธ์ง๋ฆฌ ๋ฐฐ์ด๋ง ๋ฐ์์ ๊ทธ๋ฆฌ๋ฉด ๋๋๋ก ๋ง๋ ๊ตฌ์กฐ์ ๋๋ค.
์๋ฅผ ๋ค์ด, ์๋์ ๊ฐ์ด ์ ์์ ์์ผ์ ๋ฐ๋ผ ์๋ถ๋ถ์ nil๋ก ์ฑ์ฐ๊ณ ,
์ค์ ๋ ์ง๋ 1 ~ 31๊น์ง ์ซ์๋ก ์ฑ์๋๋ค.
[ nil, nil, 1, 2, 3, 4, 5,
6, 7, 8, 9, 10, 11, 12,
...]
์ด ๋ฐฐ์ด์ ๋ง๋๋ ์ฝ๋์ ํต์ฌ์ ๋ค์๊ณผ ๊ฐ์ต๋๋ค.
static func makeDateGridArray(for startOfMonth: Date) -> [Int?] {
let calendar = Calendar(identifier: .gregorian)
// ํด๋น ๋ฌ์ 1์ผ์ด ๋ฌด์จ ์์ผ์ธ์ง (1 = ์ผ์์ผ)
let startWeekday = calendar.component(.weekday, from: startOfMonth)
// ์ด ๋ฌ์ด ๋ฉฐ์น ๊น์ง ์๋์ง (์: 1...31)
let range = calendar.range(of: .day, in: .month, for: startOfMonth)!
// ์ ์์ ์์ผ ์์ชฝ์ nil ๋ก ์ฑ์
let leadingEmptyCount = (startWeekday - 1) % 7
var array: [Int?] = Array(repeating: nil, count: leadingEmptyCount)
// ์ค์ ๋ ์ง ์ฑ์ฐ๊ธฐ
array.append(contentsOf: range.map { Optional($0) })
// ๋ง์ง๋ง ์ค๋ 7์ ๋ฐฐ์๊ฐ ๋๋๋ก ๋ค์ชฝ์ nil ๋ก ์ฑ์
let remaining = array.count % 7
if remaining > 0 {
array.append(contentsOf: Array(repeating: nil, count: 7 - remaining))
}
return array
}
์ด๋ ๊ฒ ๋ง๋ค์ด์ง [Int?] ๋ฐฐ์ด์ ๊ธฐ๋ฐ์ผ๋ก,
makeCalendarGridItems์์ ๊ฐ ์
์ ๋ค์๊ณผ ๊ฐ์ ํํ๋ก ๊ฐ์ธ์ ์ฌ์ฉํฉ๋๋ค.
struct CalendarGridItem {
let index: Int // 0 ~ 41
let day: Int? // nil ์ด๋ฉด ๋น ์นธ, ์๋๋ฉด ์ค์ ๋ ์ง
let dateViewData: DateViewData?
}
๊ฒฐ๊ณผ์ ์ผ๋ก MonthView ์ ์ฅ์์๋:
- โ์ด ๋ฌ์ ํญ์ 42๊ฐ์ ์ ์ ๊ฐ์ง๋คโ
- ๊ฐ ์ ์ โ๋ ์ง๊ฐ ์๊ฑฐ๋(nil๋ก ๋น์ด์๊ฑฐ๋)โ ๋ ์ค ํ๋๋ค
๋ผ๋ ์ ์ ๊ฐ ๊น๋ฆฌ๊ธฐ ๋๋ฌธ์,
๋๋ฉ์ธ์ ์๊ด์์ด ๊ฐ์ MonthView ์ปดํฌ๋ํธ๋ฅผ ์ฌ์ฌ์ฉํ ์ ์์์ต๋๋ค.
โข ๋ ์ง ์
(DateViewData)์ ๋ง๋๋ ํต์ฌ ๋ก์ง โ buildDateViewData
โ ์์ Month ๋ฆฌ์คํธ๋ฅผ ๋ง๋ค๊ณ , โก์์ ๊ฐ ์์ 7ร6 ๊ทธ๋ฆฌ๋๋ก ์ ๋ฆฌํ๋ค๋ฉด,
๋ค์ ๋จ๊ณ๋ โ๊ฐ ์นธ์ด ์ด๋ค ์ํ์ ๋ ์ง ์ ์ธ์ง โ๋ฅผ ๊ฒฐ์ ํ๋ ์ผ์ ๋๋ค.
์ด ์ญํ ์ ํ๋ ํจ์๊ฐ buildDateViewData์
๋๋ค.
์ด ๋จ๊ณ์์ ์ค์ ๋ก ๋ฌ๋ ฅ UI์ ๊ฑฐ์ ๋ชจ๋ ๋น์ฆ๋์ค ๋ก์ง์ด ๊ฒฐ์ ๋ฉ๋๋ค.
- ์ ํ๋ ๋ ์ง์ธ์ง (
isSelected) - ๊ณตํด์ผ/์ผ์์ผ์ธ์ง (
isHoliday) - ๊ฐ๊ฒฉ/์ฌ๊ณ ์ ๋ณด๊ฐ ์๋์ง ์ฌ๋ถ
- ์ ํ ๋ถ๊ฐ ์ฌ๋ถ (
isDisabled) - Range ๋ชจ๋์์ ์ฒดํฌ์ธ/์ฒดํฌ์์/์ค๊ฐ ๊ตฌ๊ฐ์ธ์ง (
rangeType) - ์๋ธ ํ ์คํธ(๊ฐ๊ฒฉ ํ ์คํธ)๋ฅผ ์จ๊ฒจ์ผ ํ๋์ง ๋ฑ
์ค์ ์๋น์ค ์ฝ๋๋ ๋๋ฉ์ธ๋ณ ์ ์ฑ (๊ตญ๋ด/ํด์ธ/์ฐ, ๊ฐ๊ฒฉ/์ฌ๊ณ , ์ต๋ ์ฐ๋ฐ ๋ฑ)์ด ๋ ๋ง์ด ํฌํจ๋์ด ์์ง๋ง, ํต์ฌ ํ๋ฆ๋ง ์์ฝํ๋ฉด ์๋์ ๊ฐ์ ํํ์ ๋๋ค.
/// ๋ ์ง ์
(DateViewData)์ ๋ง๋๋ ํต์ฌ ํ๋ฆ ์์ฝ๋ณธ
private static func buildDateViewData(
date: Date?,
context: CalendarRenderContext,
previous: DateViewData?
) -> DateViewData {
// ๋น ์นธ(๋ ์ง ์์) ์ฒ๋ฆฌ
guard let date else { return handleEmptyDate(context, previous) }
// ๊ธฐ๋ณธ ์ํ ๊ณ์ฐ
let isSelected = context.isSelected(date)
let isHoliday = context.isHoliday(date)
let priceInfo = context.priceInfo(for: date)
// ๋๋ฉ์ธ ์ ์ฑ
์ ๋ฐ์ํ ๋นํ์ฑํ ์ฌ๋ถ
let isDisabled = context.isDisabled(date, priceInfo: priceInfo)
// Range ๋ชจ๋์์ ์ฒดํฌ์ธ/์ฒดํฌ์์ edge ๊ณ์ฐ
let rangeType = context.rangeType(for: date)
// diff ๊ธฐ๋ฐ ์
๋ฐ์ดํธ (๋ถํ์ํ ๋ ๋๋ง ๋ฐฉ์ง)
return previous?.updated(
isSelected: isSelected,
isDisabled: isDisabled,
rangeType: rangeType,
priceText: priceInfo?.priceText
) ?? DateViewData(
date: date,
isSelected: isSelected,
isHoliday: isHoliday,
isDisabled: isDisabled,
rangeType: rangeType,
subText: priceInfo?.priceText ?? ""
)
}
ํต์ฌ ์์ด๋์ด๋ ๋ชจ๋ ๋น์ฆ๋์ค ๋ก์ง์ CalendarRenderContext ๋ด๋ถ๋ก ์บก์ํ ํ๊ณ , buildDateViewData์์๋ โ์ด ๋ ์ง๋ฅผ ์ด๋ค ์ํ๋ก ํํํ ์งโ๋ง ๊ฒฐ์ ํ๋๋ก ๋ง๋ ๊ฒ์
๋๋ค.
์ด๋ ๊ฒ ํด๋๋ฉด SwiftUI View ์ชฝ์์๋ DateViewData ๋ฐฐ์ด๋ง ๋ฐ์์:
- ๋นํ์ฑํ ์คํ์ผ๋ก ๊ทธ๋ฆด์ง
- ์ค๋ ํ์๋ฅผ ํ ์ง
- Range์ ์์/์ค๊ฐ/๋ ์คํ์ผ์ ์ ํ์ง
๋ง ์ ๊ฒฝ ์ฐ๋ฉด ๋๊ธฐ ๋๋ฌธ์, ๋๋ฉ์ธ๋ณ ์ ์ฑ ๋ณ๊ฒฝ์ด ์๊ฒจ๋ Core ๋ก์ง์์๋ง ์์ ํ๊ณ ๊ฐ ๋๋ฉ์ธ์ UI ์ฝ๋๋ ๊ทธ๋๋ก ์ฌ์ฌ์ฉํ ์ ์๊ฒ ๋ฉ๋๋ค.
โฃ ๋ณํ๋ ์ํ๋ง ๊ฐฑ์ ํ๋ ๊ตฌ์กฐ โ updateMonthDateViewData
๋ฌ๋ ฅ ํ๋ฉด์ ๋ ์ง ์ ํยท๊ฐ๊ฒฉยท๊ณตํด์ผยท์ฌ๊ณ ๋ฑ์ด ๋ฐ๋ ๋๋ง๋ค ์ํ๊ฐ ๋ณํฉ๋๋ค.
ํ์ง๋ง ์ ์ ์ฒด๋ฅผ ๋ค์ ๊ณ์ฐํ๋ฉด ์ฑ๋ฅ์ด ๊ธ๊ฒฉํ ์ ํ๋๋ฏ๋ก, โ๋ณ๊ฒฝ๋ ์ ๋ง ๊ฐฑ์ ํ๊ณ , ๋๋จธ์ง๋ ์ฌ์ฌ์ฉํ๋ ๊ตฌ์กฐโ๋ก ์ค๊ณํ์ต๋๋ค.
ํต์ฌ ์์ด๋์ด๋ ๋ฑ ๋ ๊ฐ์ง์ ๋๋ค.
(a) ์ด์ ์ (DateViewData)์ ๊ทธ๋๋ก ๋๊ฒจ์ ๋น๊ต ํ ํ์ํ ๋ถ๋ถ๋ง ์ ๋ฐ์ดํธ
let newData = calendarGridItems.map { cell in
buildDateViewData(
date: date,
context: context,
previous: cell.dateViewData // โ ์ด์ ๊ฐ ์ ๋ฌ
)
}
buildDateViewData๋ ๋ด๋ถ์์:
- ๊ฐ์ด ๊ฐ์ผ๋ฉด โ previous ๋ฐํ(= ์ฌ์ฌ์ฉ)
- ๊ฐ์ด ๋ฌ๋ผ์ก์ผ๋ฉด โ ์ DateViewData ์์ฑ
์ด๋ ๊ฒ ๋์ํฉ๋๋ค.
(b) ๊ณ์ฐ ๋น์ฉ์ด ํฐ ์์๋ ํ ๋ฒ๋ง ๋ฌถ์ด์ Context๋ก ์ ๋ฌ
let context = CalendarRenderContext(
selectedDates: selectedDates ?? [],
holidaysSet: holidaysSet,
openDaysSet: openDaysSet,
priceDataMap: priceDataMap,
model: model
)
date๋ง๋ค:
- ๊ณตํด์ผ ์ฌ๋ถ
- disabled ์ฌ๋ถ
- ๊ฐ๊ฒฉ ์กด์ฌ ์ฌ๋ถ
- rangeType(start/mid/end)
- consecutiveDays ์ฐ๋ฐ ๊ฐ๋ฅ ๋ฒ์
๊ฐ์ ๊ณ์ฐ์ ๋งค๋ฒ ํ๋ฉด ์ฑ๋ฅ์ด ๊ธ๊ฒฉํ ๋จ์ด์ง๋๋ค.
๊ทธ๋์ ํ์ํ ๊ฐ์ ํ ๋ฒ์ ๋ฌถ์ด ๋ CalendarRenderContext ๋ฅผ ๋ง๋ค๊ณ , ๊ฐ ์ ์ ์ด ๊ฐ์ ๋น ๋ฅด๊ฒ ์ฐธ์กฐํ๋๋ก ์ค๊ณํ์ต๋๋ค.
SwiftUI๋ ๋์ผํ Identifiable ์ ์ ์ฌ๋ ๋๋งํ์ง ์๊ธฐ ๋๋ฌธ์, ์ด ๊ตฌ์กฐ๋ ๋ณ๊ฒฝ๋ ์ ๋ง ์ต์๋ก ๊ฐฑ์ ๋๋ ์ค์ ์๋น์ค ์ฑ๋ฅ ์ต์ ํ์ ํฐ ํจ๊ณผ๊ฐ ์์์ต๋๋ค.
2. ScrollableCalendarView: UI๋ฅผ ๋ด๋นํ๋ SwiftUI ๋ ์ด์ด
ScrollableCalendarView๋ ์ฌ๊ธฐ์ด๋ ์ฑ์ ๋ชจ๋ Dateยท์ธ์ ์ ํ ํ๋ฉด์์ ๊ณตํต์ผ๋ก ์ฌ์ฉํ๋ SwiftUI ์บ๋ฆฐ๋ UI ๋ ์ด์ด ์
๋๋ค.
์์์ ์ค๋ช
ํ Calendar Core๊ฐ ์๋ณ DateViewData๋ฅผ ๊ณ์ฐํด ๋๊ฒจ์ฃผ๋ฉด, ์ด ๋ทฐ๋ ๊ทธ ๋ฐ์ดํฐ๋ฅผ ๊ทธ๋๋ก ๊ทธ๋ฆฌ๊ธฐ๋ง ํฉ๋๋ค.
์ฆ, UI๋ ๋ ๋๋ง + ์ด๋ฒคํธ ์ ๋ฌ ,
์ ์ฑ ยท๊ณ์ฐยท๊ฒ์ฆ์ Core๊ฐ ๋ด๋นํ๋ ๊ตฌ์กฐ์ ๋๋ค.
โ ScrollableCalendarView
์ ์ฒด ๊ตฌํ์ ๊ธธ์ง๋ง, ํต์ฌ ๊ตฌ์กฐ๋ง ๋ณด๋ฉด ๊ต์ฅํ ๋จ์ํฉ๋๋ค.
struct ScrollableCalendarView: View {
@Binding var monthDateViewData: [YearMonth: [DateViewData]]
@Binding var selectedDates: [Date]
let model: DateSelectorModel
let openDaysSet: Set<Date>
var body: some View {
ScrollViewReader { proxy in
ScrollView {
ForEach(monthDateViewData.keys.sorted(), id: \.self) { month in
DTSMonthView(
month: month,
dateViewData: monthDateViewData[month] ?? [],
onDateSelected: { date in
handleDateSelection(date)
}
)
.id(month)
.equatable()
}
}
}
}
private func handleDateSelection(_ date: Date) {
DateTravelerSelectorUtils.handleDateSelection(
date: date,
selectedDates: &selectedDates,
dateSelectorModel: model,
selectionMode: model.selectionMode,
openDaysSet: openDaysSet
)
}
}
UI๋ ์ ์ํ๋ฅผ ์ ํ ๊ณ์ฐํ์ง ์์ต๋๋ค.
Core์์ ๋ง๋ค์ด์ค DateViewData๋ง ๊ทธ๋๋ก ๋ ๋๋งํฉ๋๋ค.
โก Core๊ฐ ๊ณ์ฐํ DateViewData๋ฅผ ๊ทธ๋๋ก ๊ทธ๋ฆฐ๋ค
DTSMonthView(
month: month,
dateViewData: monthDateViewData[month] ?? [],
onDateSelected: { date in handleDateSelection(date) }
)
SwiftUI๋ Equatable ์ ์ฉ ๋๋ถ์ โ๋ณ๊ฒฝ๋ ์
๋ง ์ต์๋ก ๋ค์ ๊ทธ๋ฆฌ๊ธฐโ๊ฐ ๊ฐ๋ฅํด ์ฑ๋ฅ์ด ๋งค์ฐ ์์ ์ ์ผ๋ก ์ ์ง๋ฉ๋๋ค.
โข ์ค์ ๋๋ฉ์ธ ํ๋ฉด์์๋ ์ด๋ ๊ฒ ์ฌ์ฌ์ฉ๋๋ค
Calendar Core๋ ๋๋ฉ์ธ๋ณ๋ก ๋ฌ๋ ฅ ์ ์ฑ ์ด ๋ฌ๋ผ๋, DateSelectorModel๋ง ๋ค๋ฅด๊ฒ ์ฃผ์ ํ๋ฉด ๊ทธ๋๋ก ๋์ผํ ScrollableCalendarView๋ฅผ ์ฌ์ฌ์ฉํ ์ ์์ต๋๋ค.
- ๊ตญ๋ด ์์ - ์ฒดํฌ์ธ/์ฒดํฌ์์ Range ๋ฌ๋ ฅ
let model = DateSelectorModel(
startDate: today,
endDate: today.adding(days: 180),
consecutiveDays: 30,
selectionMode: .range
)
ScrollableCalendarView(
dateSelectorModel: model,
openDaysSet: openDates,
monthDateViewData: $monthData,
selectedDates: $selectedDates
)
- ํจํค์ง- ์ถ๋ฐ์ผ ๋จ์ผ/๋ฉํฐ ์ ํ
let model = DateSelectorModel(
startDate: today,
endDate: today.adding(days: 365),
maxSelectableDays: 31,
selectionMode: .multi
)
ScrollableCalendarView(
dateSelectorModel: model,
openDaysSet: openDates,
monthDateViewData: $monthData,
selectedDates: $selectedDates
)
Calendar Core๋ฅผ ์ค์ฌ์ผ๋ก UI ยท ์ ์ฑ ์ ์์ ํ ๋ถ๋ฆฌํ๋ฉด์ ์ฌ๊ธฐ์ด๋์ ํจํค์งยท๊ตญ๋ด์์ยทํด์ธ์์ยท์ฐ ๋ฑ ๋ค์ํ ํ๋ฉด์ ํ๋์ SwiftUI ์บ๋ฆฐ๋ ์ปดํฌ๋ํธ๋ก ์ ์ฐํ๊ฒ ๊ตฌ์ฑํ ์ ์๊ฒ ๋์์ต๋๋ค.
[ DateTravelerSelector (๋๋ฉ์ธ๋ณ) ]
โ ๋ชจ๋ธ ์ฃผ์
[ Calendar Core ] โ ๋ ์ง ์ํ ๊ณ์ฐ
โ DateViewData
[ ScrollableCalendarView ] โ ๊ทธ๋ฆฌ๊ธฐ + ์ด๋ฒคํธ ์ ๋ฌ
UI๋ ์๊ณ ๋จ์ํ๊ฒ ์ ์ง๋๊ณ , ์ ์ฑ ๋ณ๊ฒฝ์ Core์๋ง ์๋๋ฉด ๋๊ธฐ ๋๋ฌธ์
ํ์ฅ์ฑยท์ฌ์ฌ์ฉ์ฑยท์ ์ง๋ณด์์ฑ ๋ชจ๋ ํฌ๊ฒ ๊ฐ์ ๋ ๊ตฌ์กฐ์ ๋๋ค.
๐ฅ ์ธ์ ์ ํ(Traveler Selector) ๊ตฌ์กฐ
Calendar Core์ ๋ง์ฐฌ๊ฐ์ง๋ก, ์ธ์ ์ ํ๋ ๋๋ฉ์ธ๋ณ ์ ์ฑ ์ฐจ์ด๊ฐ ์ปธ๊ธฐ ๋๋ฌธ์ ์์ ๊ณตํตํ๊ฐ ์ฝ์ง ์์ ์์ญ์ด์์ต๋๋ค.
๊ทธ๋์ ๋ ์ง ์ ํ๊ณผ ๋์ผํ ์ฒ ํ์ผ๋ก, UI๋ ์๊ฒยท์ ์ฑ ์ ๋ฐ์์ ์ฃผ์ ํ๋ ๊ตฌ์กฐ๋ก ์ฌ์ค๊ณํ์ต๋๋ค.

1. TravelerView๋ โ๊ณตํต UI ์ปดํฌ๋ํธโ, ์ ์ฑ ์ ์ธ๋ถ์์ ์ฃผ์ ํ๋ ๋ฐฉ์
TravelerView๋ ์๋ ์์๋ค์ด ๋ชจ๋ ์ธ๋ถ์์ ์ ๋ฌ๋๋ ๊ตฌ์กฐ๋ก ๋์ด ์์ต๋๋ค
- ์ด๋ค ๋๋ฉ์ธ์ธ์ง(layoutType: package/domestic/overseas/favorite)
- Traveler ๋ชฉ๋ก(์ฑ์ธ/์๋/๊ฐ์ค ๋ฑ)
- ์ต์/์ต๋ ์ธ์ ๊ท์น
- ๋ฉ์์ง(์๋ด๋ฌธ๊ตฌ)
- ๊ฐ์ค ์ ํ์ ์ฌ๋ถ
- ์๋ ๋์ด ์ ํ ํ์ ์ฌ๋ถ
์ฆ, ์ธ์ ์ ์ฑ ์ ๋ทฐ ๋ด๋ถ์ ๋ฐํ์์ง ์๊ณ , ๋ชจ๋ TravelerViewData๋ก ์ฃผ์ ๋ฉ๋๋ค.
struct TravelerViewData {
let layoutType: TravelerView.TravelerLayoutType
let travelers: [Traveler]
let minTravelerCounts: [TravelerCategory: Int]
let maxTravelerCounts: [TravelerCategory: Int]
let minRoomCount: Int?
let maxRoomCount: Int?
let messages: [TravelerMessage]
}
ํด์ธ ์์ ์์๋ฅผ ํ๋๋ง ๋ณด๋ฉด ํ๋ฆ์ด ๋ ์ ๋ณด์ ๋๋ค.
// ์: ํด์ธ ์์ ์ธ์ ์ ํ์ฉ ViewData ๊ตฌ์ฑ ์์ (์ถ์ฝ ๋ฒ์ )
let viewData = TravelerViewData(
layoutType: .overseas,
travelers: [
.init(category: .adult, subTitle: "์ด ์ฑ์ธ ์"),
.init(category: .child, subTitle: "์ด ์๋ ์(๋ง 0์ธ~17์ธ)")
],
messages: [
.init(text: "์ธ์ ๊ด๋ จ ์๋ด ๋ฉ์์ง 1 ..."),
.init(text: "์ธ์ ๊ด๋ จ ์๋ด ๋ฉ์์ง 2 ...")
],
minTravelerCounts: [.adult: 1, .child: 0],
maxTravelerCounts: [.adult: 4, .child: 3],
minRoomCount: 1,
maxRoomCount: 3
)
TravelerView(
viewData: viewData,
selectedTravelerCounts: $selectedTravelerCounts,
selectedRoomCounts: $selectedRoomCount,
selectedChildAges: $selectedChildAges
)
TravelerView๋ ๋จ์ํ ์ด ๋ฐ์ดํฐ๋ฅผ ๋ ๋๋งํ๋ ์ญํ ๋ง ์ํํฉ๋๋ค.
- Stepper UI ๋ฐฐ์น
- ๋ฉ์์ง ๋ฐ์ค ๋ ธ์ถ
- ์๋ ๋์ด ์ ํ UI
- ๊ฐ์ค ์คํ ํผ
- +/-๋ฒํผ๊ณผ ํ์ฌ ๊ฐ ์ํ ๋ฐ์ธ๋ฉ
๋ฑ UI ์์ ๋ง ๋ด๋นํ๊ณ , ์ ์ฑ ํ๋จ์ ๋จ ํ๋๋ ํ์ง ์์ต๋๋ค.
ํ์ฌ ์ฌ๊ธฐ์ด๋์์๋ ๋๋ฉ์ธ๋ณ๋ก ์ธ์ ๊ตฌ์ฑ์ด ์๋์ฒ๋ผ ์ฌ์ฉ๋๊ณ ์์ต๋๋ค.
- ํจํค์ง: ์ฑ์ธ ยท ์๋ ยท ์ ์ 3์ข ์ธ์ ๊ตฌ์ฑ
- ๊ตญ๋ด์์: ์ฑ์ธ + ์๋ ํตํฉ ์ธ์ 1์ข ๊ทธ
- ํด์ธ์์: ์ฑ์ธ ยท ์๋ ๋ถ๋ฆฌ ์ธ์ + ์๋ ๋์ด + ๊ฐ์ค ์
- ์ฐ: ํด์ธ์ ๋์ผํ ๊ตฌ์ฑ(์ฑ์ธยท์๋ ๋ถ๋ฆฌ + ์๋ ๋์ด) ๋จ, ๊ฐ์ค ์ ์์
์ด๋ ๊ฒ ๋๋ฉ์ธ๋ณ ์ฐจ์ด๋ ๋ชจ๋ ์ธ๋ถ ๋ฐ์ดํฐ(TravelerViewData)์์ ์ ์ํ๊ณ ,
TravelerView๋ ๊ทธ ์ ๋ณด๋ฅผ ๋ฐ์ ๋์ผํ ๋ ์ด์์ ๊ท์น์ ๋ง์ถฐ ๊ทธ๋ฆฌ๊ธฐ๋ง ํ๊ธฐ ๋๋ฌธ์ ์ฝ๋ ํ ์ค ์์ ์์ด๋ ์ฌ๋ฌ ๋๋ฉ์ธ์์ ๊ณตํต์ผ๋ก ์ฌ์ฌ์ฉํ ์ ์์ต๋๋ค.
2. DateTravelerSelector์์ โ๋ฌ๋ ฅ + ์ธ์โ์ ์กฐ๋ฆฝํ๊ธฐ๋ง ํ๋ฉด ๋
์ต์ข DateTravelerSelector ํ๋ฉด์์๋ ์๋์ฒ๋ผ ์กฐ๋ฆฝ๋ง ํ๋ฉด ๋ฉ๋๋ค:
[ ScrollableCalendarView ] โ ๋ ์ง ์ ํ (๊ณตํต)
[ TravelerView ] โ ์ธ์ ์ ํ (๊ณตํต)
๋ชจ๋ ๋๋ฉ์ธ์ ์์ ๋ค์ ์ ์ฑ ๋๋ก TravelerViewData๋ง ๋ง๋ค์ด์ ์ ๋ฌํ๋ฉด ๋๊ณ , TravelerView ๋ด๋ถ ์ฝ๋๋ ๋ ์ด์์ ๋ถ๊ธฐ์๋ ์๋ ์ผ์ด ์์ต๋๋ค.
์ด ๋ฐฉ์์ ์ฅ์ ์ ๋ช ํํฉ๋๋ค.
- UI๋ ์ ๋ ๋ณต์กํด์ง์ง ์์ (๋จ์ ๋ ๋๋ง ๊ณ์ธต ์ ์ง)
- ๋๋ฉ์ธ ์ ์ฑ ์ด ์ถ๊ฐ๋์ด๋ TravelerViewData๋ง ํ์ฅํ๋ฉด ๋จ
- ํจํค์งยท๊ตญ๋ดยทํด์ธยท์ฐ ํ๋ฉด์ด ๊ณตํต ์ปดํฌ๋ํธ๋ฅผ 100% ๊ทธ๋๋ก ์ฌ์ฌ์ฉ
- ์ ์ง๋ณด์ ํฌ์ธํธ๊ฐ Calendar Core์ ๋์ผํ๊ฒ ํ ๊ณณ์ผ๋ก ์ ๋ฆฌ๋จ
โจ ๋ง๋ฌด๋ฆฌํ๋ฉฐ
์ด๋ฒ ๊ตฌ์กฐ ๊ฐํธ์ ํตํด Dateยท์ธ์ ์ ํ UI๋ ๋๋ฉ์ธ๋ณ ์ ์ฑ ์ฐจ์ด๋ฅผ ์ ์งํ๋ฉด์๋, SwiftUI ๊ธฐ๋ฐ์ ์ผ๊ด๋ Calendar Core + ๊ฐ๋ฒผ์ด UI ๋ ์ด์ด ๊ตฌ์กฐ๋ก ์ฌ์ ๋น๋์์ต๋๋ค.
๋๋ถ์ ํจํค์งยท๊ตญ๋ด์์ยทํด์ธ์์ยท์ฐ ๋ฑ ๋ค์ํ ํ๋ฉด์์๊ฐ์ ์ปดํฌ๋ํธ๋ฅผ ์์ ์ ์ผ๋ก ์ฌ์ฌ์ฉํ ์ ์๊ฒ ๋์๊ณ , ์์ผ๋ก ์๋ก์ด ์ ์ฑ ์ด ์ถ๊ฐ๋๋๋ผ๋ Core ํ์ฅ๋ง์ผ๋ก ๋์ ๊ฐ๋ฅํ ๊ตฌ์กฐ๋ฅผ ๊ฐ์ถ๊ฒ ๋์์ต๋๋ค.
๊ธด ๊ธ ์ฝ์ด์ฃผ์ ์ ๊ฐ์ฌํฉ๋๋ค.
๋ ๋์ ๊ตฌ์กฐ๋ฅผ ์ํ ๊ณ ๋ฏผ๊ณผ ๊ฒฝํ์ ๊ณ์ ๊ณต์ ํด๋ณด๊ฒ ์ต๋๋ค!