포스트

WidgetKit (12)

CoreData to SwiftData (iOS 17)

Convert

이전글에서 Intent Migration에 대해 언급을 한적이 있다.

이번에도 그와 유사한 방법으로 진행이 가능하다.

우선 CoreData 파일을 클릭하고.

아래 사진처럼 진행을 하자

CleanShot 2024-12-10 at 04 16 36

그리고 next를 누르다가 target을 모두 체크를 하는걸 잊지말자.

CleanShot 2024-12-10 at 04 20 06

그러면 이렇게 파일이 만들어진다.

1
2
3
4
5
6
7
8
9
10
11
12
import Foundation
import SwiftData


@Model public class Day {
    var date: Date?
    var didStudy: Bool?
    public init() {

    }
    
}

Day class가 이제 중복이 되니 CoreData파일을 지워준다.

이때 파일이 Xcode에서 지워지지 않는다면 Finder에서 직접 삭제를 하자.

그리고 init을 작성하면서 옵셔널도 전부 지워준다.

1
2
3
4
5
6
7
8
9
@Model class Day {
    var date: Date
    var didStudy: Bool
    
    init(date: Date, didStudy: Bool) {
        self.date = date
        self.didStudy = didStudy
    }
}

App파일 수정

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
static var sharedStoreURL: URL {
    let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.HaroldSong.SwiftCal")!
    return (container.appendingPathComponent("SwiftCal.sqlite"))
}

let container: ModelContainer = {
    let config = ModelConfiguration(url: sharedStoreURL)
    return try! ModelContainer(for: Day.self, configurations: config)
}()

@State private var selectedTab = 0

var body: some Scene {
    WindowGroup {
        TabView(selection: $selectedTab) {
            Tab("Calendar", systemImage: "calendar", value: 0) {
                CalendarView()
            }
            Tab("Streak", systemImage: "swift", value: 1) {
                StreakView()
            }
        }
        .modelContainer(container)
        .onOpenURL { url in
            selectedTab = url.absoluteString == "calendar" ? 0 : 1
        }
    }
}

persistence에 있던 sharedStoreURL을 가져오고 container를 새로 만들어 준다.

이때 버전은 17.0 이상이어야만한다!!

CalendarView 수정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Environment(\.modelContext) private var context

@Query(filter: #Predicate<Day> {$0.date > startDate && $0.date < endDate }, sort: \Day.date)
var days: [Day]

static var startDate: Date { .now.startOfCalendarWithPrefixDays }
static var endDate: Date { .now.endOfMonth }

.onTapGesture {
        if day.date!.dayInt <= Date().dayInt {
            day.didStudy.toggle()
            WidgetCenter.shared.reloadTimelines(ofKind: "SwiftCalWidget")
        } else {
            print("Can't study in the future!")
        }
    }

func createMonthDays(for date: Date) {
    for dayOffset in 0..<date.numberOfDaysInMonth {
        let date = Calendar.current.date(byAdding: .day, value: dayOffset, to: date.startOfMonth)!
        let newDay = Day(date: date, didStudy: false)
        context.insert(newDay)
    }
}

context, fetchrequest, ontapGesture, createMonthDays 이렇게 4가지 부분이 바뀌었다.

StreakView 수정

1
2
3
4
5
@Query(filter: #Predicate<Day> {$0.date > startDate && $0.date < endDate }, sort: \Day.date)
var days: [Day]

static var startDate: Date { .now.startOfCalendarWithPrefixDays }
static var endDate: Date { .now.endOfMonth }

이것만 추가 되었다.

기존것들은 삭제!

그리고 CoreData의 로직을 담당하던 Persistence 파일도 지워준다.

Widget 수정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@MainActor func fetchDays() -> [Day] {
    var sharedStoreURL: URL {
        let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.HaroldSong.SwiftCal")!
        return (container.appendingPathComponent("SwiftCal.sqlite"))
    }
    
    let container: ModelContainer = {
        let config = ModelConfiguration(url: sharedStoreURL)
        return try! ModelContainer(for: Day.self, configurations: config)
    }()
    
    var startDate: Date { .now.startOfCalendarWithPrefixDays }
    var endDate: Date { .now.endOfMonth }
    
    let predicate = #Predicate<Day> {$0.date > startDate && $0.date < endDate }
    let descriptor = FetchDescriptor(predicate: predicate, sortBy: [.init(\.date)])
    
    return try! container.mainContext.fetch(descriptor)
}

관련된 내용을 전부 지우고, 새롭게 fetch를 하는 함수를 구현한다.

getSnapshot, getTimeline 부분도 고쳐준다.

1
2
3
4
5
6
7
8
9
10
@MainActor func getSnapshot(in context: Context, completion: @escaping (CalendarEntry) -> ()) {
    let entry = CalendarEntry(date: Date(), days: fetchDays())
    completion(entry)
}

@MainActor func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
    let entry = CalendarEntry(date: Date(), days: fetchDays())
    let timeline = Timeline(entries: [entry], policy: .after(.now.endOfDay))
    completion(timeline)
}

모델링이 바뀌면서 옵셔널이었던것이 전부 사라졌으므로 date 관련하게 !가 있다면 꼭 확인해서 지우도록 하자.

CleanShot 2024-12-10 at 05 13 09

계속해서 타입관련에러가 떠서 코드를 자세히 확인하니 여기서 에러가 나다보니 Unwrapping 관련 에러가 나오지 않았다.

이제 실행을 해보면

simulator_screenshot_62DA2078-78D6-4B82-8F0C-F6938B4ED673

강의와 달리 현재 1이 안보인다. 그리고 widget에는 보이지가 않는다.

무슨문제인지 확인을 한번 해보자.

우선 하나는 fetchDays 함수의 여기를 잘못 적었다.

1
2
3
4
5
6
7
// before
var startDate: Date { .now.startOfCalendarWithPrefixDays }
var endDate: Date { .now.endOfMonth }

// after
let startDate = Date().startOfCalendarWithPrefixDays
let endDate = Date().endOfMonth

이건 강의가 잘못된걸로 보이는데

1
2
3
4
// before
@Query(filter: #Predicate<Day> { $0.date > startDate && $0.date < endDate }, sort: \Day.date)
// after
@Query(filter: #Predicate<Day> {$0.date >= startDate && $0.date <= endDate }, sort: \Day.date)

이전에 CoreData의 Predicate에도 >=가 포함되어있어 이렇게 고친다.

우선 날짜는 정상적으로 돌아간다. fetchDays함수의 문제인듯한데 이유는 모르겠다.

simulator_screenshot_1C89B727-FBE5-4734-AD99-82128E62D211

Interactive Widget

우선 Persistence라는 파일을 하나 만들어주고

1
2
3
4
5
6
7
8
9
10
11
12
struct Persistence {
    
    static var container: ModelContainer {
        let container: ModelContainer = {
            let sharedStoreURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.HaroldSong.SwiftCal")!.appendingPathComponent("SwiftCal.sqlite")
            let config = ModelConfiguration(url: sharedStoreURL)
            return try! ModelContainer(for: Day.self, configurations: config)
        }()
        
        return container
    }
}

기존에 App 파일에 있었던 코드를 간소화한다.

그리고 App에 Container를 다음과 같이 바꾼다.

1
.modelContainer(Persistence.container)

그리고 위젯에서도 fetchDays함수를 다음과같이 바꿔준다.

1
2
3
4
5
6
7
8
9
10
11
12
@MainActor func fetchDays() -> [Day] {
    let startDate = Date().startOfCalendarWithPrefixDays
    let endDate = Date().endOfMonth
    
    let predicate = #Predicate<Day> {$0.date >= startDate && $0.date <= endDate }
    let descriptor = FetchDescriptor(predicate: predicate, sortBy: [.init(\.date)])
    
    let context = ModelContext(Persistence.container)
    
    let days = try! context.fetch(descriptor)
    return days
}

이렇게 하자마자 위젯이 작동이된다.

이전 강의에서의 문제점

코드를 비교하니 각자가 다른 영역에서 container를 만들어서 그런문제가 발생한게 아닌가? 라는 생각이 든다.

즉, 앱과 위젯이 독립적으로 ModelContainer를 생성하면서 데이터베이스를 공유하지 못했다는것.

지금의 경우엔 container를 Persistence라는 파일에서 같은걸 사용하고있다.

코드를 다시 이전걸로 적용해보고 앱을 돌렸다가, 다시 돌아와서 해당코드로 다시 바꾸니 역시 그문제가 맞다.

SwiftCalApp.swift

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
// before
static var sharedStoreURL: URL {
    let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.HaroldSong.SwiftCal")!
    return (container.appendingPathComponent("SwiftCal.sqlite"))
}

let container: ModelContainer = {
    let config = ModelConfiguration(url: sharedStoreURL)
    return try! ModelContainer(for: Day.self, configurations: config)
}()

var body: some Scene {
    WindowGroup {
        TabView(selection: $selectedTab) {
        }
        .modelContainer(container)
    }
}

// after
var body: some Scene {
    WindowGroup {
        TabView(selection: $selectedTab) {

        }
        .modelContainer(Persistence.container)
    }
}

기존방식

  • 컨테이너 생성 방식:
    • SwiftCalApp 내부에서 ModelContainer를 직접 생성.
    • 컨테이너 초기화 시 sharedStoreURL을 통해 App Group의 SQLite 경로를 참조.
    • 앱이 실행될 때 ModelContainer가 초기화되어 데이터베이스 연결을 설정.

이후방식

  • 컨테이너 생성 방식:
    • Persistence라는 별도의 파일에서 ModelContainer를 생성하고 관리.
    • 앱은 Persistence.container를 참조하여 컨테이너를 사용.
    • 컨테이너 초기화 로직이 앱 외부로 분리되어 코드 재사용 가능.

SwiftCalWidget.swift -> fetchDays

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
// before
@MainActor func fetchDays() -> [Day] {
    var sharedStoreURL: URL {
        let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.HaroldSong.SwiftCal")!
        return (container.appendingPathComponent("SwiftCal.sqlite"))
    }
    
    let container: ModelContainer = {
        let config = ModelConfiguration(url: sharedStoreURL)
        return try! ModelContainer(for: Day.self, configurations: config)
    }()
    
    let startDate = Date().startOfCalendarWithPrefixDays
    let endDate = Date().endOfMonth
    
    let predicate = #Predicate<Day> {$0.date >= startDate && $0.date <= endDate }
    let descriptor = FetchDescriptor(predicate: predicate, sortBy: [.init(\.date)])
    
    return try! container.mainContext.fetch(descriptor)
}


// after
@MainActor func fetchDays() -> [Day] {
    let startDate = Date().startOfCalendarWithPrefixDays
    let endDate = Date().endOfMonth
    
    let predicate = #Predicate<Day> {$0.date >= startDate && $0.date <= endDate }
    let descriptor = FetchDescriptor(predicate: predicate, sortBy: [.init(\.date)])
    
    let context = ModelContext(Persistence.container)
    
    return try! context.fetch(descriptor)
}

기존방식

  • 컨테이너 생성 방식:
    • fetchDays() 함수 내부에서 ModelContainer를 직접 생성.
    • sharedStoreURL을 사용하여 App Group의 SQLite 경로를 참조.
    • 데이터베이스에서 데이터를 가져올 때마다 새로운 컨테이너를 생성.

이후방식

  • 컨테이너 생성 방식:
    • Persistence.container를 사용하여 데이터베이스와 연결.
    • fetchDays()는 Persistence.container의 컨텍스트를 참조하여 데이터를 가져옴.
    • 컨테이너 생성 로직이 Persistence에 집중.

정리

구분기존 방식이후 방식
컨테이너 관리앱과 위젯에서 독립적으로 ModelContainer 생성Persistence.container를 통해 단일화된 컨테이너 사용
데이터 일관성앱과 위젯 간 데이터 동기화 어려움앱과 위젯 간 데이터 동기화 용이
코드 중복컨테이너 생성 로직이 앱과 위젯 모두에 중복컨테이너 생성 로직이 Persistence에 집중되어 중복 제거
리소스 효율성컨테이너를 매번 생성하여 리소스 낭비 발생컨테이너를 재사용하여 리소스 절약
유지보수컨테이너 설정 변경 시 모든 관련 파일을 수정해야 함Persistence만 수정하면 앱과 위젯 모두에 변경 적용 가능

AppIntents 적용

위에 언급을 못했는데 이렇게 새로 파일을 만들땐 항상 Target을 확인하자.

1
2
3
4
5
6
7
8
9
10
struct TogleStudyIntent: AppIntent {
    
    static var title: LocalizedStringResource = "Toggle Studied"
    
    func perform() async throws -> some IntentResult {
        print("Toggle Study")
        return .result()
    }
    
}

이렇게 만들어준다.

처음에 AppIntent 프로토콜을 적용하면 몇가지를 추가하라고 에러로 뜨는데 그거 누르고 지금상황에서는 typealias는 지워주고 title만 사용해주자.

Button 추가

SwiftCalWidgetEntryView에서 버튼을 추가하는데, AppIntent와 상호작용해야하므로 import AppIntent를 해주고

CleanShot 2024-12-10 at 18 44 44

버튼을 추가할때 이녀석으로 해주자.

1
Button("Study", systemImage: "book", intent: ToggleStudyIntent())

simulator_screenshot_CC4147B8-B64A-44E4-9C9A-E4DADE0D85CC

이렇게 적용을하고 실행시키면 위와같이나온다.

우리가 스터디 버튼을 누르게 되면 아까 위에서 perform에서 print가 작동할것이다.

하지면 내것에선 작동하지 않았다.

뭐가문제일까

AppIntent 수정 및 Widget 적용

AppIntent 수정

IntentParameter 참고.

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
static var title: LocalizedStringResource = "Toggle Studied"

@Parameter(title: "Date")
var date: Date

init(date: Date) {
    self.date = date
}

init() {
    
}

func perform() async throws -> some IntentResult {
    let context = ModelContext(Persistence.container)
    let predicate = #Predicate<Day> { $0.date == date }
    let descriptor = FetchDescriptor(predicate: predicate)
    
    guard let day = try! context.fetch(descriptor).first else {
        return .result()
    }
    day.didStudy.toggle()
    try! context.save()
    return .result()
}

이때 파라미터 부분을 좀 다시 본다면?

1
2
@Parameter(title: "Date")
var date: Date

title: “Date”

  • 사용자가 App Intent를 실행할 때, 해당 파라미터가 어떤 값인지 명시적으로 알리기 위한 제목.
    • 이 값은 시스템 UI(예: Siri, Shortcuts 앱)에서 사용자에게 “어떤 값을 입력해야 하는지”를 안내하는 라벨로 표시된다.
    • 예: “Date”라는 제목으로 표시되며, 사용자는 날짜를 선택하게 됨. var date: Date
    • 이 파라미터는 Date 타입으로 정의되어 있으며, App Intent가 실행될 때 사용자로부터 제공받을 값이다.
    • 사용자는 이 date 값을 통해 특정 날짜를 지정할 수 있으며, Intent 내부에서 이 값이 사용된다.

@Parameter는 이 App Intent가 실행될 때 사용자가 입력해야 할 값(여기서는 날짜)을 정의하는 역할을 하며, 해당 값에 대한 설명(UI에서의 라벨 역할)을 제공한다. 이를 통해 사용자는 명확하게 어떤 값을 제공해야 하는지 알 수 있다


Perform 함수도 조금 다시 본다면

@Parameter로 전달된 date 사용

1
let predicate = #Predicate<Day> { $0.date == date }
  • date는 @Parameter로 사용자가 제공한 날짜이다.
  • 데이터베이스에서 date와 일치하는 Day 객체를 가져온다.
  • 즉, date 값에 해당하는 데이터를 처리합니다. (오늘과는 관계 X)

Widget 적용

그리고 Widget으로 돌아가서

오늘을 나타내는 변수를 하나 만든다

1
2
3
var today: Day {
    entry.days.filter { Calendar.current.isDate($0.date, inSameDayAs: .now)}.first ?? .init(date: .distantPast, didStudy: false)
}
  1. entry.days
    • entry는 CalendarEntry 객체로, 위젯의 데이터 모델이다.
    • entry.days는 Day 객체들의 배열로, 특정 기간의 날짜와 공부 여부(didStudy) 데이터를 포함한다.
  2. filter 조건
    • Calendar.current.isDate($0.date, inSameDayAs: .now)
    • 배열의 각 Day 객체의 date가 오늘 날짜와 동일한지 확인.
    • 오늘 날짜와 같은 Day 객체만 필터링.
  3. first
    • 필터링된 결과에서 첫 번째 Day 객체를 가져옴.
    • 오늘 날짜에 해당하는 데이터가 여러 개 있을 경우, 첫 번째 데이터만 사용.
  4. ?? .init(...)
    • 만약 필터링된 결과가 없을 경우 기본값을 반환
    • date: .distantPast (아주 오래전의 날짜로 설정)
    • didStudy: false (공부 여부를 false로 설정)

그리고 버튼을 삼항연산자를 통해 적용해준다.

1
2
3
4
5
6
Button(today.didStudy ? "Studied" : "Study",
        systemImage: today.didStudy ? "checkmark.circle" : "book",
        intent: ToggleStudyIntent(date: today.date))
    .font(.caption)
    .tint(today.didStudy ? .mint : .orange)
    .controlSize(.small)

Dec-10-2024 19-07-07

실행하니 다음과 같이 잘 된다.

이때 버튼을 클릭할때마다 위젯의 달력이 좌우로 왔다갔다하는데

1
2
3
4
5
6
7
8
9
10
11
12
13
14
VStack {
    Link(destination: URL(string: "streak")!) {
        VStack {
        }
    }
    
    Button(today.didStudy ? "Studied" : "Study",
            systemImage: today.didStudy ? "checkmark.circle" : "book",
            intent: ToggleStudyIntent(date: today.date))
        .font(.caption)
        .tint(today.didStudy ? .mint : .orange)
        .controlSize(.small)
}
.frame(width: 90)

VStack의 프레임을 고정하면 해결 된다.

이부분의 사진은 패스

Dec-10-2024 19-10-42

Widget과 App에서 내용이 바뀌면 상호간의 적용이 바로안되는 버그가 발생한다.

이후 강의에서

1
2
3
4
5
6
7
8
9
.onTapGesture {
    if day.date.dayInt <= Date().dayInt {
        day.didStudy.toggle()
        try? context.save() // new
        WidgetCenter.shared.reloadTimelines(ofKind: "SwiftCalWidget")
    } else {
        print("Can't study in the future!")
    }
}

저부분이 빠져서 안된 문제였다.

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