본문 바로가기

iOS

[iOS] Unsplash Search photos (feat.MVVM, Rx, CollectionViewAPI) + 코드 수정

728x90

우하하!!! 다 적용해보자!!!

사실 이게 맞는 방법인지는 잘 모르겠지만 .. 일단 가보자고 .. ~ 

 

https://unsplash.com/documentation#search-photos

 

Unsplash API Documentation | Free HD Photo API | Unsplash

Getting started This document describes the resources that make up the official Unsplash JSON API. If you have any problems or requests, please contact our API team. Creating a developer account To access the Unsplash API, first join. Registering your appl

unsplash.com

이 사진 검색 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)
}