포스트

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에 대해 코드 서술한 적이 없으므로 이번에는 코드를 전부 적었다.

사실 크게 언급할만한게 없어보인다.

Image

실행하면 위와 같고

또한 콘솔에

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")
  }
}

딱히 언급할만한건 없어보인다.

Image

실행하면 이렇게 된다.

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적으로는

Image

이렇게 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는 아래 사진과 같은 타입이 되었다.

Image

물론 위에 올려보면 알겠지만 해당 부분을 수정하기 전에도

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
항목설명
@FetchRequestSwiftUI 뷰에서 Core Data 객체를 가져올 때 사용하는 property wrapper
<Pokemon>조회 대상 Core Data Entity 타입 지정
sortDescriptors어떤 속성을 기준으로 정렬할지 지정 (.id 기준 오름차순)
animation데이터 변경 시 SwiftUI에서 적용할 애니메이션
pokedexFetchedResults 타입의 프로퍼티로, List나 ForEach에 사용됨

  • 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
}

Image

실행해보면 반영이 되는걸 알 수 있다.


이제 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이게 설정을 해두었다. (기능 확인은 해야하니…)

이제 실행해보면

Image

잘된다. 그리고 id 만 짝수인 포켓몬 이름 옆에 ⭐️이 있는걸 알 수 있다.

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