저번주에 이어 포켓몬 도감앱을 마저 구현하였다.
저번주에 구현하고 남은 구현 과제는
- MainViewController 구현
- UICollectionViewCell 구현
- DetailViewController 구현
- DetailViewModel 구현
- 영어로 제공되는 포켓몬 데이터 한글로 번역
- 무한 스크롤 기능 구현
였다.
오늘은 MainViewController 구현을 시작하였다.
import UIKit
import RxSwift
import SnapKit
// 사용한 컬러 hex 값.
extension UIColor {
static let mainRed = UIColor(red: 190/255, green: 30/255, blue: 40/255, alpha: 1.0)
static let darkRed = UIColor(red: 120/255, green: 30/255, blue: 30/255, alpha: 1.0)
static let cellBackground = UIColor(red: 245/255, green: 245/255, blue: 235/255, alpha: 1.0)
}
class MainViewController: UIViewController {
private let mainViewModel = MainViewModel()
private let disposeBag = DisposeBag()
private var pokemonData = [PokemonData]()
private let pokemonballImageView: UIImageView = {
let imageView = UIImageView()
imageView.image = UIImage(named: "pokemonBall")
imageView.backgroundColor = UIColor.mainRed
imageView.contentMode = .scaleAspectFill
return imageView
}()
private lazy var collectionView: UICollectionView = {
let collectionView = UICollectionView()
collectionView.register(PokemonCell.self, forCellWithReuseIdentifier: PokemonCell.id)
collectionView.delegate = self
collectionView.dataSource = self
collectionView.backgroundColor = UIColor.darkRed
return collectionView
}()
override func viewDidLoad() {
super.viewDidLoad()
bind()
configureUI()
}
private func bind() {
mainViewModel.pokemonSubject
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] pokemon in
self?.pokemonData = pokemon
self?.collectionView.reloadData()
}, onError: { error in
print("에러 발생 : \(error)")
}).disposed(by: disposeBag)
}
private func configureUI() {
view.backgroundColor = UIColor.mainRed
[
pokemonballImageView,
collectionView
].forEach { view.addSubview($0) }
pokemonballImageView.snp.makeConstraints {
$0.top.equalTo(view.safeAreaLayoutGuide.snp.top).offset(10)
$0.centerX.equalTo(view.safeAreaLayoutGuide.snp.centerX)
$0.width.height.equalTo(UIScreen.main.bounds.width * 0.3)
}
collectionView.snp.makeConstraints {
$0.top.equalTo(pokemonballImageView.snp.bottom).offset(10)
$0.horizontalEdges.equalTo(view.safeAreaLayoutGuide.snp.horizontalEdges)
$0.bottom.equalTo(view.safeAreaLayoutGuide.snp.bottom)
}
}
}
extension MainViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
// detailViewController로 이동
// let selectedCell = pokemonData[indexPath.row]
// let detailVC = detailViewController()
// detailVC.setDetailViewData(selectedCell)
// self.navigationController?.pushViewController(detailVC, animated: true)
}
}
extension MainViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return pokemonData.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PokemonCell.id, for: indexPath) as? PokemonCell else {
return UICollectionViewCell()
}
cell.configure(pokemonData[indexPath.row])
return cell
}
}
UICollectionViewCell
을 곧 구현할 것이기 때문에 있다고 가정하고 구현해야 하는 코드를 거의 다 작성하였다.
컬렉션 뷰의 셀을 클릭하면 해당 포켓몬의 상세 정보가 나오는 화면도 구현할 것이지만, 조금 더 나중에 구현할 것이므로 didSelectItemAt
도 작성한 뒤 에러가 나지 않도록 주석처리 하였다.
이렇게까지만 구현하고, UICollectionViewCell
을 구현하였다.
import UIKit
import RxSwift
class PokemonCell: UICollectionViewCell {
static let id = "PokemonCell"
private let imageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.backgroundColor = UIColor.cellBackground
imageView.layer.cornerRadius = 10
return imageView
}()
override func prepareForReuse() {
super.prepareForReuse()
imageView.image = nil
}
override init(frame: CGRect) {
super.init(frame: frame)
contentView.addSubview(imageView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(_ pokemonData: PokemonData) {
let mainViewModel = MainViewModel()
let disposeBag = DisposeBag()
mainViewModel.fetchPokemonImage(pokemonData)
.observe(on: SerialDispatchQueueScheduler(qos: .default))
.subscribe(onSuccess: { [weak self] url in
if let data = try? Data(contentsOf: url) {
if let image = UIImage(data: data) {
DispatchQueue.main.async {
self?.imageView.image = image
}
}
}
}, onError: { error in
print("이미지 에러 : \(error)")
}).disposed(by: disposeBag)
}
}
이미지 URL 처리는 백그라운드 스레드에서 비동기로 처리하고, ui를 그리는 작업은 메인 스레드에서 동기적으로 작업 한다는 것을 잘 알고 있으면서,
configure
메서드에서 MainViewModel
의 fetchPokemonImage
로부터 이미지 url을 반환받도록 설계하는 바람에 어떻게 할지 고민하다가 위와 같이 작성해보게 되었다.
실행이 되는지 확인해보고자 빌드를 하는데,
Thread 1: "UICollectionView must be initialized with a non-nil layout parameter"
이런 에러가 발생했다.
MainViewController
에서 컬렉션 뷰를 선언할 때,
private lazy var collectionView: UICollectionView = {
let collectionView = UICollectionView()
collectionView.register(PokemonCell.self, forCellWithReuseIdentifier: PokemonCell.id)
collectionView.delegate = self
collectionView.dataSource = self
collectionView.backgroundColor = UIColor.darkRed
return collectionView
}()
이렇게 해서 생긴 에러였다.
UICollectionView
를 생성할 때 반드시 UICollectionViewLayout
객체를 함께 초기화해야 하는데
그냥 let collectionView = UICollectionView()
이렇게 작성해버려서 layout
파라미터가 nil로 설정 되어버린 것이다.
private lazy var collectionView: UICollectionView = {
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())
collectionView.register(PokemonCell.self, forCellWithReuseIdentifier: PokemonCell.id)
collectionView.delegate = self
collectionView.dataSource = self
collectionView.backgroundColor = UIColor.darkRed
return collectionView
}()
UICollectionViewFlowLayout()
을 사용하여 초기화 해주었다.
이제 빌드는 되는데 셀의 이미지가 뜨지 않았다.
별다른 에러 문구 없이 아무 셀도 뜨지 않은 것을 보니 url이 잘못되었거나, 서버에서 데이터를 불러오는데 실패했거나, 디코딩에 실패한 것은 아니었다.
UICollectionViewCell
에 print("data : \(data)")
를 추가하여 데이터가 넘어오는지 확인하였는데, 잘 넘어오는 것을 확인할 수 있었다.
흐름을 다시 짚어가며 코드를 쭉 살펴보는데 굉장히 간단한 문제였다.
UICollectionViewCell
에서 이미지뷰의 레이아웃 설정 코드를 작성하지 않은 것이었다.
private func setupUI() {
[
imageView
].forEach { contentView.addSubview($0) }
imageView.snp.makeConstraints {
$0.width.height.equalTo(contentView.snp.width)
}
}
위와 같이 컬렉션뷰 셀의 이미지뷰 레이아웃을 간단하게 작성하여 빌드하였다.
이번엔 한 줄에 3개의 이미지씩 나오지 않고 6개씩 나오게 되었다.
이 문제는 전에 해결해본 적 있다.
extension MainViewController: UICollectionViewDelegateFlowLayout {
// 셀 크기 설정
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let itemsPerRow: CGFloat = 3 // 가로로 배치할 셀 개수
let spacing: CGFloat = 10
let totalSpacing = (itemsPerRow - 1) * spacing // 전체 간격 계산
let width = (collectionView.frame.width - totalSpacing - 20) / itemsPerRow // 셀 너비 계산
return CGSize(width: floor(width), height: floor(width))
}
// 행 간 간격 설정
func collectionView(
_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
return 10
}
// 여백 설정
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
let spacing: CGFloat = 10
return UIEdgeInsets(top: spacing, left: spacing, bottom: spacing, right: spacing)
}
// 열 간 간격 설정
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
return 10
}
}
뷰 컨트롤러에 UICollectionViewDelegateFlowLayout
를 채택하고 간격과 여백 설정을 하여 한 줄에 3개씩 셀이 들어가도록 코드를 작성하여 문제를 해결하였다.
UI 문제를 해결한 뒤 코드를 쭉 다시 돌아보는데,
// PokemonCell (UICollectionViewCell)
func configure(_ pokemonData: PokemonData) {
let mainViewModel = MainViewModel()
let disposeBag = DisposeBag()
mainViewModel.fetchPokemonImage(pokemonData)
.observe(on: SerialDispatchQueueScheduler(qos: .default))
.subscribe(onSuccess: { [weak self] url in
if let data = try? Data(contentsOf: url) {
if let image = UIImage(data: data) {
DispatchQueue.main.async {
self?.imageView.image = image
}
}
}
}, onError: { error in
print("이미지 에러 : \(error)")
}).disposed(by: disposeBag)
}
이 코드가 신경쓰였다.
뷰모델에서 네트워크 통신을 포함한 비지니스 로직을 처리한 뒤 뷰에 해당하는 UICollectionViewCell
에서는 바인딩만 하는 것이 이상적이라고 생각되는데, 지금 이 형태는 MainViewModel
에서 url을 받아서 직접 네트워크 통신을 하는 구조로 되어 있다.
.observe(on:)
으로 스레드를 변경한 뒤에 DispatchQueue.main.async
로 메인으로 다시 변경하는 구조인데, 안될 건 없지만 MVVM 구조를 공부하고 활용하는 중에 이런 코드는 많이 불편하게 느껴졌다.
// MainViewModel 수정
private let baseUrl = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/"
func fetchPokemonImage(_ pokemonData: PokemonData) -> Single<UIImage> {
guard let imageUrl = URL(string: baseUrl + "\(pokemonData.id).png") else {
return Single.error(NetworkError.invalidUrl)
}
return Single.create { single in
let task = URLSession.shared.dataTask(with: imageUrl) { data, response, error in
if let error = error {
single(.failure(error))
return
}
guard let data = data, let image = UIImage(data: data) else {
single(.failure(NetworkError.invalidData))
return
}
single(.success(image))
}
task.resume()
return Disposables.create {
task.cancel()
}
}
}
MainViewModel
의 fetchPokemonImage
코드를 Single을 반환하는 코드에서 Single를 반환하는 코드로 변경하였다.
이제 네트워크 통신은 뷰모델에서 하고, 뷰에 해당하는 컬렉션뷰 셀에서는 이 이미지를 받아다가 띄우는 작업만 하게 될 것이다.
따라서 UICollectionViewCell
에 있는 configure
메서드도 변경하였다.
// PokemonCell (UICollectionViewCell)
func configure(_ pokemonData: PokemonData) {
let mainViewModel = MainViewModel()
mainViewModel.fetchPokemonImage(pokemonData)
.observe(on: MainScheduler.instance)
.subscribe(onSuccess: { [weak self] image in
self?.imageView.image = image
}, onError: { error in
print("이미지 에러: \(error)")
}).disposed(by: disposeBag)
}
이미지를 띄우는 기능만 하도록 단순화 하여 좀 더 간결하고 구조도 MVVM 패턴에 더 부합하도록 바꾸었고, configure 메서드가 호출 될 때마다 불필요하게 호출되어 초기화 되는 let disposeBag = DisposeBag()
도 UICollectionViewCell 안쪽으로 이동하였다.
// MainViewControlle 수정
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PokemonCell.id, for: indexPath) as? PokemonCell else {
return UICollectionViewCell()
}
cell.configure(pokemonData[indexPath.row], mainViewModel)
return cell
}
// PokemonCell의 configure메서드에서 mainViewModel 인스턴스 생성 코드 삭제
func configure(_ pokemonData: PokemonData, _ viewModel: MainViewModel) {
viewModel.fetchPokemonImage(pokemonData)
.observe(on: MainScheduler.instance)
.subscribe(onSuccess: { [weak self] image in
self?.imageView.image = image
}, onError: { error in
print("이미지 에러: \(error)")
}).disposed(by: disposeBag)
}
이후 MainViewController
의 cellForItemAt
에서도 configure()
를 호출할 때 아예 mainViewModel
인스턴스를 파라미터로 넘겨서 configure() 메서드 내에서 MainViewModel의 인스턴스를 생성하지 않도록 변경하였다.
extension MainViewController: UICollectionViewDelegate {
// 셀 선택 처리
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
// detailViewController로 이동
let selectedCell = pokemonData[indexPath.row]
let detailVC = DetailViewController()
detailVC.setDetailViewData(selectedCell)
self.navigationController?.pushViewController(detailVC, animated: true)
}
}
주석처리 했던 MainViewController
의 didSelectItemAt
를 활성화하고, MainViewController를 작성하기 전에 DetailViewModel
를 먼저 작성하였다.
import UIKit
import RxSwift
class DetailViewModel {
private let disposeBag = DisposeBag()
func fetchPoekemonDetailData(_ pokemonData: PokemonData) -> Single<PokemonDetail> {
guard let url = URL(string: "\(pokemonData.url)") else {
return Single.error(NetworkError.invalidUrl)
}
return NetworkManager.shared.fetch(url: url)
}
}
DetailViewModel
에서는 네트워크 통신을 통해 해당 포켓몬의 이름과 키, 몸무게, 타입을 받아오면 되는 화면인데 Single<T>
를 반환하는 NetworkManager
의 fetch
메서드 덕분에 굉장히 간결하게 구현이 가능하였다.
- DetailViewController 구현
- 영어로 제공되는 포켓몬 데이터 한글로 번역
- 무한 스크롤 기능 구현
정도 남았고, 기한이 1월 6일까지인 걸 고려하면 생각보다 빠르게 진행되어서
Observable
,Subject
,Relay
의 차이를 공부하고,ViewModel
에서Relay
를 활용해보세요.- 가장 많이 활용되는 이미지 라이브러리인
Kingfisher
를 활용해보세요.
이 부분도 도전해볼만 할 것 같다.
전에 넷플릭스 클론 코딩 할 때 BehaviorSubject
를 쓰는 걸 보고서 '저게 뭐지?' 했던 터라 RxSwift 강의를 들을 때 Subject
와 Relay
를 좀 더 자세히 봤던 것이 기억이 난다.
둘 다 Observable
의 역할과 Observer
의 역할을 겸하는 공통점이 있었는데, Relay는 완료나 에러 이벤트를 방출하지 않는 객체여서 UI 이벤트 처리에 사용된다고 했었다.
이번에 UI 처리는 컬렉션뷰와 이미지, 레이블 정도가 전부이기 때문에 낯설지 않아서 재밌게 공부하며 구현할 수 있을 것 같다.
그리고 전에 Kingfisher 라이브러리가 많이 편하고 현업에서도 많이 사용된다는 말을 들은적이 있는데 아는바가 없어서 이것도 이번 기회에 공부하면 좋을 것 같다.