나의 발자취
[SwiftUI] OpenAPI 활용한 Book Finder App 만들기 본문
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에서 테스트해본다.
'앱 개발 > iOS' 카테고리의 다른 글
[SwiftUI ver] 당근마켓 거래서비스 풀스택 구현하기 (Frontend) - 무한 스크롤 (0) | 2024.11.19 |
---|---|
[SwiftUI ver] 당근마켓 거래서비스 풀스택 구현하기 (Frontend) - 로그인, 회원가입, 상품 리스트 업데이트 (1) | 2024.11.15 |
[SwiftUI-UIKit] 같이 사용하기 (0) | 2024.11.07 |
FE] 당근마켓 아니고 양파마켓 만들기 (2) WIP (0) | 2024.11.07 |
[FE] 당근마켓 아니고 양파마켓 만들기 (!!!!!!WIP!!!!!!!) (0) | 2024.11.06 |