우하하!!! 다 적용해보자!!!
사실 이게 맞는 방법인지는 잘 모르겠지만 .. 일단 가보자고 .. ~
https://unsplash.com/documentation#search-photos
이 사진 검색 API를 바탕으로 UISearchBar에 사용자가 특정한 단어를 검색하면, 그 검색어에 해당하는 사진 목록을 보여주는 기능을 구현해보자.
스케치를 먼저해보면,
#MVVM
검색 목록이 해당 기능에서의 모델이 될 것이다.
그리고 UISearchBar와 UICollectionView로 이뤄진 화면과 사용자의 입력값을 받는 부분이 뷰에 해당할 것이고,
사용자가 뷰에서 입력한 검색어를 기반으로 비즈니스 로직인 서버 통신을 하는 것과 이 때의 응답 데이터를 관리하는 역할을 뷰모델이 할 것이다.
#RxCocoa
RxCocoa를 통해서 사용자가 search bar에 검색어를 입력하고,
이 검색어가 일종의 이벤트가 방출된 것이다. (-> Observable)
그리고 사용자가 단어를 모두 입력했다면, 이 검색어를 기반으로 서버 통신을 하는 것이 Observer의 역할이다.
#Diffable DataSource, Compositional Layout
검색 결과에 대한 데이터를 바탕으로 CollectionView에 보여주면 되는데,
이 때 디퍼블 데이터소스의 스냅샷으로 데이터의 갱신을 관리하고,
컴포지셔널 레이아웃으로 컬렉션뷰의 레이아웃을 관리하면 된다.
실제 코드는 아래와 같다.
서버통신
Alamofire를 통해서 서버통신을 구현했다.
#데이터모델
먼저 응답 데이터에 대한 모델을 만들어준다. 이 모델이 DiffableDataSource의 데이터 타입이 되므로 Hashable 프로토콜을 채택한다.
struct SearchPhoto: Codable, Hashable {
let total, totalPages: Int
let results: [Photo]
enum CodingKeys: String, CodingKey {
case total
case totalPages = "total_pages"
case results
}
}
struct Photo: Codable, Hashable {
let id: String
let urls: Urls
let likes: Int
enum CodingKeys: String, CodingKey {
case id
case urls, likes
}
}
struct Urls: Codable, Hashable {
let raw, full, regular, small: String
let thumb, smallS3: String
}
#APIManager
싱글톤 패턴의 APIManger 파일을 별도로 만들어서 서버통신을 한다.
final class SearchPhotoAPIManager {
static let shared = SearchPhotoAPIManager()
private init() { }
func fetchPhotoList(_ query: String, completionHandler: @escaping(SearchPhoto?, Int?, Error?) -> Void) {
let url = URLConstant.searchURL
let headers: HTTPHeaders = [APIKey.authorization : APIKey.key]
let params: Parameters = [
"query" : query,
"page" : 1,
"per_page" : 10,
"order_by" : "popular"
]
let request = AF.request(url,
method: .get,
parameters: params,
encoding: URLEncoding.default,
headers: headers)
request.responseDecodable(of: SearchPhoto.self) { response in
let statusCode = response.response?.statusCode
switch response.result {
case .success(let value):
completionHandler(value, statusCode, nil)
case .failure(let error):
completionHandler(nil, statusCode, error)
}
}
}
}
주의할 점은 요청하는 모델, 반환하는 모델의 타입을 제대로 작성/명시해야 한다.
ViewModel
서버통신은 결국 비즈니스 로직이므로 ViewModel에서 관리한다.
뷰모델은 모델을 참조하고 있기 때문에 CObservable한 모델을 만들어주고, 서버 통신을 한 후 통신의 결과로 받은 리스트를 해당 모델에 넣으면 된다.
final class SearchPhotoViewModel {
var list: CObservable<[Photo]> = CObservable([])
func requestPhotoList(_ query: String) {
SearchPhotoAPIManager.shared.fetchPhotoList(query) { [weak self] value, statusCode, error in
guard let self = self else { return }
guard let value = value else { return }
self.list.value = value.results
}
}
}
View
ViewModel 객체와 Rx에서 필요한 객체들을 만들고 사용자의 인터랙션과 UI를 만들면 된다.
final class SearchPhotoViewController: UIViewController {
// MARK: - UI Property
private var searchBar = UISearchBar()
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: createLayout())
// MARK: - Property
private var dataSource: UICollectionViewDiffableDataSource<Int, Photo>!
private let viewModel = SearchPhotoViewModel()
private let disposeBag = DisposeBag()
// MARK: - Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
configureUI()
setLayout()
bindData()
}
// MARK: - UI Method
private func configureUI() {
view.backgroundColor = .white
configureDataSource()
configureSearchBar()
}
private func setLayout() {
view.addSubview(searchBar)
view.addSubview(collectionView)
searchBar.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.height.equalTo(44)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(searchBar.snp.bottom)
make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide)
}
}
private func configureDataSource() {
let cellRegistration = UICollectionView.CellRegistration<ListCollectionViewCell, Photo>.init { cell, indexPath, itemIdentifier in
cell.setData(itemIdentifier)
}
dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView, cellProvider: { collectionView, indexPath, itemIdentifier in
let cell = collectionView.dequeueConfiguredReusableCell(using: cellRegistration, for: indexPath, item: itemIdentifier)
return cell
})
}
private func configureSearchBar() {
searchBar.rx.text.orEmpty
.debounce(RxTimeInterval.seconds(1), scheduler: MainScheduler.instance)
.subscribe(with: self) { (vc, value) in
self.viewModel.requestPhotoList(value)
print(value)
}
.disposed(by: disposeBag)
}
// MARK: - Data
private func bindData() {
viewModel.list.bind { [weak self] photoList in
guard let self = self else { return }
var snapshot = NSDiffableDataSourceSnapshot<Int, Photo>()
snapshot.appendSections([0])
snapshot.appendItems(photoList)
self.dataSource.apply(snapshot)
}
}
}
// MARK: - CollectionView
extension SearchPhotoViewController {
private func createLayout() -> UICollectionViewLayout {
let configuration = UICollectionViewCompositionalLayoutConfiguration()
let layout = createCompositionalLayout()
layout.configuration = configuration
return layout
}
private func createCompositionalLayout() -> UICollectionViewCompositionalLayout {
return UICollectionViewCompositionalLayout { (sectionIndex, NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection? in
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
heightDimension: .absolute(140))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
return section
}
}
}
위의 과정까지 하고 .. 생각을 해보니 .. 뷰모델에서 참조하고 있는 데이터의 형태를 바꿀 필요가 있다고 생각했다.
- 뷰(SearchBar)에서 사용자에게 인터랙션을 받아서 해당 인터랙션에 대한 처리를
- 뷰모델로 전달한다. (입력받은 검색어를 기반으로 서버 통신을 해야 하므로)
- 그리고 뷰모델에서 서버 통신 이후로 받은 응답값을 다시 Subject의 onNext로 새로운 값을 넣는다.
- 새로운 값으로 변화했기 때문에 이를 관찰하고 있는 컬렉션뷰의 데이터소스가 변화해야 한다.
그러므로 코드는 아래와 같이 수정된다.
먼저, 뷰모델에서 아래처럼 PublishSubject로 데이터를 관리한다.
final class SearchPhotoViewModel {
// var list: CObservable<[Photo]> = CObservable([])
var list = PublishSubject<[Photo]>()
func requestPhotoList(_ query: String) {
SearchPhotoAPIManager.shared.fetchPhotoList(query) { [weak self] value, statusCode, error in
guard let self = self else { return }
guard let value = value else { return }
// self.list.value = value.results
self.list.onNext(value.results)
}
}
}
그리고 이렇게 list의 onNext로 새로운 이벤트가 방출되면, 이를 다시 뷰에서 받아서 컬렉션 뷰를 갱신한다.
private func bindData() {
viewModel.list
.bind { [weak self] photoList in
guard let self = self else { return }
var snapshot = NSDiffableDataSourceSnapshot<Int, Photo>()
snapshot.appendSections([0])
snapshot.appendItems(photoList)
self.dataSource.apply(snapshot)
}
.disposed(by: disposeBag)
}
'iOS' 카테고리의 다른 글
[동시성 프로그래밍] GCD - 비동기 VS 동기 / 직렬 VS 동시 (0) | 2023.02.26 |
---|---|
[동시성 프로그래밍] GCD - 작업을 큐로 보낸다. / GCD vs Operation (0) | 2023.02.19 |
[iOS] MVVM+DiffableDataSource+CompositionalLayout (1) | 2022.10.24 |
[iOS] Collection View APIs (0) | 2022.10.23 |
[Realm] Migration (실습) (0) | 2022.10.13 |