포스트

Dex (6)

PokemonDetailView

이제는 DetailView를 만들어 본다.

Image

디자인은 이렇게 할 예정

Preview용 SampleData 생성

그전에 샘플 데이터를 먼저 만들어줄것이다.

1
2
3
4
5
6
7
8
static var previewPokemon: Pokemon {
        let context = PersistenceController.preview.container.viewContext
}

@MainActor
static let preview: PersistenceController = {
    // 생략
}

이렇게 코드를 작성하자마자 발생하는 에러

Image

@MainActor로 선언된 preview는 MainActor context 내에서만 접근 가능하다. 즉, 문제의 원인은 @MainActor로 선언된 static 프로퍼티나 메서드는 명시적으로 MainActor 컨텍스트 내에서만 접근할 수 있는데, 그걸 MainActor 외부에서 사용했기 때문, 그렇기에 @MainActor를 지워주면 된다.

그리고 코드를 완성시키자.

1
2
3
4
5
6
7
8
9
static var previewPokemon: Pokemon {
    let context = PersistenceController.preview.container.viewContext
    
    let fetchRequest: NSFetchRequest<Pokemon> = Pokemon.fetchRequest()
    fetchRequest.fetchLimit = 1
    
    let results = try! context.fetch(fetchRequest)
    return results.first!
}

Environmentobject 사용한 pokemon 데이터 공유

이전에 이것을 사용해서 코드를 작성해본 경험이 있기에 어렵지는 않다.

이전글은 여기에

1
2
3
4
5
6
// ContentView

.navigationDestination(for: Pokemon.self) { pokemon in
    PokemonDetailView() // Changed
        .environmentObject(pokemon)
}

이때 넘겨주는 포켓몬 인스턴스는 뷰 계층 전체에서 공유 가능한 전역 상태처럼 작동하므로, 해당 뷰와 그 하위 뷰에서 @EnvironmentObject로 접근 가능하다.

UIKit 시절이라면 아마 didSelectRowAt에서 indexPath를 기준으로 데이터를 꺼내고,
NavigationController.pushViewControllerpresent를 통해 화면을 전환하면서 해당 데이터를 전달했을 것이다.

SwiftUI에서는 이 과정이 훨씬 간단하다. environmentObject를 통해 데이터를 넘기고, NavigationStack을 사용해 “어떤 데이터가 선택되면 어떤 화면으로 이동할지”를 코드로 미리 구성해둘 수 있다. UIKit처럼 화면 전환을 직접 명령하지 않아도 된다.

다시 본론으로 돌아와서 EnvironmentObject를 전달 받는 DetailView에서는

1
2
3
4
5
6
7
8
9
10
11
12
struct PokemonDetailView: View {
    // 생략
    @EnvironmentObject private var pokemon: Pokemon
    // 생략
}

#Preview {
    NavigationStack {
        PokemonDetailView()
            .environmentObject(PersistenceController.previewPokemon)
    }
}

이런식으로 만들어주자.

여기서 Preview는 NavigationStack으로 한번 감싸준 이유는 NavigationLink를 통해 넘어오기에 아래 디자인 쪽에 navigationTitle Modifier를 사용했는데, preview에서는 보이지 않기에 NavigationStack을 사용.

그리고 environmentObject의 경우엔 위에서 만든 샘플을 적용

UI Design

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
64
struct PokemonDetailView: View {
    @Environment(\.managedObjectContext) private var viewContext
    
    @EnvironmentObject private var pokemon: Pokemon
    
    @State private var showShiny = false
    
    var body: some View {
        ScrollView {
            ZStack {
                Image(.normalgrasselectricpoisonfairy)
                    .resizable()
                    .scaledToFit()
                    .shadow(color: .black, radius: 6)
                
                AsyncImage(url: pokemon.sprite) { image in
                    image
                        .interpolation(.none)
                        .resizable()
                        .scaledToFit()
                        .padding(.top, 50)
                        .shadow(color: .black, radius: 6)
                } placeholder: {
                    ProgressView()
                }

            }
            
            HStack {
                ForEach(pokemon.types!, id: \.self) { type in
                    Text(type.capitalized)
                        .font(.title2)
                        .fontWeight(.semibold)
                        .foregroundStyle(.black)
                        .shadow(color: .white, radius: 1)
                        .padding(.vertical, 7)
                        .padding(.horizontal)
                        .background(Color(type.capitalized))
                        .clipShape(.capsule)
                }
                
                Spacer()
                
                Button {
                    pokemon.favorite.toggle()
                    
                    do {
                        try viewContext.save()
                    } catch {
                        print(error)
                    }

                } label: {
                    Image(systemName: pokemon.favorite ? "star.fill" : "star")
                        .font(.largeTitle)
                        .tint(.yellow)
                }
            }
            .padding()
            
        }
        .navigationTitle(pokemon.name!.capitalized)
    }
}

interpolation말고는 크게 언급할만한 내용은 없어보인다.

Docs

interpolation(.none)은 확대 시 흐릿하게 보이지 않도록 픽셀을 그대로 보여주는 옵션으로, 포켓몬 스프라이트처럼 도트 기반 이미지에 적합하다.

현재까지 완성된 UI는 다음과 같다.

Image

Pokemon Extension

Core Data 모델(Pokemon)에는 저장 속성만 정의할 수 있기 때문에, 계산된 속성이나 UI를 위한 로직은 따로 확장해서 구현해주어야 한다. Swift의 extension 기능을 이용하면 기존 모델에 새로운 계산 프로퍼티를 추가할 수 있어, View 쪽에서 더 간결하고 직관적인 방식으로 접근이 가능하다.

예를 들어, 포켓몬의 주 타입(types[0])에 따라 배경 이미지를 다르게 보여주고 싶을 때 아래와 같이 구현할 수 있다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
extension Pokemon {
    var background: ImageResource {
        switch types![0] {
        case "rock", "ground", "steel", "fighting", "ghost", "dark", "psychic":
            .rockgroundsteelfightingghostdarkpsychic
        case "fire", "dragon":
            .firedragon
        case "flying", "bug":
            .flyingbug
        case "ice":
            .ice
        case "water":
            .water
        default:
            .normalgrasselectricpoisonfairy
        }
    }
}

이렇게 extension을 통해 뷰에서 .background만 호출하면 간단히 배경 이미지를 불러올 수 있게 된다.

1
2
3
4
var body: some View {
        ScrollView {
            ZStack {
                Image(pokemon.background) // changed

그리고 실행을 해보면, 각 type에 맞게 배경이 다르게 표시되는걸 알 수 있다.

Image

이번 글에서는 포켓몬의 타입에 따라 배경 이미지가 바뀌도록 background 속성을 확장했다.

다음 글에서는 포켓몬 능력치 시각화를 위해 typeColor, stats, highestStat 속성을 확장하고
이를 활용한 Stat Chart UI를 Detail 화면에 구현해본다.

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