Dex (4)
Fetch
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
struct FetchService {
enum FetchError: Error {
case badResponse
}
private let baseURL = URL(string: "https://pokeapi.co/api/v2/pokemon")!
func fetchPokemon(_ id: Int) async throws -> FetchedPokemon {
let fetchURL = baseURL.appending(path: String(id))
let (data, response) = try await URLSession.shared.data(from: fetchURL)
guard let response = response as? HTTPURLResponse, response.statusCode == 200 else {
throw FetchError.badResponse
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let pokemon = try decoder.decode(FetchedPokemon.self, from: data)
print("Fetched pokemon: \(pokemon.id): \(pokemon.name.capitalized)")
return pokemon
}
}
이렇게 코드를 작성해준다. 이미 BBQuote 에서 했던 내용이라 설명은 패스
그리고 Content View에서 데이터를 가져오도록 한다.
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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
struct ContentView: View {
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Pokemon.id, ascending: true)],
animation: .default)
private var pokedex: FetchedResults<Pokemon>
let fetcher = FetchService()
var body: some View {
NavigationView {
List {
ForEach(pokedex) { pokemon in
NavigationLink {
Text(pokemon.name ?? "no name")
} label: {
Text(pokemon.name ?? "no name")
}
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
ToolbarItem {
Button("Add Item", systemImage: "plus") {
getPokemon()
}
}
}
}
}
private func getPokemon() {
Task {
for id in 1..<152 {
do {
let fetchedPokemon = try await fetcher.fetchPokemon(id)
let pokemon = Pokemon(context: viewContext)
pokemon.id = fetchedPokemon.id
pokemon.name = fetchedPokemon.name
pokemon.types = fetchedPokemon.types
pokemon.hp = fetchedPokemon.hp
pokemon.attack = fetchedPokemon.attack
pokemon.defense = fetchedPokemon.defense
pokemon.specialAttack = fetchedPokemon.specialAttack
pokemon.specialDefense = fetchedPokemon.specialDefense
pokemon.speed = fetchedPokemon.speed
pokemon.sprite = fetchedPokemon.sprite
pokemon.shiny = fetchedPokemon.shiny
try viewContext.save()
} catch {
print(error)
}
}
}
}
}
ContentView에 대해 코드 서술한 적이 없으므로 이번에는 코드를 전부 적었다.
사실 크게 언급할만한게 없어보인다.
실행하면 위와 같고
또한 콘솔에
1
2
3
4
5
Fetched pokemon: 147: Dratini
Fetched pokemon: 148: Dragonair
Fetched pokemon: 149: Dragonite
Fetched pokemon: 150: Mewtwo
Fetched pokemon: 151: Mew
이런식으로 출력된다. (마지막 부분만 가져왔다.)
UIDesign
List Design
기존에 NavigationView로 되어있던걸 NavigationStack으로 고치고, NavigationLink등 몇개를 손보면서
코드를 수정한다.
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
NavigationStack {
List {
ForEach(pokedex) { pokemon in
NavigationLink(value: pokemon) {
AsyncImage(url: pokemon.sprite) { image in
image
.resizable()
.scaledToFit()
} placeholder: {
ProgressView()
}
.frame(width: 100, height: 100)
VStack(alignment: .leading) {
Text(pokemon.name!.capitalized)
.fontWeight(.bold)
HStack {
ForEach(pokemon.types!, id: \.self) { type in
Text(type.capitalized)
.font(.subheadline)
.fontWeight(.semibold)
.foregroundStyle(.black)
.padding(.horizontal, 13)
.padding(.vertical, 5)
.background(Color(type.capitalized))
.clipShape(.capsule)
}
}
}
}
}
}
.navigationTitle("Pokedex")
.navigationDestination(for: Pokemon.self) { pokemon in
Text(pokemon.name ?? "no name")
}
}
딱히 언급할만한건 없어보인다.
실행하면 이렇게 된다.
sort & filter
기존에 JPApexPredator에서 했던것과 비슷하게 searchbar 그리고 filter 기능을 넣어본다.
1
2
3
4
5
6
@State private var searchText = ""
// 생략
.navigationTitle("Pokedex")
.searchable(text: $searchText, prompt: "Find a Pokemon")
.autocorrectionDisabled()
기능은 작동하지 않아도 우선 ui적으로는
이렇게 searchbar가 만들어졌다.
하지만 왜 작동안되는지는 알지만 이번에는 변수가 좀 다르다.
private var pokedex: FetchedResults<Pokemon>
이렇게되어있다.
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
@FetchRequest<Pokemon>(
sortDescriptors: [SortDescriptor(\.id)],
animation: .default
) private var pokedex
private var dynamicPredicate: NSPredicate {
var predicates: [NSPredicate] = []
// Search predicate
if !searchText.isEmpty {
predicates.append(NSPredicate(format: "name contains[c] %@", searchText))
}
// Filter by favorite predicate
// Combine predicates
return NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
}
// 생략
.autocorrectionDisabled()
.onChange(of: searchText) {
pokedex.nsPredicate = dynamicPredicate
}
Apple Developer Docs – FetchRequest
기존에는 pokedex 변수에 대해서 그냥 만들었던걸 이젠 @FetchRequest wrapper를 사용하여 바꿔 주었다.
그러면서 pokedex는 아래 사진과 같은 타입이 되었다.
물론 위에 올려보면 알겠지만 해당 부분을 수정하기 전에도
1
2
3
4
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Pokemon.id, ascending: true)],
animation: .default)
private var pokedex: FetchedResults<Pokemon>
이미 FetchedResults<Pokemon>
이긴 했으나, 이때는 직접적으로 설정을 해주었다.
@FetchRequest는 Core Data에서 엔티티를 조회하고, SwiftUI 뷰에 실시간으로 데이터를 반영할 수 있게 해주는 property wrapper이다. SwiftUI에서 List, ForEach와 함께 많이 사용된다.
여기선 아래와 같이 쓰였다
1
2
3
4
@FetchRequest<Pokemon>(
sortDescriptors: [SortDescriptor(\.id)],
animation: .default
) private var pokedex
항목 | 설명 |
---|---|
@FetchRequest | SwiftUI 뷰에서 Core Data 객체를 가져올 때 사용하는 property wrapper |
<Pokemon> | 조회 대상 Core Data Entity 타입 지정 |
sortDescriptors | 어떤 속성을 기준으로 정렬할지 지정 (.id 기준 오름차순) |
animation | 데이터 변경 시 SwiftUI에서 적용할 애니메이션 |
pokedex | FetchedResults |
- FetchedResults는 배열처럼 사용할 수 있는 컬렉션이지만, 실제로는 Core Data의 실시간 결과 집합
- 항상 private으로 선언하는 것이 권장됨 (뷰 초기화 시 외부 설정 방지)
그리고 이제 동적으로 작동하게 만드는 dynamicPredicate를 만들어 주었다.
코드는 아래와 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
private var dynamicPredicate: NSPredicate {
var predicates: [NSPredicate] = []
// Search predicate
if !searchText.isEmpty {
predicates.append(NSPredicate(format: "name contains[c] %@", searchText))
}
// Filter by favorite predicate
// Combine predicates
return NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
}
이전에 Udemy 다른 강의를 들을때 사용한적이 있었다. 간단하게 적어두었는데, 그때는 Docs를 제대로 활용해볼수있는 정도의 레벨은 아니었기에 이번엔 Apple Developer Docs – NSCompoundPredicate도 첨부한다.
- 🔗 NSCompoundPredicate 요약
NSCompoundPredicate
는 여러 개의 NSPredicate를 조합하여 하나의 논리식으로 평가할 수 있게 해주는 클래스이다.- 논리 연산자
AND
,OR
,NOT
을 통해 복잡한 조건을 만들 수 있다.
ex)
1
2
3
NSCompoundPredicate(andPredicateWithSubpredicates: [NSPredicate])
NSCompoundPredicate(orPredicateWithSubpredicates: [NSPredicate])
NSCompoundPredicate(notPredicateWithSubpredicate: NSPredicate)
NSPredicate 경우 연산식이 필요한데, 이번도 링크를 걸어둔다. nspredicate cheatsheet로 구글링하면 많이 나온다.
여기서의
name contains[c] %@
의미는? 대소문자를 구분 안하고 단어가 포함된걸 가져온다는 뜻
이후 onchange
Modifier를 통해 searchtext에 변화가 있을때 (즉, 유저가 검색을 시도할때) 해당 predicate 를 적용하여 결과가 반영하게 만든다.
1
2
3
.onChange(of: searchText) {
pokedex.nsPredicate = dynamicPredicate
}
실행해보면 반영이 되는걸 알 수 있다.
이제 favorite 기능을 넣어보자
이건 심플하다
@State private var filterByFavorite = false
변수를 만들어주고
1
2
3
4
5
6
7
8
9
10
11
12
private var dynamicPredicate: NSPredicate {
var predicates: [NSPredicate] = []
// 생략
// Filter by favorite predicate
if filterByFavorite {
predicates.append(NSPredicate(format: "favorite == %d", true))
}
// Combine predicates
return NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
}
그리고 ui에도 약간의 변화를 준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
VStack(alignment: .leading) {
HStack { // new
Text(pokemon.name!.capitalized)
.fontWeight(.bold)
if pokemon.favorite { // new
Image(systemName: "star.fill")
.foregroundStyle(.yellow)
}
}
HStack {
// 생략
}
}
그리고 onchange Modifier도 하나 더 추가해준다.
1
2
3
4
5
6
.onChange(of: searchText) {
pokedex.nsPredicate = dynamicPredicate
}
.onChange(of: filterByFavorite, { // new
pokedex.nsPredicate = dynamicPredicate
})
그리고 ToolBar의 버튼도 추가해주자
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button { // modified
filterByFavorite.toggle()
} label: {
Label("Filter By Favorites", systemImage: filterByFavorite ? "star.fill" : "star")
}
.tint(.yellow)
}
ToolbarItem {
Button("Add Item", systemImage: "plus") {
getPokemon()
}
}
}
삼항연산자를 사용하여 눌렀을때와 아닐때의 아이콘 이미지의 차이를 주었다.
그리고 지금은 Favorite를 직접 선정할수는 없어서
getPokemon 함수를 조금 수정한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private func getPokemon() {
Task {
for id in 1..<152 {
do {
let fetchedPokemon = try await fetcher.fetchPokemon(id)
// 생략
if pokemon.id % 2 == 0 {
pokemon.favorite = true
}
try viewContext.save()
} catch {
print(error)
}
}
}
}
여기에 id가 짝수인것만 favorite가 true이게 설정을 해두었다. (기능 확인은 해야하니…)
이제 실행해보면
잘된다. 그리고 id 만 짝수인 포켓몬 이름 옆에 ⭐️이 있는걸 알 수 있다.