포스트

BB Quotes (1)

이번 내용도 아는건 최대한 간략하게 하고 과정을 적어본다.

TabView 사용하기

이번엔 새로운 프로젝트 앱을 만들면서 TabView를 사용해본다.

이전에 많이 사용해봐서 뭐 딱히 적을만한건 없어보이긴한다.

1
2
3
4
5
6
7
8
9
10
11
var body: some View {
    TabView {
        Tab("Breaking Bad", systemImage: "tortoise") {
            Text("Breaking Bad View")
        }
        
        Tab("Better Call Saul", systemImage: "briefcase") {
            Text("Better Call Saul View")
        }
    }
}

이렇게 간단하게 만들어 주었다.

Image

이렇게 만들어 졌다.

이때 TabBar쪽에 약간의 Background를 만들어주고 싶다면

1
2
3
4
Tab("Breaking Bad", systemImage: "tortoise") {
    Text("Breaking Bad View")
        .toolbarBackgroundVisibility(.visible, for: .tabBar)
}

이렇게 ` .toolbarBackgroundVisibility` Modifier를 사용해주면 된다.

  • Modifier ❌ Image
  • Modifier ⭕️ Image

확실히 다른걸 알 수 있다.

모델링

우선 json sample 파일들을 바탕으로 모델링을 한다.

이부분은 코드만 서술하는걸로…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct Char: Decodable {
    let name: String
    let birthday: String
    let occupations: [String]
    let images: [URL]
    let aliases: [String]
    let status: String
    let portrayedBy: String
    var death: Death?
}

struct Quote: Decodable {
    let quote: String
    let character: String
}

struct Death: Decodable {
    let character: String
    let image: URL
    let details: String
    let lastWords: String
}

준비 완료.

Concurrency

이건 전에 Async/Await 하면서 정리를 했었기에 그걸 다시보며 리마인드를 하는게 좋다.

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
65
66
67
68
69
struct FetchService {
    enum FetchError: Error {
        case badResponse
    }
    
    let baseURL = URL(string: "https://breaking-bad-api-six.vercel.app/api")!
    
    func fetchQuote(from show: String) async throws -> Quote {
        // Build fetch url
        let quoteURL = baseURL.appending(path: "quotes/random")
        let fetchURL = quoteURL.appending(queryItems: [URLQueryItem(name: "production", value: show)])
        
        // Fetch data
        let (data, response) = try await URLSession.shared.data(from: fetchURL)
        
        // Handle response
        guard let response = response as? HTTPURLResponse, response.statusCode == 200 else {
            throw FetchError.badResponse
        }
        
        // Decode dta
        let quote = try JSONDecoder().decode(Quote.self, from: data)
        
        // Return quote
        return quote
    }
    
    func fetchCharacter(_ name: String) async throws -> Char {
        let characterURL = baseURL.appending(path: "characters")
        let fetchURL = characterURL.appending(queryItems: [URLQueryItem(name: "name", value: name)])
        
        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 characters = try decoder.decode([Char].self, from: data)
        
        return characters[0]
    }
    
    func fetchDeath(for character: String) async throws -> Death? {
        let fetchURL = baseURL.appending(path: "deaths")
        
        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 deaths = try decoder.decode([Death].self, from: data)
        
        for death in deaths {
            if death.character == character {
                return death
            }
        }
        
        return nil
    }
    
}

위와 같이 fetch 기본 코드를 작성해준다.

이때 낯이 익은 코드 전개가 있는데 바로 이전글에서 Generic을 사용하면서 Fetch Code를 작성했던게 떠올랐다. 이전글을 참고하면서 리마인드하면 좋다.

View Model 만들기

MVVM Pattern에서 사용되는 방식

코드를 작성하면서

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Observable
@MainActor
class ViewModel {
    enum FetchStatus {
        case notStarted
        case fetching
        case success
        case failed(error: Error)
    }
    
    private(set) var status: FetchStatus = .notStarted
    
    private let fetcher = FetchService()
    
    var quote: Quote
    var character: Char
    
    init() {
        
    }
}

여기까지 코드가 작성 되었는데, 더 코드를 추가하기 전에 짚고 넘어갈게 있어서 적어본다.

우선 @Observable, @MainActor 이 2개는 이전에 언급을 한적이 있다. 참고하면 좋을 듯

그리고 여기서 우리가 잘 사용하지 않았던 private(set)이 나온다.


🔒 private(set) 접근 제어자

private(set)은 Swift에서 외부에서는 읽기만 가능하고, 내부에서는 읽고 쓸 수 있는 속성을 만들 때 사용된다.

✅ 기본 개념

  • set을 private으로 제한하지만
  • get은 public으로 허용된다.

즉, 외부에서는 값을 읽을 수는 있지만 수정할 수는 없다.
반면, 선언된 내부(struct, class 등)에서는 읽고 쓸 수 있다.


💡 사용 예시

1
2
3
4
5
@Observable  
@MainActor  
class ViewModel {  
  private(set) var fetchStatus: FetchStatus = .notStarted  
}
  • 외부 뷰는 fetchStatus 값을 읽을 수는 있지만
  • 직접 변경(set)은 할 수 없음
  • 값 변경은 ViewModel 내부에서만 가능

🔍 왜 사용하나?

  • 보안: 외부에서 중요한 속성을 무분별하게 변경하지 못하도록 보호
  • 예측 가능성: 상태(state) 변경을 ViewModel 내부에서만 관리하도록 제한하여 버그를 줄임

📌 요약

접근 제어자외부에서 읽기외부에서 쓰기내부에서 읽기/쓰기
private
public
private(set)

따라서, private(set)상태를 외부에 노출하되 변경은 막고 싶을 때 사용하는 효과적인 방법이다.


SampleData 가져오기

그리고 class type에서는 Initializer가 없기에

1
2
var quote: Quote
var character: Char

이렇게 사용할 수가 없다.

그래서 init() {} 통해 초기화를 해준다.

1
2
3
4
5
6
7
8
9
10
init() {
    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase
    
    let quoteData = try! Data(contentsOf: Bundle.main.url(forResource: "samplequote", withExtension: "json")!)
    quote = try! decoder.decode(Quote.self, from: quoteData)
    
    let characterData = try! Data(contentsOf: Bundle.main.url(forResource: "samplecharacter", withExtension: "json")!)
    character = try! decoder.decode(Char.self, from: characterData)
}
  • 🔍 ViewModel의 init()에서 샘플 데이터를 직접 디코딩한 이유
    • QuoteChar는 앱 실행 시 필요한 초기 데이터를 담고 있음
    • 실제 네트워크 통신 없이도 Preview 및 UI 테스트가 가능하도록 하기 위함
    • 특히 Char는 내부적으로 sampledeath.json까지 포함되기 때문에
      death 정보까지 갖춘 완성된 샘플 데이터를 구성할 수 있음
      → death 정보는 아래에서 init 을 통해 가져올 예정
    • 따라서 ViewModel의 초기화 시점에서
      samplequote.json, samplecharacter.json을 직접 디코딩하여
      View가 사용할 수 있는 모의 데이터(mock data)를 세팅한 것이다

그리고 Char.swift로 가서도 init을 해준다.

이때

Image

첫번째를 선택하여 자동완성을 해주자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Char: Decodable {
    // 생략
    
    enum CodingKeys: CodingKey {
        // 생략
    }
    
    init(from decoder: any Decoder) throws {
        // 생략
        let deathDecoder = JSONDecoder()
        deathDecoder.keyDecodingStrategy = .convertFromSnakeCase

        let deathData = try Data(contentsOf: Bundle.main.url(forResource: "sampledeath", withExtension: "json")!)
        death = try deathDecoder.decode(Death.self, from: deathData)
    }
}

이때 init을 통해 만들어진 death 관련 코드는 지우고 이렇게 직접 initializing을 해준다.

  • ☠️ deathCharinit(from:)에서 수동으로 초기화한 이유
    • Char 모델은 let death: Death? 옵셔널 프로퍼티를 포함하고 있음
      → 즉, 죽은 캐릭터는 death 정보가 있고, 살아있는 캐릭터는 nil
    • 하지만 samplecharacter.json에는 death에 대한 정보가 없음
      → decoder는 기본적으로 JSON에 없는 키는 nil로 처리함
    • 문제: death 정보를 Preview나 샘플 데이터에서 보여주고 싶은 경우,
      별도로 sampledeath.json을 디코딩해서 Char의 death에 수동으로 넣어야 함
    • 그래서 Charinit(from:) 내부에서 death를
      sampledeath.json에서 디코딩하여 수동으로 설정한 것

📌 정리:

  • 실제 앱 동작에서는 네트워크에서 가져온 데이터를 조합
  • 샘플 및 프리뷰용에서는 death 데이터를 따로 미리 불러와
    character 인스턴스 생성 시 함께 초기화해 보여주기 위해 init에서 처리함
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.