Dex (5)
앱 실행 시 데이터 자동 로드
지금까지는 사용자가 + 버튼을 눌러야만 1번부터 151번까지의 포켓몬 데이터를 가져올 수 있었다.
이제는 앱 실행 시점에 자동으로 데이터를 불러오도록 해보자.
UIKit을 사용해봤다면, 이 시점에서 자연스럽게 ViewController 생명주기를 떠올렸을 것이다. 대부분은 viewDidLoad
에서 데이터를 불러오는 식으로 처리했을 것이다.
이후의 작업 흐름에 따라
viewWillAppear
,viewDidAppear
등 적절한 생명주기를 선택해 사용했을 것이다.
출처: Medium
하지만 우리는 SwiftUI를 사용 중이다.
SwiftUI에서는 viewDidLoad
같은 생명주기가 없기 때문에, 그에 해당하는 역할을 task
modifier가 대신해준다.
예시:
1
2
3
task {
getPokemon()
}
이렇게 하면 뷰가 나타날 때 자동으로 getPokemon()
이 실행된다.
task
는 SwiftUI View의 수명주기 중 등장 시점에 실행되며,- 비동기 작업(
async/await
) 도 자연스럽게 처리할 수 있다.
비동기 흐름과 관련해서는 이전에 다룬 Async/Await 글 시리즈를 참고하면 좋다.
마지막으로, 이제 더 이상 수동으로 데이터를 불러올 일이 없기 때문에
+ 버튼은 삭제해도 된다.
ContentUnavailableView를 활용한 예외 처리 및 중단 로딩 개선
SwiftUI에서는 데이터가 비어 있거나, 네트워크 요청이 중간에 실패했을 때 적절한 UI 안내를 제공하는 것이 중요하다. 이럴 때 유용하게 사용할 수 있는 것이 ContentUnavailableView
이다.
1. 초기 상태 (pokedex가 비어 있을 때)
pokedex 배열이 비어 있는 경우, 유저에게 “포켓몬이 없음”을 안내하고 데이터를 불러오는 액션을 제공한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var body: some View {
if pokedex.isEmpty {
ContentUnavailableView {
Label("No Pokemon", image: .nopokemon)
} description: {
Text("There aren't any Pokemon yet.\nFetch some Pokemon to get started!")
} actions: {
Button("Fetch Pokemon", systemImage: "antenna.radiowaves.left.and.right") {
getPokemon(from: 1)
}
.buttonStyle(.borderedProminent)
}
} else {
NavigationStack {
// 생략
}
}
}
ContentUnavailableView
는pokedex.isEmpty
조건에서만 표시되며, 사용자에게 명확한 피드백과 동작 유도를 제공한다.
2. 중단된 로딩 처리 (리스트 footer 활용)
네트워크 오류 등으로 인해 모든 데이터를 불러오지 못한 경우, 리스트 하단에 추가 안내를 띄워 재시도를 유도한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
NavigationStack {
List {
Section {
ForEach(pokedex) { pokemon in
// 생략
}
} footer: {
if pokedex.count < 151 {
ContentUnavailableView {
Label("Missing Pokemon", image: .nopokemon)
} description: {
Text("The fetch was interrupted!\nFetch the rest of the Pokemon")
} actions: {
Button("Fetch Pokemon", systemImage: "antenna.radiowaves.left.and.right") {
getPokemon(from: pokedex.count + 1)
}
.buttonStyle(.borderedProminent)
}
}
}
}
}
footer
를 활용하면 스크롤을 내렸을 때 중단 로딩 여부를 바로 확인하고, 다시 불러오도록 유도할 수 있다.
3. 중단된 지점부터 이어받기 위한 함수 개선
모든 데이터를 다시 불러오는 것이 아니라, 현재까지 로드된 포켓몬의 다음 ID부터 시작하도록 함수를 수정한다.
1
2
3
4
5
6
7
8
9
10
private func getPokemon(from id: Int) {
Task {
for i in id..<152 {
do {
let fetchedPokemon = try await fetcher.fetchPokemon(i)
// 생략
}
}
}
}
이 방식은 네트워크나 앱 상태 등으로 인해 중단된 상황에서도 이어서 데이터를 받아올 수 있는 구조로, 사용자 경험을 크게 개선할 수 있다.
✅ 요약
ContentUnavailableView
는 데이터 없음 또는 예외 상황에 적절한 피드백과 액션을 제공하는 SwiftUI의 도구이다.- 조건부로 보여주면
if pokedex.isEmpty
또는footer
위치에서 유저에게 필요한 정보를 안내할 수 있다. getPokemon(from:)
과 같이 중단 지점부터 이어받는 로직을 구현하면 네트워크 장애 대응에 효과적이다.
Swipe Action과 ContentUnavailableView 이슈 해결
이건 여러 이전글에서 언급을 했지만, 가장 최근에 작성한 글 링크를 걸어둔다.
NavigationLink 뒤에 swipeActions
modifier를 추가해 즐겨찾기 기능을 구현했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
NavigationLink(value: pokemon) {
// 생략
}
.swipeActions(edge: .leading) {
Button(pokemon.favorite ? "Remove from Favorites" : "Add to Favorites", systemImage: "star") {
pokemon.favorite.toggle()
do {
try viewContext.save()
} catch {
print(error)
}
}
.tint(pokemon.favorite ? .gray : .yellow)
}
작동은 잘 된다.
⚠️ 문제점
1. 필터 후에도 ContentUnavailableView가 계속 표시됨
필터링을 통해 즐겨찾기 포켓몬만 보이게 했지만, 하단에는 여전히 “완전히 로드되지 않음”을 알리는 ContentUnavailableView
가 나타난다.
2. 즐겨찾기가 아무것도 없을 때, ContentUnavailableView가 잘못 노출됨
즐겨찾기된 포켓몬이 하나도 없는 상태에서 필터를 적용하면, 리스트 대신 ContentUnavailableView
만 보이고 버튼을 눌러도 아무 반응이 없다.
⛏ UI 계층도 확인해보았지만…
Hierarchy를 보아도 ContentUnavailableView
외에 다른 리스트 항목은 렌더되지 않았다. 혹시나 뒤에 가려진 게 아닐까 했지만, 그렇지 않다.
🔍 하지만 콘솔 로그를 보면…
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
→ 데이터는 정상적으로 Core Data에 저장되었다.
❗️버튼이 작동하지 않은 것처럼 보인 이유
SwiftUI의
@FetchRequest
는 Core Data의 변화 전체에 반응하는 것이 아니라,
현재 설정된predicate
조건에 부합하는 데이터 변화에만 반응한다.
@FetchRequest
의 predicate는 상태 변화에 자동으로 반응하지 않음- 즐겨찾기 버튼을 눌러
pokemon.favorite
를 변경해도, .onChange(of: filterByFavorite)
나.onChange(of: searchText)
는 실행되지 않음- 따라서
@FetchRequest
에 적용된 predicate 결과가 다시 평가되지 않음 - 그 결과로
pokedex
배열은 여전히 비어 있는 상태로 남아 있고,
ContentUnavailableView
는 계속 노출됨
- 즐겨찾기 버튼을 눌러
- 사용자 입장에서는 버튼이 “작동안한 것처럼” 보임
- 내부적으로는
toggle()
과viewContext.save()
가 실행되었지만, - predicate가 다시 평가되지 않아 UI에 변화가 없음
- SwiftUI의 조건 분기 및
@FetchRequest
의 자동 갱신 한계 때문에 발생
- 내부적으로는
✅ 요약
버튼은 실제로 작동했지만,
@FetchRequest
는 predicate와 일치하는 데이터의 변화에만 반응하기 때문에
UI가 갱신되지 않았고, 사용자 입장에서는 작동하지 않은 것처럼 보였다.
💡 원인 분석
이 문제는 조건 분기에 사용된 pokedex.count
가 필터링 결과에 따라 달라지기 때문이며, 이는 ContentUnavailableView
가 원본 데이터가 모두 로딩되지 않았다고 잘못 판단하게 만든다.
이전 JPApexPredators 프로젝트에서도 하나의 상태값만으로 정렬 및 필터를 모두 처리하려다 비슷한 문제를 겪은 바 있다.
🛠 해결책: 전체 데이터를 담는 별도 배열 사용
전체 포켓몬 데이터를 항상 담고 있는 별도 배열을 @FetchRequest
로 만들어준다:
@FetchRequest<Pokemon>(sortDescriptors: []) private var allPokedex
이 배열은 predicate 없이 전체 포켓몬을 유지하며, 조건 분기에서 활용할 수 있다.
그 다음, 뷰의 조건을 다음과 같이 수정한다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var body: some View {
if allPokedex.isEmpty { // pokedex → allPokedex 로 수정 (문제 2 해결)
// ContentUnavailableView
} else {
NavigationStack {
List {
Section {
// 필터링된 pokedex 출력
} footer: {
if allPokedex.count < 151 { // pokedex → allPokedex 로 수정 (문제 1 해결)
// ContentUnavailableView
}
}
}
}
}
}
이제 작동이 잘 되는 것을 확인할 수 있다.
✅ 정리
- 필터링된 배열(
pokedex
)만 사용할 경우, 전체 데이터의 상태 판단에 오류가 발생할 수 있다. - 이럴 때는 전체 데이터를 추적할 수 있는 배열(
allPokedex
)을 별도로 만들어 조건 분기에 활용해야 한다. - ContentUnavailableView, 로딩 상태, 버튼 비활성화 여부 판단 모두에서 이 구조가 안정성을 확보한다.
Sort & Filter가 함께 쓰일 때는 전체 값을 담는 배열과, 필터링 결과를 담는 배열을 분리해서 사용하는 것이 중요하다.
이번글은 내가 글을 작성한걸 바탕으로 gpt가 매무새만 다듬어보는 방식으로 모든걸 바꿔서 작성해보았다.
약간 내스타일하고 조금 다르지만 나름 괜찮을지도..?