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")
}
}
}
이렇게 간단하게 만들어 주었다.
이렇게 만들어 졌다.
이때 TabBar쪽에 약간의 Background를 만들어주고 싶다면
1
2
3
4
Tab("Breaking Bad", systemImage: "tortoise") {
Text("Breaking Bad View")
.toolbarBackgroundVisibility(.visible, for: .tabBar)
}
이렇게 ` .toolbarBackgroundVisibility` Modifier를 사용해주면 된다.
확실히 다른걸 알 수 있다.
모델링
우선 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()
에서 샘플 데이터를 직접 디코딩한 이유Quote
와Char
는 앱 실행 시 필요한 초기 데이터를 담고 있음- 실제 네트워크 통신 없이도 Preview 및 UI 테스트가 가능하도록 하기 위함
- 특히
Char
는 내부적으로sampledeath.json
까지 포함되기 때문에
death 정보까지 갖춘 완성된 샘플 데이터를 구성할 수 있음
→ death 정보는 아래에서init
을 통해 가져올 예정 - 따라서 ViewModel의 초기화 시점에서
samplequote.json
,samplecharacter.json
을 직접 디코딩하여
View가 사용할 수 있는 모의 데이터(mock data)를 세팅한 것이다
그리고 Char.swift로 가서도 init을 해준다.
이때
첫번째를 선택하여 자동완성을 해주자.
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을 해준다.
- ☠️
death
를Char
의init(from:)
에서 수동으로 초기화한 이유Char
모델은let death: Death?
옵셔널 프로퍼티를 포함하고 있음
→ 즉, 죽은 캐릭터는 death 정보가 있고, 살아있는 캐릭터는nil
- 하지만
samplecharacter.json
에는death
에 대한 정보가 없음
→ decoder는 기본적으로 JSON에 없는 키는nil
로 처리함 - 문제: death 정보를 Preview나 샘플 데이터에서 보여주고 싶은 경우,
별도로sampledeath.json
을 디코딩해서 Char의death
에 수동으로 넣어야 함 - 그래서
Char
의init(from:)
내부에서 death를
sampledeath.json
에서 디코딩하여 수동으로 설정한 것
📌 정리:
- 실제 앱 동작에서는 네트워크에서 가져온 데이터를 조합
- 샘플 및 프리뷰용에서는 death 데이터를 따로 미리 불러와
character 인스턴스 생성 시 함께 초기화해 보여주기 위해 init에서 처리함