Dex (fin)
Predicate 수정
CoreData에서 SwiftData로의 마이그레이션을 마치며 기본적인 구조와 동작 오류는 대부분 해결했다. 하지만 실제 앱에서 데이터를 “조건에 맞게 필터링”하는 기능, 즉 Predicate는 아직 손대지 않은 상태이다.
이번 글에서는 SwiftData에서 predicate를 어떻게 사용하는지, 기존 방식과 어떤 차이가 있는지를 구체적으로 살펴보며 마이그레이션을 마무리해본다.
CoreData vs SwiftData: Predicate 비교
CoreData 방식
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// CoreData 방식
private var dynamicPredicate: NSPredicate {
var predicates: [NSPredicate] = []
if !searchText.isEmpty {
predicates.append(NSPredicate(format: "name contains[c] %@", searchText))
}
if filterByFavorite {
predicates.append(NSPredicate(format: "favorite == %d", true))
}
return NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
}
NavigationStack {
List {
Section {
ForEach(pokedex) // 결과 자동 필터링 안 됨
}
}
}
문자열 기반 format으로 Predicate를 구성해야 하며, 타입 안정성(type-safety)이 떨어지고 런타임 에러 발생 가능성이 존재함.
SwiftData 방식
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// SwiftData 방식
private var dynamicPredicate: Predicate<Pokemon> {
#Predicate<Pokemon> { pokemon in
if filterByFavorite && !searchText.isEmpty {
pokemon.favorite && pokemon.name.localizedStandardContains(searchText)
} else if !searchText.isEmpty {
pokemon.name.localizedStandardContains(searchText)
} else if filterByFavorite {
pokemon.favorite
} else {
true
}
}
}
NavigationStack {
List {
Section {
ForEach((try? pokedex.filter(dynamicPredicate)) ?? pokedex)
}
}
}
SwiftData의 #Predicate는 타입 세이프하며 코드 레벨에서 논리 조건을 직접 명시할 수 있다.
&&, || 등의 Swift 연산자를 그대로 사용할 수 있어 복잡한 조건 조합도 직관적으로 표현 가능하며, 가독성이 높고 디버깅도 훨씬 쉽다.
🔍 추가 참고 자료
SwiftData 공식 튜토리얼
SwiftData Tutorials우리가 일반적으로 알고 있는 String.filter
String.filterSequence 프로토콜의 filter (예시 없음)
Sequence.filter
- 개인적인 생각
- SwiftData의 filter는 Predicate 기반으로 동작하며 throws 가능성이 있음
- 따라서 사용 시 try 또는 try?가 필요하며, 실패를 대비해 ?? 연산자로 fallback 처리 가능
- 일반적인 Sequence.filter와는 완전히 다른 메서드이므로 혼동하지 않아야 함
향후 throws, rethrows, throwing closures 등에 대한 별도 정리가 필요해보인다.
해당 부분에 대해 별도 정리한글은 나중에 링크를 여기에 추가하는걸로…
애니메이션과 UX 개선
predicate 조건이 바뀔 때마다 리스트 애니메이션을 부여하여 부드러운 UX를 제공한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
.autocorrectionDisabled()
.animation(.default, value: searchText)
ToolbarItem(placement: .navigationBarTrailing) {
Button {
withAnimation {
filterByFavorite.toggle()
}
} label: {
Label("Filter By Favorites", systemImage: filterByFavorite ? "star.fill" : "star")
}
.tint(.yellow)
}
요약
- SwiftData에서는
#Predicate<T>
구문을 통해 타입 안정성과 가독성 높은 필터링이 가능하다. - CoreData에서 자주 발생하던 format 오류, 문자열 오탈자 문제에서 자유롭다.
ForEach(pokedex.filter(predicate))
형태로 결과를 안전하게 필터링할 수 있다.- 동적 필터링 시에는
.animation()
또는withAnimation {}
으로 자연스러운 뷰 변화를 연출할 수 있다.
Widget 수정하기
이전에 앱 실행이 우선이라 위젯부분을 전부 주석처리했는데 이제는 이 부분을 마무리 지으려한다.
Provider 수정
sharedModelContainer 정의
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var sharedModelContainer: ModelContainer = {
let schema = Schema([
Pokemon.self,
])
let modelConfiguration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false
)
do {
return try ModelContainer(
for: schema,
configurations: [modelConfiguration]
)
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}()
SwiftData는 SwiftUI View에서의 사용을 전제로 설계되었기 때문에, View 외부인
TimelineProvider
에서 사용할 경우 직접 ModelContainer를 구성해 주입해야 한다.
getTimeline 함수 수정
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
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
Task { @MainActor in
var entries: [SimpleEntry] = []
let currentDate = Date()
if let results = try? sharedModelContainer.mainContext.fetch(FetchDescriptor<Pokemon>()) {
for hourOffset in 0..<10 {
let date = Calendar.current.date(byAdding: .second, value: hourOffset * 5, to: currentDate)!
let pokemon = results.randomElement()!
let entry = SimpleEntry(
date: date,
name: pokemon.name,
types: pokemon.types,
sprite: pokemon.spriteImage
)
entries.append(entry)
}
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
} else {
let timeline = Timeline(entries: [SimpleEntry.placeholder, SimpleEntry.placeholder2], policy: .atEnd)
completion(timeline)
}
}
}
- @MainActor가 필요한 이유: mainContext는 MainActor에 바인딩되어 있기 때문에, 해당 fetch 작업은 MainActor에서 수행되어야 한다.
- Task로 감싸는 이유: getTimeline은 동기 함수이기 때문에 함수 선언부에 @MainActor를 붙일 수 없다. 따라서 내부에서
Task { @MainActor in ... }
로 명시적인 메인 액터 컨텍스트를 확보한다. - fetch가 실패하면 else 블럭에서 placeholder로 대체한다.
내가 이해한부분에 대해 강사에게 물어보니
“Yes, you’re right again. We need that portion of the code to be on the main actor but if we put @MainActor outside of the function it will break other stuff, so we do it inside the function instead, which requires us to do Task.”
이렇게 대답이 왔었다.
즉, 함수 외부에 @MainActor를 붙이면 위젯 전체 생명주기와 관련된 동작이 깨질 수 있기 때문에, 함수 내부에서만 메인 액터로 전환하는 것
Challenge
- Shiny 스프라이트 전환 토글
- ContentView에 일반 스프라이트 ↔ Shiny 스프라이트를 전환할 수 있는 토글 추가.
- 포켓몬 타입 필터링 추가
- 기존의 즐겨찾기/검색 필터 외에, 타입별로 필터링하는 기능 추가.
- 스프라이트 전체 보기 화면 구현
- 디테일 화면의 스프라이트를 탭하면 여러 세대의 다양한 스프라이트를 보여주는 새로운 화면으로 이동.
- 포켓몬 기술 목록 표시
- 디테일 화면 하단에 DisclosureGroup을 추가해 해당 포켓몬이 배울 수 있는 모든 기술들을 표시.
- 기술의 타입 정보도 함께 표시하면 보너스. ⚙️ 챌린지는 CoreData와 SwiftData 중 원하는 방식으로 구현 가능하며, 두 방식 모두 시도해도 좋음. 각각의 커밋 기준으로 새로운 브랜치를 만들어 분리된 작업이 가능하며, 병합은 필수 아님.
이것도 나중에 해보는걸로..