포스트

WidgetKit (8)

이번에는 Coredata를 연동한 Calendar 위젯을 만들어보려고한다.

CleanShot 2024-12-06 at 15 19 17

일반적으로 박스를 친 부분에는 None인데, 이번엔 CoreData를 사용하기에 프로젝트를 생성하면서 CoreData도 같이 생성되게 하였다.

사실 체크 안했더라도 이후에 CoreData를 새로 추가해도 되긴하다.

Attribute 추가

CleanShot 2024-12-06 at 15 28 55

사진과 같이 한다.

뭐 언급할 부분은 없다.

Preview 수정

1
2
3
4
5
for _ in 0..<10 {
    let newDay = Day(context: viewContext)
    newDay.date = Date()
    newDay.didStudy = Bool.random()
}

Item 대신 Day Entity가 생겼다.

이때 Entity가 적용이 안되면 Xcode를 재실행하면 된다.

View 간소화하기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct ContentView: View {
    @Environment(\.managedObjectContext) private var viewContext
    
    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Day.date, ascending: true)],
        animation: .default)
    private var days: FetchedResults<Day>
    
    var body: some View {
        NavigationView {
            List {
                ForEach(days) { day in
                    Text(day.date!.formatted())
                }
            }
        }
    }
}

필요없는 부분은 전부 날려두었다.

현재 List만 남겨둔 이유는 날짜 값을 preview를 통해 확인을 하기 위함. 이후 Calendar로 바꿀예정

CleanShot 2024-12-06 at 15 36 59

우선 preview는 다음과 같다.

preview 현재 달을 기준으로 한달 만들기

1
2
3
4
5
6
7
let startDate = Calendar.current.dateInterval(of: .month, for: .now)!.start

for dayOffset in 0..<31 {
    let newDay = Day(context: viewContext)
    newDay.date = Calendar.current.date(byAdding: .day, value: dayOffset, to: startDate)
    newDay.didStudy = Bool.random()
}

이렇게 해주면 된다.

크게 언급할 부분은 없어 보인다.

startDate는 현재 날짜를 기준으로, 그 달의 첫째날을 구하는 방식이다.

CleanShot 2024-12-06 at 17 06 03

그럼 이렇게 만들어진다.

Grid를 활용하여 Calendar 만들기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
let daysOfWeek = ["S", "M", "T", "W", "T", "F", "S"]

var body: some View {
    NavigationView {
        VStack {
            HStack {
                ForEach(daysOfWeek, id: \.self) { dayOfWeek in
                    Text(dayOfWeek)
                        .fontWeight(.black)
                        .foregroundStyle(.orange)
                        .frame(maxWidth: .infinity)
                }
            }
            
            LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 7), content: {
                ForEach(days) { day in
                    Text(day.date!.formatted(.dateTime.day()))
                        .fontWeight(.bold)
                        .foregroundStyle(day.didStudy ? .orange : .secondary)
                        .frame(maxWidth: .infinity, minHeight: 40)
                        .background {
                            Circle()
                                .foregroundStyle(.orange.opacity(day.didStudy ? 0.3 : 0.0))
                        }
                }
            })
            Spacer()
        }
        .navigationTitle(Date().formatted(.dateTime.month(.wide)))
        .padding()
    }
}

이렇게 구성을 해주었다. 크게 언급할 부분은 없어 보인다.

CleanShot 2024-12-06 at 17 20 03

Calendar Logic 구현

지금 우연히 겹쳤는데

CleanShot 2024-12-06 at 17 27 59

12월의 캘린더와 일치 한다.

하지만 내년 1월은 수요일에 1일인데

현재 View에는 그냥 로직이 매달 일요일이 1일로 시작하기에 Calendar라고 하기엔 많이 부족하다.

이젠 Calendar 로직을 구현해보도록 한다.

Date Extension 추가

우선 Date Extension을 다음과 같이 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
extension Date {

    var startOfMonth: Date {
        Calendar.current.dateInterval(of: .month, for: self)!.start
    }

    var endOfMonth: Date {
        Calendar.current.dateInterval(of: .month, for: self)!.end
    }

    var endOfDay: Date {
        Calendar.current.dateInterval(of: .day, for: self)!.end
    }

    var startOfPreviousMonth: Date {
        let dayInPreviousMonth = Calendar.current.date(byAdding: .month, value: -1, to: self)!
        return dayInPreviousMonth.startOfMonth
    }

    var startOfNextMonth: Date {
        let dayInNextMonth = Calendar.current.date(byAdding: .month, value: 1, to: self)!
        return dayInNextMonth.startOfMonth
    }

    var numberOfDaysInMonth: Int {
        // endOfMonth returns the 1st of next month at midnight.
        // An adjustment of -1 is necessary to get last day of current month
        let endDateAdjustment = Calendar.current.date(byAdding: .day, value: -1, to: self.endOfMonth)!
        return Calendar.current.component(.day, from: endDateAdjustment)
    }

    var dayInt: Int {
        Calendar.current.component(.day, from: self)
    }

    var monthInt: Int {
        Calendar.current.component(.month, from: self)
    }

    var monthFullName: String {
        self.formatted(.dateTime.month(.wide))
    }
}

각각은 다음을 의미한다.

  1. startOfMonth
    • 현재 날짜가 속한 월의 첫 번째 날을 반환한다.
      1
      2
      
       let currentDate = Date() // 예: 2024-12-06
       print(currentDate.startOfMonth) // 2024-12-01 00:00:00
      
  2. endOfMonth
    • 현재 날짜가 속한 월의 마지막 날의 다음 날을 반환한다.
      1
      2
      
       let currentDate = Date() // 예: 2024-12-06
       print(currentDate.endOfMonth) // 2025-01-01 00:00:00
      
  3. endOfDay
    • 현재 날짜의 마지막 시간(다음 날 0시)을 반환한다.
      1
      2
      
       let currentDate = Date() // 예: 2024-12-06
       print(currentDate.endOfDay) // 2024-12-07 00:00:00
      
  4. startOfPreviousMonth
    • 이전 달의 첫 번째 날을 반환한다.
      1
      2
      
       let currentDate = Date() // 예: 2024-12-06
       print(currentDate.startOfPreviousMonth) // 2024-11-01 00:00:00
      
  5. startOfNextMonth
    • 다음 달의 첫 번째 날을 반환한다.
      1
      2
      
       let currentDate = Date() // 예: 2024-12-06
       print(currentDate.startOfNextMonth) // 2025-01-01 00:00:00
      
  6. numberOfDaysInMonth
    • 현재 날짜가 속한 월의 일 수를 반환한다.
      1
      2
      
       let currentDate = Date() // 예: 2024-12-06
       print(currentDate.numberOfDaysInMonth) // 31
      
  7. dayInt
    • 현재 날짜의 일(day) 숫자를 반환한다.
      1
      2
      
       let currentDate = Date() // 예: 2024-12-06
       print(currentDate.dayInt) // 6
      
  8. monthInt
    • 현재 날짜의 월(month) 숫자를 반환한다.
      1
      2
      
       let currentDate = Date() // 예: 2024-12-06
       print(currentDate.monthInt) // 12
      
  9. monthFullName
    • 현재 날짜의 월 이름을 반환한다.
      1
      2
      
       let currentDate = Date() // 예: 2024-12-06
       print(currentDate.monthFullName) // December
      

일 생성 함수 구현하기

그리고 각 달에 해당하는 일을 구하는 함수를 만든다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func createMonthDays(for date: Date) {
    for dayOffset in 0..<date.numberOfDaysInMonth {
        let newDay = Day(context: viewContext)
        newDay.date = Calendar.current.date(byAdding: .day, value: dayOffset, to: date.startOfMonth)
        newDay.didStudy = false
    }
    
    do {
        try viewContext.save()
        print("✅ \(date.monthFullName) days created")
    } catch {
        print("Error creating month days: \(error)")
    }
}

numberOfDaysInMonth

  • endOfMonth는 현재 달의 마지막 날의 다음 날 자정(다음 달 1일 00:00)을 반환한다.
  • 따라서, 마지막 날짜를 계산하려면 하루를 빼야 한다.
  • ex) 2024년 12월의 경우:
    • startOfMonth = 2024-12-01
    • endOfMonth = 2025-01-01 (다음 달의 첫째 날)
    • endDateAdjustment = 2024-12-31 (하루 뺌)
    • numberOfDaysInMonth = 31

이렇게하여 마지막 날짜를 계산하고 createMonthDays 함수에 적용하면, 각 달에 해당하는 ex) 12월: 1~31 생성이 되고, 값이 CoreData에 저장된다.

FetchRequest 수정하기

1
2
3
4
5
@FetchRequest(
    sortDescriptors: [NSSortDescriptor(keyPath: \Day.date, ascending: true)],
    predicate: NSPredicate(format: "(date >= %@) AND (date <= %@)",
    Date().startOfMonth as CVarArg,
    Date().endOfMonth as CVarArg))

NSPredicate에서 startOfMonth 기준으로 이상(date >= %@), endOfMonth 기준으로 이하(date <= %@)를 의미한다.

그리고 그 강의에서 어떤 유져(Obj-c Developer)의 댓글을 보았는데 (date >= %@) AND (date <= %@) 이거대신 date BETWEEN { %@, %@ } 이렇게 쓰면 조금 더 심플하게 표현이 가능하다고 말한다.

그리고 as를 통해 다운 캐스팅을 한건 NSPredicate init에 나와있다.

해당 방법은 init 방법 중 하나이다.

NSPredicate Docs는 여기.

predicate의 결과로, 달의 시작 일수부터 마지막 일수까지 전부를 가져오게 된다.

즉, 그달의 일자를 전부 가져온다.

그리고 Date Extension에 다음과 같은 내용을 하나 더 추가해준다

1
2
3
4
5
6
var startOfCalendarWithPrefixDays: Date {
    let startOfMonthWeekday = Calendar.current.component(.weekday, from: self.startOfMonth)
    let numberOfPrefixDays = startOfMonthWeekday - 1
    let startDate = Calendar.current.date(byAdding: .day, value: -numberOfPrefixDays, to: startOfMonth)!
    return startDate
}

startOfCalendarWithPrefixDays는 현재 달의 시작 날짜를 기준으로 해당 월의 캘린더에 표시될 이전 달의 날짜들을 포함한 시작 날짜를 계산한다.

작동 순서

  1. startOfMonthWeekday
    • 현재 달의 첫째 날의 요일(일요일: 1, 월요일: 2, …)을 수로 나타냄.
    • 예를 들어, 2024년 12월 1일은 일요일(1).
    • 2025년 1월 1일이라면 수요일(4)이다.
  2. numberOfPrefixDays
    • 첫 주의 앞쪽 빈칸을 채울 날짜의 수를 계산한다.
    • 이는 startOfMonthWeekday - 1로 계산되며, 예를 들어, 월요일(2)이면 1일 이전의 날짜가 필요하다.
    • 예를들어, 2025년 1월의 첫 번째 날(1월 1일)은 수요일(4)이다.
      • startOfMonthWeekday = 4
      • numberOfPrefixDays = startOfMonthWeekday - 1 = 4 - 1 = 3
      • 따라서, 캘린더의 첫 번째 줄의 앞쪽에 3개의 빈칸이 필요하며, 2024년 12월 29일(일요일) 부터 시작.
  3. startDate
    • numberOfPrefixDays를 현재 달의 시작 날짜에서 빼서 캘린더의 시작 날짜를 결정한다.
    • 2025년 1월 1일에서 3일을 빼면 2024년 12월 29일이다.
    • 즉, 캘린더는 2024년 12월 29일부터 시작해야 한다.

결과

캘린더 예시2024년 12월2025년 1월
startOfMonth2024년 12월 1일 (일요일)2025년 1월 1일 (수요일)
startOfWeekday14
numberOfPrefixDays03
startDate2024년 12월 1일2024년 12월 29일 (일요일)

2025년 1월 캘린더

2930311234
567891011

2024년 12월 캘린더

1234567

그리고 fetchRequest를 다시 수정해준다.

1
2
3
4
5
@FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Day.date, ascending: true)],
        predicate: NSPredicate(format: "(date >= %@) AND (date <= %@)",
                               Date().startOfCalendarWithPrefixDays as CVarArg,
                               Date().endOfMonth as CVarArg))

이후 onappear도 수정

1
2
3
4
5
6
7
8
9
.onAppear {
    if days.isEmpty {
        createMonthDays(for: .now.startOfPreviousMonth)
        createMonthDays(for: .now)
    } else if days.count < 10 {
        // Is this Only the prefix days
        createMonthDays(for: .now)
    }
}
  1. days.isEmpty
    • 저장된 Day 데이터가 비어 있는 경우, 이전 달과 현재 달의 날짜 데이터를 생성한다.
    • 이 과정에서 createMonthDays(for: date) 함수를 호출하여 날짜 데이터를 Core Data에 추가한다.
  2. days.count < 10
    • days 데이터가 10개 미만인 경우, 데이터가 불완전하다고 판단하고 현재 달 데이터를 추가로 생성한다.
    • 이 로직은 일반적으로 이전 달의 데이터만 저장된 경우(ex: 29, 30, 31)를 처리하기 위한 것이다.

그리고 VGrid에 대해서도 수정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 7), content: {
    ForEach(days) { day in
        if day.date!.monthInt != Date().monthInt {
            Text(" ")
        } else {
            Text(day.date!.formatted(.dateTime.day()))
                .fontWeight(.bold)
                .foregroundStyle(day.didStudy ? .orange : .secondary)
                .frame(maxWidth: .infinity, minHeight: 40)
                .background {
                    Circle()
                        .foregroundStyle(.orange.opacity(day.didStudy ? 0.3 : 0.0))
                }
        }
    }
})

if문이 생겼는데, 달력에 일요일이 1일이 아닌 경우

CleanShot 2024-12-06 at 18 19 24

위와 같은 사진처럼 지난달의 마지막 일수가 적히는데 그걸 방지하기 위해서 if 조건을 달았다.

현재 생성된 값의 달이 지금 달(Date().monthInt)과 일치 하지 않을때, 즉 생성된 일자가, 현재 달이 아닌 일에 대해서 그 값을 공백으로 치는 것이다.

CleanShot 2024-12-06 at 18 20 04

이렇게 없어진걸 볼 수 있다.

이번글은 캘린더 관련 로직이 중요하기에 이해를 잘 해둬야겠다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.