오랜만에 개발하고있는 앱의 진행사항 기록을 해보려고 한다.
코드베이스로 전체 화면을 구성하는 건 처음인데요..!✍🏻
우선 첫 화면부터 만들어보자.
UITabBarController 사용하기
탭 바 인터페이스를 관리하는 뷰 컨트롤러를 따로 만들었다.
func configureUI() {
self.view.backgroundColor = .white
self.tabBar.backgroundColor = .systemGray5
}
func configureTabItem() {
let searchVC = SearchTabViewController()
let savedBooksVC = SavedBooksViewController()
let searchNav = UINavigationController(rootViewController: searchVC)
searchVC.tabBarItem = UITabBarItem(tabBarSystemItem: .search, tag: 0)
savedBooksVC.tabBarItem = UITabBarItem(tabBarSystemItem: .favorites, tag: 1)
self.viewControllers = [searchNav, savedBooksVC]
self.selectedIndex = 0
}
위와 같이 탭 바 컨트롤러를 구성하는 코드를 작성했다.
검색 화면, 저장 화면을 탭에 넣어주어야 하니 아이템을 설정해주고 각 탭바의 탭으로 만들었다.
검색 화면은 네비게이션을 사용하게 될 것 같아 UINavigationController으로 래핑하여 탭에 추가했다
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
guard let windowScene = (scene as? UIWindowScene) else { return }
window = UIWindow(windowScene: windowScene)
window?.rootViewController = TabBarViewController()
window?.makeKeyAndVisible()
}
스토리보드 없이 작업하는 거니 씬델리게이트 수정해서 시작화면 지정해주기!
책 검색 화면 - SearchBar
검색 화면과 검색 결과 화면은 따로 만들었다.
우선 검색을 위해 SearchBar를 사용했다.
let searchBar: UISearchBar = {
let searchBar = UISearchBar()
searchBar.placeholder = "Search Books"
searchBar.searchBarStyle = .minimal
searchBar.translatesAutoresizingMaskIntoConstraints = false
return searchBar
}()
func setupSearchBar() {
searchBar.delegate = self
}
func setupConstraints() {
view.addSubview(searchBar)
searchBar.snp.makeConstraints {
$0.top.equalTo(view.safeAreaLayoutGuide)
$0.leading.trailing.equalToSuperview().inset(16)
}
}
SearchBar를 생성하고 레이아웃은 SnapKit 라이브러리를 사용해서 설정했다.
// MARK: - UISearchBarDelegate
extension SearchTabViewController: UISearchBarDelegate {
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
guard let searchText = searchBar.text else { return }
print("검색어: \(searchText)")
let searchResultVC = SearchResultViewController()
searchResultVC.searchKeyword = searchText
navigationController?.pushViewController(searchResultVC, animated: true)
}
}
SearchBarDelegate로 입력된 텍스트를 가져와서 검색 결과 화면으로 전달하며 이동하도록 구현했다.
아직 SearchBar만 존재하지만 이 화면에 요소들을 더 추가해볼 예정이다!
검색 결과 화면 - CollectionView
이제 검색했을 때 이동되는 검색 화면을 구성해보자!
검색 결과화면은 컬렉션뷰로 구성했는데 UI부터 작업했다.
마찬가지로 검색 결과 화면에 필요한 컴포넌트를 생성하고 레이아웃을 설정해서 배치했다.
let resultCountLabel: UILabel = {
let label = UILabel()
label.font = UIFont.boldSystemFont(ofSize: 16)
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
let searchBar: UISearchBar = {
let searchBar = UISearchBar()
searchBar.searchBarStyle = .minimal
searchBar.translatesAutoresizingMaskIntoConstraints = false
return searchBar
}()
let collectionView: UICollectionView = {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .vertical
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.translatesAutoresizingMaskIntoConstraints = false
return collectionView
}()
private func setupConstraints() {
[searchBar, resultCountLabel, collectionView].forEach {
view.addSubview($0)
}
searchBar.snp.makeConstraints {
$0.top.equalTo(view.safeAreaLayoutGuide)
$0.leading.trailing.equalToSuperview()
}
resultCountLabel.snp.makeConstraints {
$0.top.equalTo(searchBar.snp.bottom).offset(16)
$0.leading.equalToSuperview().inset(16)
}
collectionView.snp.makeConstraints {
$0.top.equalTo(resultCountLabel.snp.bottom).offset(8)
$0.leading.trailing.bottom.equalToSuperview().inset(10)
}
}
컬렉션뷰에는 3개씩 셀을 보이도록 하기 위해 다음과 같이 FlowLayout을 설정했다.
extension SearchResultViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let paddingSpace = 5 * 4
let availableWidth = collectionView.bounds.width - CGFloat(paddingSpace)
let widthPerItem = availableWidth / 3
return CGSize(width: widthPerItem, height: 280)
}
}
CollectionViewCell에는 필요한 요소들을 구성하기 위해 책의 제목, 작가, 출판사, 썸네일 이미지를 표시하도록 했다.
class BookCollectionViewCell: UICollectionViewCell {
static let identifier = String(describing: BookCollectionViewCell.self)
let thumbnailImageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 10
return imageView
}()
let titleLabel: UILabel = {
let label = UILabel()
label.font = UIFont.boldSystemFont(ofSize: 17)
label.numberOfLines = 2
return label
}()
let authorLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 14)
return label
}()
let priceLabel: UILabel = {
let label = UILabel()
label.font = UIFont.boldSystemFont(ofSize: 14)
return label
}()
let publisherLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 14)
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
configureUI()
setupConstraints()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupConstraints() {
[thumbnailImageView, titleLabel, authorLabel, publisherLabel].forEach {
contentView.addSubview($0)
}
thumbnailImageView.snp.makeConstraints {
$0.top.equalToSuperview()
$0.width.equalTo(contentView.snp.width)
}
titleLabel.snp.makeConstraints {
$0.top.equalTo(thumbnailImageView.snp.bottom).offset(5)
$0.leading.trailing.equalToSuperview().inset(5)
}
authorLabel.snp.makeConstraints {
$0.top.equalTo(titleLabel.snp.bottom).offset(3)
$0.leading.trailing.equalToSuperview().inset(5)
}
publisherLabel.snp.makeConstraints {
$0.top.equalTo(authorLabel.snp.bottom).offset(3)
$0.leading.trailing.equalToSuperview().inset(5)
$0.bottom.lessThanOrEqualToSuperview().inset(5)
}
}
private func configureUI() {
backgroundColor = .white
}
데이터를 받아오기 전에 CollectionViewCell에 우선 요소를 생성하고 레이아웃을 설정했다.
셀 identifier도 static으로 만들어두면 유용하다.
VC에서 셀 등록하고 DataSource를 채택해서 셀을 그려준다.
collectionView.register(BookCollectionViewCell.self, forCellWithReuseIdentifier: BookCollectionViewCell.identifier)
extension SearchResultViewController: UICollectionViewDataSource, UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return {임시 숫자}
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: BookCollectionViewCell.identifier, for: indexPath) as? BookCollectionViewCell else { return UICollectionViewCell() }
return cell
}
}
아래는 코드베이스로 작업할 때 간단하게 프리뷰 보는 방법이다! 매번 빌드하기 번거로워서 유용하게 사용했다.
#Preview {
SearchResultViewController()
// 화면 업데이트: command+option+p
}
네트워크 통신
이제 화면을 구성했으니 검색 기능을 위해 네트워크 통신을 통해 데이터를 받아오는 과정을 정리해보자.
카카오 책 검색 REST API를 이용했다.
RESTAPIKey를 발급 받고 아래 링크에서 API 문서를 살펴보자.
https://developers.kakao.com/docs/latest/ko/daum-search/dev-guide#search-book
아래는 네트워크 통신하는 간단한 예제 코드이다.
let url = URL(string: "https://dapi.kakao.com/v3/search/book?query=세이노")!
var request = URLRequest(url: url)
request.allHTTPHeaderFields = ["Authorization": "KakaoAK <이 곳에 RESTAPI Key 를 넣으세요.>"]
URLSession.shared.dataTask(with: request) { data, _, _ in
print(String(data: data!, encoding: .utf8))
}.resume()
이제 통신을 위한 데이터 모델을 정의하자.
API의 응답을 참고해서 BookData를 작성했다. documents안에 책 데이터가 감싸져 있다는 점!
모델에서 이름을 다르게 사용하려면 CodingKeys를 사용하여 키와 구조체 속성 간의 매핑도 해줘야 한다.
import Foundation
struct BookData: Codable {
let documents: [Document]
}
struct Document: Codable {
let title: String
let contents: String
let url: String
let isbn: String
let datetime: String
let authors: [String]
let publisher: String
let translators: [String]
let price: Int
let salePrice: Int
let thumbnail: String
let status: String
enum CodingKeys: String, CodingKey {
case title, contents, url, isbn, datetime, authors, publisher, translators, price, thumbnail, status
case salePrice = "sale_price"
}
}
아래 사이트를 이용해서 response를 입력하고 데이터 모델을 가져와서 작성하면 아주 편리하다!
이번에도 URLSession을 통해 데이터를 받아왔다.
싱글톤 패턴으로 네트워크 매니저 인스턴스를 공유하도록 했고 KakaoAPI키와 URL을 사용해 네트워크 통신을 진행했다.
요청 URL에 검색을 위한 쿼리 매개변수를 추가했다!
또한 HTTP GET 요청을 생성하고 헤더에 API키를 추가한다.
URLSession을 사용하여 데이터 요청을 생성 후 완료 핸들러로 요청 결과를 반환하도록 메서드를 작성했다.
class NetworkingManager {
static let shared = NetworkingManager()
private let apiKey = "{발급받은 API 키}"
private let baseURL = "https://dapi.kakao.com/v3/search/book"
func searchBooks(query: String, completion: @escaping (Result<Data, Error>) -> Void) {
// 검색어를 인코딩하여 쿼리스트링에 추가
guard let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
let error = NSError(domain: "EncodingError", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to encode query"])
completion(.failure(error))
return
}
// 요청 URL 생성
let urlString = "\(baseURL)?query=\(encodedQuery)"
guard let url = URL(string: urlString) else {
let error = NSError(domain: "URLCreationError", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to create URL"])
completion(.failure(error))
return
}
// HTTP 요청 생성
var request = URLRequest(url: url)
request.httpMethod = "GET"
// 요청 헤더에 API 키 추가
request.addValue("KakaoAK \(apiKey)", forHTTPHeaderField: "Authorization")
// URLSession을 사용하여 데이터 요청
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
if let error = error {
completion(.failure(error))
return
}
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
let error = NSError(domain: "InvalidResponseError", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid response"])
completion(.failure(error))
return
}
if let data = data {
completion(.success(data))
}
}
// 요청 실행
task.resume()
}
}
이제 받아온 데이터를 사용해보자.
검색 화면에서 가져온 searchKeyword와 검색 결과를 담아줄 배열을 만들어준다.
var searchKeyword: String?
var books: [Document] = []
검색어를 쿼리로 가지며 책 데이터를 가져오는 메서드를 작성했다.
위에서 작성한 메서드를 호출하여 KakaoAPI에 요청을 보내고 결과로. 책 정보를 받아온다!
또한 books 배열에 담아주기 위해 BookData 구조에 맞게 디코딩을 해야한다.
func fetchBookData() {
NetworkingManager.shared.searchBooks(query: searchKeyword ?? "") { result in
switch result {
case .success(let data):
do {
let decodedData = try JSONDecoder().decode(BookData.self, from: data)
print("Decoded data: \(decodedData)")
self.books = decodedData.documents
} catch {
print("Failed to parse data: \(error.localizedDescription)")
}
case .failure(let error):
print("Failed to fetch book data: \(error.localizedDescription)")
}
}
}
이제 검색 결과에 대한 데이터를 Cell에 설정해주자.
book에 담아진 데이터를 CollectionViewCell안에 표시해주도록 했다.
이미지 처리는 KingFisher라이브러리를 사용해서 편리하게 사용했다.
func setData(with book: Document) {
titleLabel.text = book.title
authorLabel.text = book.authors.isEmpty ? "" : book.authors.joined(separator: ", ")
publisherLabel.text = book.publisher
// 썸네일 이미지 처리
if let thumbnailURL = URL(string: book.thumbnail) {
thumbnailImageView.kf.setImage(with: thumbnailURL)
} else {
thumbnailImageView.image = UIImage(named: "placeholder")
}
}
데이터를 설정했으니 DataSource 부분도 수정해야한다.
배열의 데이터 요소 수를 반환하고 각 셀에 데이터를 설정했다.
extension SearchResultViewController: UICollectionViewDataSource, UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return books.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: BookCollectionViewCell.identifier, for: indexPath) as? BookCollectionViewCell else { return UICollectionViewCell() }
let book = books[indexPath.item]
cell.setData(with: book)
return cell
}
}
또한 아래와 같이 데이터를 가져올 때 성공할 경우 UI업데이트를 진행하도록 했다.
메인 쓰레드에서 처리되어야 하기 때문에 DispatchQueue.main.async 블록 내에서 collectionView를 리로드하고, resultCountLabel에 검색 결과의 개수를 표시한다.
URLSession은 아직도 어렵고 단번에 이해하기 힘든 것 같다..!!!😢
세션을 다시 한 번 복습해야겠다.
extension SearchResultViewController: UISearchBarDelegate {
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
guard let searchText = searchBar.text else { return }
print("검색어: \(searchText)")
searchKeyword = searchText
fetchBookData()
searchBar.resignFirstResponder()
}
}
결과 화면에서도 검색을 할 수 있도록 Delegate 메서드를 수정했다.
검색어를 다시 할당해서 해당 검색어로 책 데이터를 가져온다.
검색 기능 구현은 완료했으니 어서 다른 기능도 진행해야겠다..!
'TIL✏️' 카테고리의 다른 글
[iOS] 알람 앱 (2) - 스톱워치 구현하기, Timer와 버튼 상태 변화 (2) | 2024.05.14 |
---|---|
[iOS] 알람 앱 (1) - TabBarItem, NavigationBarItem 이미지 추가하기 (10) | 2024.05.13 |
[iOS] UICollectionView: Header(헤더) 사용하기 (1) | 2024.04.23 |
[iOS] WishList App - ScrollView 적용, Pull to Refresh (4) | 2024.04.18 |
[iOS] WishList App - CoreData 사용하기 (2) | 2024.04.17 |