어제에 이어 포켓몬 도감앱을 마저 작성하였다.
오늘은 DetailViewController
를 구현하여 큼직한 기능 구현은 마무리 하는 단계였다.
import UIKit
import RxSwift
import SnapKit
class DetailViewController: UIViewController {
private let detailViewModel = DetailViewModel()
private let disposeBag = DisposeBag()
// 포켓몬 정보를 띄울 배경 뷰
private let pokemonView: UIView = {
let view = UIView()
view.backgroundColor = UIColor.darkRed
view.layer.cornerRadius = 10
return view
}()
// 포켓몬 이미지뷰
private let pokemonImageView: UIImageView = {
let imageView = UIImageView()
imageView.backgroundColor = UIColor.darkRed
imageView.contentMode = .scaleAspectFill
return imageView
}()
// 포켓몬 번호와 이름 스택뷰
private let nameStackView: UIStackView = {
let stackView = UIStackView()
stackView.axis = .horizontal
stackView.spacing = 10
stackView.alignment = .center
stackView.distribution = .fill
return stackView
}()
// 포켓몬 정보 스택뷰
private let dataStackView: UIStackView = {
let stackView = UIStackView()
stackView.axis = .vertical
stackView.spacing = 10
stackView.alignment = .center
stackView.distribution = .fillEqually
return stackView
}()
// 포켓몬 번호 레이블
private let numberLabel: UILabel = {
let label = UILabel()
label.text = "No."
label.font = UIFont.boldSystemFont(ofSize: 24)
label.textAlignment = .center
label.textColor = .white
return label
}()
// 포켓몬 이름 레이블
private let nameLabel: UILabel = {
let label = UILabel()
label.text = "이름"
label.font = UIFont.boldSystemFont(ofSize: 24)
label.textAlignment = .center
label.textColor = .white
return label
}()
// 포켓몬 타입 레이블
private let typeLabel: UILabel = {
let label = UILabel()
label.text = "타입: "
label.font = UIFont.boldSystemFont(ofSize: 18)
label.textAlignment = .center
label.textColor = .white
return label
}()
// 포켓몬 키 레이블
private let heightLabel: UILabel = {
let label = UILabel()
label.text = "키: "
label.font = UIFont.boldSystemFont(ofSize: 18)
label.textAlignment = .center
label.textColor = .white
return label
}()
// 포켓몬 몸무게 레이블
private let weightLabel: UILabel = {
let label = UILabel()
label.text = "몸무게: "
label.font = UIFont.boldSystemFont(ofSize: 18)
label.textAlignment = .center
label.textColor = .white
return label
}()
override func viewDidLoad() {
super.viewDidLoad()
configureUI()
}
private func configureUI() {
view.backgroundColor = UIColor.mainRed
// 배경 뷰 추가
view.addSubview(pokemonView)
// 배경 뷰에 이미지와 스택뷰 추가
[
pokemonImageView,
nameStackView,
dataStackView,
].forEach { pokemonView.addSubview($0)}
// 번호와 이름을 horizontal 스택뷰에 추가
[
numberLabel,
nameLabel,
].forEach { nameStackView.addArrangedSubview($0) }
// 타입, 키, 몸무게를 vertical 스택뷰에 추가
[
typeLabel,
heightLabel,
weightLabel,
].forEach { dataStackView.addArrangedSubview($0) }
pokemonView.snp.makeConstraints {
$0.top.equalTo(view.safeAreaLayoutGuide.snp.top)
$0.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(32)
$0.height.equalTo(view.safeAreaLayoutGuide.snp.height).multipliedBy(0.6)
}
pokemonImageView.snp.makeConstraints {
$0.top.equalTo(pokemonView.snp.top).offset(40)
$0.centerX.equalTo(pokemonView.snp.centerX)
$0.width.height.equalTo(pokemonView.snp.width).multipliedBy(0.6)
}
nameStackView.snp.makeConstraints{
$0.top.equalTo(pokemonImageView.snp.bottom).offset(12)
$0.centerX.equalTo(pokemonView.snp.centerX)
$0.height.equalTo(pokemonImageView.snp.height).multipliedBy(0.25)
}
dataStackView.snp.makeConstraints {
$0.top.equalTo(nameStackView.snp.bottom).offset(8)
$0.centerX.equalTo(pokemonView.snp.centerX)
$0.width.equalTo(nameStackView.snp.width)
}
}
위와 같이 코드를 작성해 UI에 대한 코드 먼저 작성하였다.
어두운 배경의 뷰 안에 이미지와, 번호, 이름,타입, 키, 몸무게가 나와야 하므로 UIView
하나와 UIImageView
하나, UILabel
5개를 선언하였다.
그리고 번호와 이름은 같은 줄에 나와야 하므로 UIStackView
로 묶고자 했고,
타입, 키, 몸무게도 각각 따로 레이아웃을 잡을 필요 없이 하나의 스택 뷰로 묶어 관리되도록 하고자 했기 때문에 horizontal
과 vertical
스택뷰 하나씩 선언하였다.
func setDetailViewData(_ data: PokemonData) {
detailViewModel.fetchPoekemonDetailData(data)
.observe(on: MainScheduler.instance)
.subscribe(onSuccess: { [weak self] pokemonDetail in
self?.updateUI(with: pokemonDetail)
}, onError: { error in
print("데이터 에러: \(error)")
}).disposed(by: disposeBag)
}
func updateUI(with pokemonDetail: PokemonDetail) {
nameLabel.text = pokemonDetail.name
numberLabel.text = "No.\(pokemonDetail.id)"
heightLabel.text = "키: \(pokemonDetail.height)"
weightLabel.text = "몸무게: \(pokemonDetail.weight)"
typeLabel.text = "타입: " + pokemonDetail.types.map { $0.type.name }.joined(separator: ", ")
}
// 이런식으로 리팩토링 할까 생각중
MainViewController
에서 didSelectItemAt
메서드를 작성할 때 미리 썼던 setDetailViewData
메서드를 선언하고 바인딩 하는 코드를 작성하였다.
func setDetailViewData(_ data: PokemonData) {
detailViewModel.fetchPoekemonDetailData(data)
.observe(on: MainScheduler.instance)
.subscribe(onSuccess: { [weak self] pokemonDetail in
self?.nameLabel.text = pokemonDetail.name
self?.numberLabel.text = "No.\(pokemonDetail.id)"
self?.heightLabel.text = "키: \(pokemonDetail.height)"
self?.weightLabel.text = "몸무게: \(pokemonDetail.weight)"
self?.typeLabel.text = "타입: " + pokemonDetail.types.map { $0.type.name }.joined(separator: ", ")
}, onError: { error in
print("데이터 에러: \(error)")
}).disposed(by: disposeBag)
}
이 과정에서 조금 시간이 걸렸다. UI는 화면에 나오는데, 자꾸 디코딩 에러가 나서 UI 업데이트가 안 된 것이다.
디코딩 에러라고 하면, 데이터는 잘 불러왔는데 디코딩하는 과정에서 문제가 생긴 것이니, url 문제나 서버 응답 문제는 아니고 데이터를 저장할 구조체가 잘못 되었다고 판단하였다.
포켓몬 api 반환 데이터를 다시 살펴보는데, 반환해주는 데이터가 매우 많아 문제를 찾기 힘들었다. 포켓몬 api 깃허브도 가보고 공식사이트를 찾아보다가, 공식사이트의 한참 밑에 각각의 데이터에 대해 설명하는 Description이 있는 것을 발견하였다.
다음부턴 어떤 api를 쓰든 Cmd+F로 Description부터 찾아야 할 것 같다..
문제는 포켓몬 타입을 받는 데이터가 문제였다. 서버로부터 받는 데이터에 포켓몬 타입에 대한 데이터가 types라는 리스트 안에 String 타입의 type이 있는 건줄 알고,
import Foundation
struct PokemonDetail: Codable {
let id: Int
let name: String
let weight: Int
let height: Int
let types: [Types]
}
struct Types: Codable {
let type: String
}
구조체를 이렇게 선언했었는데, types 안에 type 안에 String 타입의 name이라는 형태로 들어있었다.
import Foundation
struct PokemonDetail: Codable {
let id: Int
let name: String
let weight: Int
let height: Int
let types: [Types]
}
struct Types: Codable {
let type: Name
}
struct Name: Codable {
let name: String
}
따라서 코드를 이렇게 바꾸고,
self?.typeLabel.text = "타입: " + pokemonDetail.types.map { $0.type.name }.joined(separator: ", ")
setDetailViewData 메서드에서 포켓몬 타입을 바인딩 하는 것도 위처럼 작성한 것이다.
이후 포켓몬 이미지까지 받아서 UI를 업데이트 해야했는데 이 부분도 고민이 생겼다.
이미지 처리는 MainViewModel
에서 하는 상태인데, MainViewModel
의 값을 MainViewController
가 아닌 DetailViewController
가 바인딩해도 되는 것인가 였다.
https://velog.io/@ryuhyewon/MVVM-%EA%B0%9C%EB%85%90%EA%B3%BC-%EC%9E%A5%EB%8B%A8%EC%A0%90
https://velog.io/@rjsdnql123/TILReact%EC%99%80-MVVM%ED%8C%A8%ED%84%B4
MVVM에 대해 찾아보니 MVVM 패턴은 ViewModel과 View가 1:N 관계로 적용 되는 특징이 있다고 하였다.
물론 코딩에 정답은 없는 거겠지만 다수의 뷰에서 하나의 ViewModel을 재활용 하는게 MVVM 패턴을 적용하는 이상적인 상황이라고 생각하였다.
그런데 지금 내가 작성한 코드는 View인 DetailViewController
가 두 개의 ViewModel인 MainViewModel
과 DetailViewModel
을 적용하는 N:1 관계가 될 상황이라 고민이 된 것이다.
// NetworkManager 클래스
func fetchImage(_ url: URL) -> Single<UIImage> {
return Single.create { single in
let task = URLSession.shared.dataTask(with: url) { 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()
}
}
}
고민하다가, 위오 같이 이미지를 받아오는 메서드는 fetch
메서드와 마찬가지로 두개의 뷰 모델에 전부 필요하니 NetworkManager
클래스로 옮기기로 하였다.
이렇게 하면 MainViewModel
에서 작성한 이미지 메서드도 좀 더 단순해지고, 두 개의 뷰모델에서 중복 코드 작성을 하지 않고 재사용 가능해지기 때문이다.
func fetchPokemonImage(_ pokemonData: PokemonData) -> Single<UIImage> {
guard let imageUrl = URL(string: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/\(pokemonData.id).png") else {
return Single.error(NetworkError.invalidUrl)
}
return NetworkManager.shared.fetchImage(imageUrl)
}
MainViewModel
과 DetailViewModel
양쪽에는 위와 같이 UIImage
또는 error
를 방출하도록 작성하여 각각의 View에서 이미지를 바인딩할 수 있게 하였다.
처음엔 아예 이 코드도 쓰지 않고 NetworkManager
에서 View
로 바로 이미지를 받도록 할까도 생각했는데, 그렇게 하면 View가 ViewModel이 아니라 네트워크 매니저에게 데이터를 받는 구조라 MVVM이랑은 또 달라진다고 생각하여 ViewModel을 거치도록 만들었다.
func setDetailViewData(_ data: PokemonData) {
// 이미지 바인딩
detailViewModel.fetchPokemonImage(data)
.observe(on: MainScheduler.instance)
.subscribe(onSuccess: { [weak self] image in
self?.pokemonImageView.image = image
}, onError: { error in
print("이미지 에러: \(error)")
}).disposed(by: disposeBag)
// 나머지 UI 데이터 바인딩
detailViewModel.fetchPoekemonDetailData(data)
.observe(on: MainScheduler.instance)
.subscribe(onSuccess: { [weak self] pokemonDetail in
self?.nameLabel.text = pokemonDetail.name
self?.numberLabel.text = "No.\(pokemonDetail.id)"
self?.heightLabel.text = "키: \(pokemonDetail.height)"
self?.weightLabel.text = "몸무게: \(pokemonDetail.weight)"
self?.typeLabel.text = "타입: " + pokemonDetail.types.map { $0.type.name }.joined(separator: ", ")
}, onError: { error in
print("데이터 에러: \(error)")
}).disposed(by: disposeBag)
}
위와 같이 setDetailViewData
메서드에 이미지 바인딩 코드를 추가하여 이미지도 출력되도록 수정하였다.
포켓몬 이름을 한글화 하는 코드를 추가하고 decimetres와 hectograms 단위로 반환해주는 키와 몸무게 값을 적절히 변환한 뒤 출력하였다.
- 무한 스크롤
- Kingfisher 활용
- Relay 활용
오늘 무한 스크롤까지는 구현하려고 했었다.
사실 구현도 하긴 했는데, 데이터 로드가 굉장히 오래걸리고 뭔가 이상한 위치에 들어갔다가 스크롤하면 그때야 제자리에 들어가는 상태여서 마음에 안든다.
데이터가 제자리에 바로 안들어가는 것도 데이터 로드가 이상하게 되는 것 같은데 더 살펴보고 해결해야 할 거 같고, 데이터 로드가 오래 걸리는 문제는 캐싱이랑 Kingfisher를 공부해서 해결해야 할 것 같다.