포스트

BookStore (1)

UI 구성

CleanShot 2024-11-25 at 16 23 40

이렇게 구성을 한다.

좌측 부터 1, 3, 2 으로 하여 UI 구성을 해본다.

1. MainView 구성

첫번째 화면이고 SearchBar, Grid, list가 UIComponent의 Point이다.

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
NavigationStack {
            VStack {
                VStack(alignment: .leading) {
                    Text("최근 본 책")
                        .font(.system(size: 25))
                        .fontWeight(.bold)
                    HStack {
                        ScrollView {
//                            LazyHGrid(rows: [GridItem]) {
//                                
//                            }
                        }
                    }
                    .frame(height: UIScreen.main.bounds.width * 0.35)
                }
                .padding(.horizontal, 20)
                VStack(alignment: .leading) {
                    Text("검색 결과")
                        .font(.system(size: 25))
                        .fontWeight(.bold)
                    List {

                    }
                }
                .padding(.horizontal, 20)
            }
        }

우선은 이렇게 구상을 한다.

실행화면은 다음과 같다.

simulator_screenshot_FD77F42D-B0B0-4426-8191-47C77CA6FD64

이때 Searchable의 경우 그냥 쓰게되면 보이지 않았다.

그래서 NavigationStack을 사용하였다.

1. Int Extension

1
2
3
4
5
6
7
8
extension Int {
    func toString() -> String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .currency
        formatter.locale = Locale(identifier: "ko_KR")
        return formatter.string(from: self as NSNumber) ?? String(describing: self)
    }
}

CleanShot 2024-11-25 at 20 05 46

이렇게 가격에 ,가 자동으로 붙고 앞에 ₩가 붙는다.

2. APIRequest 구현

1. Modeling

KAKAO_Docs를 참고 하여 모델링을 한다 아래 사진은 모델링 부분에 대한 내용

CleanShot 2024-11-25 at 18 21 38

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
struct BookModel: Codable {
    let meta: Meta
    let documents: [Document]
}

// MARK: - Document
struct Document: Codable {
    let authors: [String]
    let contents: String
    let price: Int
    let publisher: String
    let status: String
    let thumbnail: String
    let title: String
    let url: String

    enum CodingKeys: String, CodingKey {
        case authors, contents, price, publisher
        case status, thumbnail, title, url
    }
}

// MARK: - Meta
struct Meta: Codable {
    let isEnd: Bool
    let pageableCount, totalCount: Int

    enum CodingKeys: String, CodingKey {
        case isEnd = "is_end"
        case pageableCount = "pageable_count"
        case totalCount = "total_count"
    }
}

2. APIService

특이점이라면 이번엔 Header / Query가 필요하다. 이건 예전에 했던것 처럼 Header와 Query가 필요한 파트라 오래간만에 해서 가물가물하여 이전에 작성한 글을 참고하였다. 그리면서 Generic을 추가해주었다.

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
class APIRequestService {
    
    enum NetworkError: Error {
        case badUrl
        case invalidRequest
        case badResponse
        case badStatus
        case failedToDecodeResponse
    }
    
    func requestAPI<T: Codable> (searchText: String) async -> T? {
        
        let urlString = "https://dapi.kakao.com/v3/search/book?target=title"
        let headers = ["Authorization" : "KakaoAK \(Secret().apiKey)"]
        var urlComponent = URLComponents(string: urlString)
        urlComponent?.queryItems?.append(URLQueryItem(name: "query", value: searchText))
        guard let url = urlComponent?.url else { return nil }
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        request.allHTTPHeaderFields = headers
        
        do {
            let (data, response) = try await URLSession.shared.data(for: request)
            guard let response = response as? HTTPURLResponse, response.statusCode == 200 else {
                throw NetworkError.badResponse
            }
            guard response.statusCode >= 200 && response.statusCode < 300 else {
                throw NetworkError.badStatus
            }
            guard let decodedData = try? JSONDecoder().decode(T.self, from: data) else {
                throw NetworkError.failedToDecodeResponse
            }
            return decodedData
        } catch NetworkError.badUrl {
            print("There was an error creating the URL")
        } catch NetworkError.badResponse {
            print("Did not get a valid response")
        } catch NetworkError.badStatus {
            print("Did not get a 2xx status code from the response")
        } catch NetworkError.failedToDecodeResponse {
            print("Failed to decode response into the given type")
        } catch {
            print("An error occured downloading the data")
        }
        return nil
    }
    
}

3. ViewModel

1
2
3
4
5
6
7
8
9
10
@MainActor
class APIViewModel: ObservableObject {
    @Published var books: [BookModel] = []
    
    func request(searchText: String) async {
        guard let requestedData: BookModel = await APIRequestService().requestAPI(searchText: searchText) else { return }
        print(requestedData)
        books = [requestedData]
    }
}

이렇게 작성을 했다.

print를 넣은건 제대로 되는지 확인하기 위함.

수정.

1
2
3
4
5
6
7
8
9
10
@MainActor
class APIViewModel: ObservableObject {
    @Published var books: [Document] = []
    
    func request(searchText: String) async {
        guard let requestedData: BookModel = await APIRequestService().requestAPI(searchText: searchText) else { return }
        print(requestedData.documents)
        books = requestedData.documents
    }
}

모델 전체를 가져오는건 나중에할 무한스크롤에서 필요한기능이고 List에 필요한건 Document라서 이렇게 바꿔준다.

4. Test

MainView에 onAppear Modifier를 통하여 호출을 해본다.

1
2
3
4
5
6
.onAppear {
            Task {
                await apiViewModel.request(searchText: "미움받을 용기")
            }
            
        }

이렇게 하여 작동이 되는지 확인을 해본다.

1
2
BookModel(meta: BookStore.Meta(isEnd: false, pageableCount: 22, totalCount: 22), documents: [BookStore.Document(authors: ["기시미 이치로", "고가 후미타케"], contents: "부정하며, 자유도 행복도 모두 ‘용기’의 문제일 뿐 환경이나 능력의 문제가 아님을 보여준 알프레드 아들러(Alfred Adler)다.
생략...

작동이 잘되는것 확인

5. 문제점

하지만 여기 문제점이 있는데 List는 Identifiable 프로토콜을 따른다. 현재 모델링에는 해당 프로토콜이 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Document: Codable, Identifiable {
    let id = UUID() // new
    let authors: [String]
    let contents: String
    let price: Int
    let publisher: String
    let status: String
    let thumbnail: String
    let title: String
    let url: String

    enum CodingKeys: String, CodingKey {
        case authors, contents, price, publisher
        case status, thumbnail, title, url
    }
}

이때 주의할점은 id: UUID 를 하게되면 Decoding 에러가 난다.

해당 부분은 api 결과에 id가 있다고 판단하기 때문.

그래서 반드시 let id = UUID()를 해줘야 한다.

6. 호출하기

처음에 searchbar에 대한 내용을 어떻게 적용을 해야할지 생각이 나질 않았다.

그래서 검색을 해보다가 YouTube를 참고했다.

1
2
3
4
5
6
7
8
9
 .onChange(of: searchText) { _, _ in
        Task {
            if !searchText.isEmpty && searchText.count > 2 {
                await  apiViewModel.request(searchText: searchText)
            } else {
                apiViewModel.books.removeAll()
            }
        }
    }

검색해보니 onSubmit은 엔터를 치거나 돋보기 버튼을 눌렀을때 작동한다.

1
2
3
4
5
.onSubmit(of: .search) {
            Task {
                await  apiViewModel.request(searchText: searchText)
            }
}

3. ListCell

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@State var title: String = "title"
@State var author: String = "author"
@State var price: Int = 0

var body: some View {
    HStack {
        Text(title)
            .font(.system(size: 13))
            .lineLimit(0)
        Spacer()
        Text(author)
            .font(.system(size: 8))
        Spacer()
        Text(price.toString())
            .font(.system(size: 10))
    }
}

설명은 패스.

4. 실행결과

Nov-25-2024 20-12-52

우선 이렇게 했다.

보완할 점이라면, x를 눌렀을때 검색결과를 날리는것? 그정도가 될듯하다.

내일은 아마도 NavigatoinLink 사용하여 클릭했을때 상세페이지 나오기,

SwiftData 사용하여 담기 기능을 해보지 않을까 싶다.

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