나의 발자취

[SwiftUI] OpenAPI 활용한 Book Finder App 만들기 본문

앱 개발/iOS

[SwiftUI] OpenAPI 활용한 Book Finder App 만들기

달모드 2024. 11. 8. 17:08

UIKit으로 BookFinderApp을 여러번 생성했었다.

2024.09.06 - [앱 개발/iOS] - KakaoAPI를 활용한 BookSearch 앱 만들기 (1)

2024.09.09 - [앱 개발/iOS] - KakaoAPI를 활용한 BookSearch 앱 만들기 (2)

2024.10.22 - [앱 개발/iOS] - [iOS] Codable - 인코딩 / 디코딩, KakaoAPI를 활용한 BookSearch 앱 만들기 응용

2024.10.28 - [앱 개발/iOS] - [iOS] BookSearch 앱 좌우 넘기기 화살표 func 하나로 일치, CoreLocation 라이브러리

 

이번 시간에는 SwiftUI로 만들어볼 것이다.

 


searchBar만들기

text type: Binding<String> 이므로, Binding객체를 만들어서 넘겨줘야한다.

 

에러를 없애주기 위해, Preview에서 아래와 같이 SearchBar() 안에 값을 넣어준다.


검색바 디자인

HStack {
            TextField("검색어를 입력하세요", text: $searchText).padding()
        }.background(Color(.systemGray6))
            .clipShape(.rect(cornerRadius: 18))
            .padding(.horizontal, 10)

.padding()

아무것도 넣어주지 않으면 (.padding() ) 전체 다 패딩이 생기므로, .horizontal / .leading / .trailing / .top / .bottom 으로 설정해준다.

 

 

@State isEditing

검색창이 클릭되었을 때에 취소 버튼이 나타났다가 사라지도록 애니메이션을 준다.

struct SearchBar: View {
    @Binding var searchText: String
    @State var isEditing: Bool = false
    
    var body: some View {
        HStack {
            TextField("검색어를 입력하세요", text: $searchText).padding()
        .background(Color(.systemGray6))
            .clipShape(.rect(cornerRadius: 18))
            .padding(.horizontal, 10)
            .onTapGesture {
                isEditing = true
            }.animation(.easeInOut, value: isEditing)
            if isEditing {
                Button {
                    isEditing = false
                } label: {
                    Text("Cancel")
                }
                .padding(.trailing, 15)
                .transition(.move(edge: .trailing))
            }
        }
    }
}

 


handler 클로저

엔터 버튼을 눌렀을 때 바로 넘어가도록 

하고

Preview 뒤에 후행클로저를 열어줘서 에러를 처리한다.

 


이제, 검색어를 넘겨받아서 API 통신으로 그 결과를 처리할것이다.

BookFinder.swift를 만든다. +Model.swift

프로토콜 ObservableObject를 사용하면 @Published를 사용할 수 있다.

 

import Foundation
import Alamofire

class BookFinder: ObservableObject {
    @Published var books: [Book] = []
    @Published var isEnd = true
    let apiKey = <apiKey>
    let endPoint = "https://dapi.kakao.com/v3/search/book"
    
    func search(query: String, at page: Int) {
        let headers: HTTPHeaders = ["Authorization": apiKey]
        let params: Parameters = ["query" : query, "page" : page]
        AF.request(endPoint, method: .get, parameters: params, headers: headers)
            .validate(statusCode: 200..<300)
            .responseDecodable(of: BookRoot.self) { response in
                switch response.result {
                case .success(let bookRoot):
                case .failure(let error):
                }
            }
    }
        
}

 

그리고, 나머지 코드 작성

 

 


BookList.swift (SwiftUI 파일 생성)

searchBar에서 엔터를 치면 넘어와서 책 리스트를 보여주게 되는 파일을 구현할 것이다. 따라서

1) SearchBar를 불러온다.

Binding해주는 애를 optional로 해주면 에러가 난다. 그렇기 때문에 빈 텍스트인 ""로 해주는 것이다.

 

2) BookFinder()로부터 객체를 받아와서 bookFinder 안의 search 함수를 넣어준다. (@StateObject)

3) bookFinder.search함수는 page 인자가 필요하므로, 기본값을 넣어준다 (@StateObject)

 


BookRow.swift (SwiftUI 파일 생성)

tableView의 cell과 같은 컴포넌트를 만드는 파일이다.

HStack, VStack을 활용해서 적절히 잘 만들어줄 것이다.

 

API response 객체 접근

좀전 API를 호출했을 때 나오는 결과값들 중 책 한권의 데이터에 해당하는 데이터를 복사해서 sampleBookData 라는 변수명을 생성해 그 값 안에 집어넣어준다.

 

import SwiftUI

let sampleBookData = Book(title: "모두 거짓말을 한다", publisher: "더퀘스트", authors: ["세스 스티븐스 다비도위츠"], thumbnail: "https://search1.kakaocdn.net/thumb/R120x174.q85/?fname=http%3A%2F%2Ft1.daumcdn.net%2Flbook%2Fimage%2F1596717%3Ftimestamp%3D20240713113438", url: "https://search.daum.net/search?w=bookpage&bookId=1596717&q=%EB%AA%A8%EB%91%90+%EA%B1%B0%EC%A7%93%EB%A7%90%EC%9D%84+%ED%95%9C%EB%8B%A4", price: 18000, id: "1160504571 9791160504576")

struct BookRow: View {
    var body: some View {
        Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
    }
}

#Preview {
    BookRow()
}

 

 

책 썸네일 이미지 가져오는 법

AsyncImage를 사용할 것이다.

 

Book Row 디자인

HStack, VStack을 사용해서 이래저래 해준다.

struct BookRow: View {
    let book: Book
    var body: some View {
        HStack(spacing: 5){
            AsyncImage(url: URL(string: book.thumbnail)) { image in
                image.resizable()
                    .scaledToFill()
                    .frame(width: 60, height: 80)
                
            } placeholder: {
                Image(systemName: "book.fill")
                    .resizable()
                    .frame(width: 60, height: 80)
                    .foregroundColor(.gray)
                    .cornerRadius(8)
            }

            VStack(alignment: .leading) {
                Text(book.title).font(.title2).fontWeight(.bold).foregroundStyle(.blue).lineLimit(1)
                    .truncationMode(.tail)

                Text(book.publisher).font(.subheadline).foregroundStyle(.gray).lineLimit(1)
                
                HStack {
                    Text(book.authors.joined(separator: ", ")).font(.subheadline)
                    Spacer()
                    Text("\(book.price.formatted())원").frame(alignment: .trailing).foregroundStyle(.red).fontWeight(.bold)
                    
                }

            }
            .padding(5)
            
            
            
        }.padding(10)
        
        
    }
}

 


 

BookList.swift에서 검색창에 책 검색 시 결과 호출하기

List()를 호출해준다.

            List(bookFinder.books) { book in
                BookRow(book: book)
            }

 

아래와 같이 잘 나온다.

 

 


 

Navigation바

 

Container를 NavigationSplitView 로 바꾸어 네비게이션 바 안에 담아주고, detail: {}을 추가해준다.

 


WebView 만들기

BookList에서 특정 책을 눌렀을 때, url로 넘어가게 만드는 기능을 구현할 것이다.

import SwiftUI
import WebKit

struct BookDetailWebView: UIViewRepresentable {
    var strURL: String
    
    func makeUIView(context: Context) -> WKWebView {
        let webView = WKWebView()
        return webView
    }
    
    func updateUIView(_ uiView: UIViewType, context: Context) {
        guard let url = URL(string: strURL) else { return }
        let request = URLRequest(url: url)
        uiView.load(request)
    }
    

}

#Preview {
    BookDetailWebView(strURL: sampleBookData.url)
}

 

 

BookDetailWebView는 strURL을 매개변수로 받으므로, Preview에서는 인자값으로 sampleBookData.url 넣어준다.

 


책 선택 시 WebView로 넘어가기

BookList.swift

이제, 개별 책 row는 List 안에 있으므로 List()안에 NavigationLink를 만들어서 아까 BookDetailWebView에서 구현했던 값을 넣어준다.

                List(bookFinder.books) { book in
                    NavigationLink {
                        BookDetailWebView(strURL: book.url)
                    } label: {
                        BookRow(book: book)
                    }
                }

 

 

그리고 에러 핸들링을 해주었다.

alert("책검색", isPresented: $bookFinder.isError) {
                    Button("확인", role: .cancel) { }
                } message: {
                    Text("책 정보를 가져오는데 실패했습니다.")
                }

 

SwiftUI alert 

여기서 잠깐, SwiftUI의 alert창은 아래와 같이 .destructive를 하면 빨간색으로 나오고, 여러개를 할 수 있다.

 

반면, .cancel은 여러개를 코드로 선언해도, 하나밖에 안 된다.


OpenWeatherAPI 적용하기

www.openweathermap.org 로 가서 계정을 만들고 API key를 받아준다.

아래 두 API 기능을 쓸것이다.

Postman test

새 컬렉션을 만들고 아래와 같이 설정해준다.

 

GET/ Current Weather

 

API Call이 잘 되는것을 확인해준다.

 

GET/ Forecast

 

WeatherProvider.swift

를 생성하고, 모델을 생성한 후 Alamofire를 이용해서 데이터를 가져온다.

 

Alamofire 라이브러리를 import 해야 Parameters 타입이라던가 등을 쓸 수 있다.

import Foundation
import Alamofire

class WeatherProvider: ObservableObject {
    @Published var main: String = ""
    @Published var description: String = ""
    @Published var icon: String = ""
    @Published var temp: Double?
    
    let appid = <appid>
    let endPoint = "https://api.openweathermap.org/data/2.5/weather"

    
    func getWeather() {
        let params:Parameters = ["appid": appid, "q": "seoul", "units":"metric", "lang":"kr"]
        AF.request(endPoint, method: .get, parameters: params)
            .validate(statusCode: 200..<300)
            .responseDecodable(of:Result.self) { response in
                switch response.result {
                case .success(let result):
                    let weather = result.weather[0]
                    self.main = weather.main
                    self.temp = result.main.temp
                    self.icon = "https://openweathermap.org/img/w/\(weather.icon)@2x.png"
                    self.description = weather.description
                    
                case .failure(let error):
                    print(error.localizedDescription)
                }}
    }
}

 


WeatherView.swift (SwiftUI)

를 생성해서 방금 만든 모델을 화면에 보여줄것이다.

이제 방금 만든 WeatherProvider를 Environment로 던져놓고 필요할때마다 쓸것이다.

import SwiftUI

struct WeatherView: View {
    @EnvironmentObject var provider: WeatherProvider
    var body: some View {
        HStack {
            Text(provider.description)
            if let temp = provider.temp{
                Text(String(format: "%.1f°C", temp))
            }
        }
    }
}

#Preview {
    let provider = WeatherProvider()
    WeatherView().environmentObject(provider).onAppear {
        provider.getWeather()
    }
}

#Preview에서 로딩하는 방법을 주의해야한다.

 

 

디자인, 배치

HStack {
            Text(provider.description)
            AsyncImage(url: URL(string: provider.icon)) {
                image in
                image.image?.resizable().frame(width: 30, height: 30)
            }
            if let temp = provider.temp{
                Text(String(format: "%.1f°C", temp))
                    .bold()
                    .foregroundStyle(temp > 10 ? .red : .blue)
                    .padding(.leading, -12)
            }
        }

 


MainView.swift

를 만들어준다.

import SwiftUI

struct MainView: View {
    var weatherProvider = WeatherProvider()
    var body: some View {
        TabView {
            BookList()
                .tabItem {
                    Image(systemName: "books")
                    Text("책검색")
                }
        }.environmentObject(weatherProvider)
            .onAppear {
                weatherProvider.getWeather()
            }
    }
}

#Preview {
    MainView()
}

 


ForecastProvider.swift

를 만들어준다.

import Foundation
import Alamofire

class ForecastProvider: ObservableObject {
    @Published var list: [Forecast]?
    let appid = <appid>
    let endPoint = api.openweathermap.org/data/2.5/forecast?
    
    func getForecast(city: String) {
        let parameters: [String: Any] = ["q":city, "appid":appid, "lang":"kr", "units":"metric"]
        AF.request(endPoint, method: .get, parameters: parameters)
            .validate(statusCode: 200..<300)
            .responseDecodable(of: Root.self) { response in
                switch response.result {
                case .success(let forecasts):
                    self.list = forecasts.list
                    print(forecasts.list[0])
                    
                case .failure(let error):
                    print(error.localizedDescription)
                }
            
        }
        
    }
    
    
    
    
}

 

 

나중에 샘플 데이터를 가져오기 위해 복사를 해준다.

 


ForecastView.swift (SwiftUI View 생성)

import SwiftUI

struct ForecastView: View {
    @State var city = ""
    @StateObject var provider = ForecastProvider()
    
    var body: some View {
        VStack {
            SearchBar(searchText: $city){
                provider.getForecast(city: city)
            }
        }
    }
}

#Preview {
	ForecastView(city: "seoul")
}

 

 

그리고 Preview 검색창에 "seoul"이 있을건데, 여기에 마우스 커서를 올려놓고 엔터를 쳐준다.

 

Preview Console창에 URL이 잘못되었다고 했는데, 보니까 https://가 빠져있었다.


ForecastRow.swift (SwiftUI)

//
//  ForecastRow.swift
//  OpenAPIWithSwiftUI
//
//  Created by Lia An on 11/8/24.
//

import SwiftUI

let sampleForecastData = Forecast(id: 1731056400, main: Main(temp: 16.6, humidity: 27), weather: [Weather(main: "Clear", description: "맑음", icon: "01n")], date: "2024-11-08 09:00:00")

struct ForecastRow: View {
    let forecast: Forecast
    
    var body: some View {
        HStack {
            let icon = "https://openweathermap.org/img/wn/\(forecast.weather[0].icon)@4x.png"
            // 왼쪽 부분 (아이콘 + 날씨 설명)
            AsyncImage(url: URL(string: icon)) { image in
                image.resizable().frame(width: 50, height: 50)
            } placeholder: {
                Image(systemName: "sun.min")
            }
            

            VStack(alignment: .leading) {
                Text(forecast.date)
                    .font(.subheadline)
                    .foregroundColor(.gray)
                
                Text(forecast.weather[0].description)
                    .font(.headline)
            }
            
            Spacer()
            
            // 오른쪽 부분 (온도 + 습도)
            VStack(alignment: .trailing) {
                HStack {
                    Image(systemName: "thermometer.medium")
                    Text(String(format: "%.1f", forecast.main.temp) + "°C")
                }
                .foregroundStyle(.red)
                
                HStack {
                    Image(systemName: "humidity")
                    Text("\(forecast.main.humidity)%")
                }
                .foregroundStyle(.blue)
            }
        }
        .padding() // 전체 여백 추가
    }
}

#Preview {
    ForecastRow(forecast: sampleForecastData)
}

 

(아이콘 잘 안나와서 보니까 ForecastProvider.swift의 endPoint 끝에 ? 기호가 있어서 지워주니 잘 나옴


ForecastView.swift

import SwiftUI

struct ForecastView: View {
    @State var city = ""
    @StateObject var provider = ForecastProvider()
    
    var body: some View {
        VStack {
            SearchBar(searchText: $city){
                provider.getForecast(city: city)
            }
            if let forecasts = provider.list {
                List(forecasts) { forecast in
                    ForecastRow(forecast: forecast)
                }
            } else {
                EmptyView()
            }
        }
    }
}

#Preview {
    ForecastView(city: "seoul")
}

 

프리뷰에서 엔터를 쳐주면, 아래와 같이 

<전>

 

<후>

 

BookList에서 아래 내용을 복사해온다.

List(bookFinder.books) { book in
                    NavigationLink {
                        BookDetailWebView(strURL: book.url)
                    } label: {
                        BookRow(book: book)
                    }
                }
                .listStyle(.plain)
                .navigationTitle("책 검색")
                .navigationBarTitleDisplayMode(.inline)
                .toolbar {
                    ToolbarItem(placement: .topBarTrailing) {
                        Button {
                            page -= 1
                            bookFinder.search(query: query, at: page)
                        } label: {
                            Image(systemName: "arrow.left")
                            
                        }.disabled(true)
                    }

 

 

Main.view에 가서

 

에서

하나 더 만들어준다.

import SwiftUI

struct MainView: View {
    var weatherProvider = WeatherProvider()
    var body: some View {
        TabView {
            BookList()
                .tabItem {
                    Image(systemName: "books.vertical")
                    Text("책검색")
                }
            
            ForecastView()
                .tabItem {
                    Image(systemName: "sun.min")
                    Text("일기예보")
                }
            
        }.environmentObject(weatherProvider)
            .onAppear {
                weatherProvider.getWeather()
            }
    }
}

#Preview {
    MainView()
}

 

 

 

ContentView.swift

에 가서 VStack{} 안의 내용을 MainView()로 띄워준다.

 

 

다시 돌아와서 ForecastView.swift에서 테스트해본다.

Comments