Model
, View
, ViewModel
의 구현을 마쳤다. 이제 남은 것은 데이터 바인딩
이다. 나는 이번에 새로운 방식을 연습해보고 싶어서 UICollectionViewDiffableDataSource
를 사용했는데, 개념을 정리하면서 내가 구현한 내용을 함께 작성해보려 한다.
UICollectionViewDataSource
는 프로토콜
이었지만 이 친구는 클래스
다. 그래서 프로토콜을 채택한 뒤 메소드를 통해 구현하는 것이 아니라 인스턴스를 생성하여 컬렉션 뷰의 dataSource
에 할당해준다.
https://developer.apple.com/documentation/uikit/uicollectionviewdiffabledatasource-9tqpa/
diffable data source
객체는collection view
객체와 함께 동작한다. 컬렉션 뷰의데이터와 UI
의 업데이트를 간단하고 효율적으로 관리할 수 있는 기능을 제공한다. 또한UICollectionViewDataSouce
프로토콜을 따르기 때문에 여기서 제공하는 메소드를 사용할 수 있다.
컬렉션 뷰에 데이터를 채우기 위해서는 다음을 수행해야 한다.
1.collection view
의diffable data source
를 연결 -initializer
사용
2.cell
을 설정하기 위해cell provider
를 구현 -initializer
사용
3. 데이터의 최신 버전을 공급 -snapshot
사용
4. 데이터를UI
에 적용
공식문서에서 생성자
를 사용해서 연결하라고 했으니, diffable data source
를 생성해보겠다. 우선 생성할 때 SectionIdentifierType
과 ItemIdentifierType
을 제네릭에 넣어줘야 한다. Section
은 enum
으로 만들어서 넣어주고, Item
은 Model
의 타입을 그대로 넣어주겠다.
enum Section {
case main
}
포켓몬 도감 앱 컬렉션 뷰에는 섹션이 하나 밖에 없기 때문에 main
만 선언해주었다.
나는 collection view
를 컨테이너 뷰에 정의하지 않고 클래스를 분리해서 구현했기 때문에, diffable data source
의 생성자에 collectionView
파라미터로 self
를 넣어줘야 했다. 이건 self
인 컬렉션 뷰가 메모리에 올라간 뒤에 인식이 가능하므로, diffable data source
를 lazy var
로 선언해야 했다.
또, 공식문서에는 datsSource = UICollectionViewDiffableDataSource
로 정의함과 동시에 컬렉션 뷰의 데이터소스에 할당해주지만, 나는 따로 변수로 선언하여 정의한 뒤 할당해주었다.
class PokemonCollectionView: UICollectionView {
private lazy var diffableDataSource: UICollectionViewDiffableDataSource<Section, PokemonResult> = { // 제네릭에 section, item 타입 넣어줌
return UICollectionViewDiffableDataSource(collectionView: self) { collectionView, indexPath, itemIdentifier in
// ... cell provider ... //
}
}()
// ... //
init() {
dataSource = diffableDataSource
}
평소처럼 UICollectionViewDataSource
프로토콜을 채택하여 구현하는 방식으로 했다면, dataSource = self
가 되었겠지만 나는 따로 만들어 정의한 diffableDataSource
를 할당해 준 것이다.
cell provider
는 기존 UICollectionViewDataSource
의 cellForItemAt
메소드의 구현부를 그대로 복붙한다고 생각하면 된다.
private lazy var diffableDataSource: UICollectionViewDiffableDataSource<Section, PokemonResult> = {
return UICollectionViewDiffableDataSource(collectionView: self) { collectionView, indexPath, itemIdentifier in
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PokemonCell.identifier, for: indexPath) as? PokemonCell else { return UICollectionViewCell() }
cell.configure(itemIdentifier)
return cell
}
}()
이 때, cell
의 configure
함수의 내용은 다음과 같다.
func configure(_ pokemon: PokemonResult) {
guard let url = URL(string: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/\(pokemon.pokemonId).png") else { return }
pokemonImageView.kf.setImage(with: url)
}
day.02에서 열심히 url string
을 정제하여 추출한 포켓몬 id
값 계산 프로퍼티를 이미지 url
에 넣어준 뒤 Kingfisher
를 활용하여 PokemonCell
의 프로퍼티인 이미지 뷰에 바인딩 해주는 것이다.
snapshot
이 본격적인 데이터 바인딩 역할을 한다. 1. 스냅샷을 선언하고, 2. 스냅샷에 section
을 추가하고, 3. section
에 items(데이터)
를 추가한 뒤 4. data source
에 적용시킨다.
snapshot
을 정의할 때 diffableDataSource
선언부에 넣은 section
과 item
타입의 제네릭을 똑같이 넣어주어야 한다.private var snapshot = NSDiffableDataSourceSnapshot<Section, PokemonResult>()
snapshot
에 section
을 추가해준다. 이 때 appendSections
메소드를 사용하며 파라미터로는 section
의 배열([SectionIdentifierType]
)을 넣어준다.// section을 추가하는 건 초기 설정이기 때문에, collection view의 생성자에서 추가해주었다.
class PokemonCollectionView: UICollectionView {
private var snapshot = NSDiffableDataSourceSnapshot<Section, PokemonResult>()
init() {
// ... //
snapshot.appendSections([.main])
}
collection view
와 바인딩할 데이터를 받아 snapshot
에 추가해준다.func updateDataSource(with items: [PokemonResult]) {
snapshot.appendItems(items, toSection: .main)
}
snapshot
을 data source
에 적용한다.func updateDataSource(with items: [PokemonResult]) {
snapshot.appendItems(items, toSection: .main)
// data source에 적용
diffableDataSource.apply(snapshot, animatingDifferences: true)
}
animatingDifferences
의 경우 true
로 해주면 데이터가 추가될 때 자연스러운 애니메이션이 적용된다.
여기까지 했으면 이제 ViewModel
에서 데이터를 끌어다 snapshot
에 뿌려주면 된다. 정말 진짜 최종 데이터 바인딩만 남았다.
class MainViewModel {
let pokemonList = PublishSubject<[PokemonResult]>()
}
이 친구를 구독시키면 된다.
class MainViewController: UIViewController {
private let disposeBag = DisposeBag()
private let vm: MainViewModel = .init()
private var pokemons = [PokemonResult]()
private lazy var containerView: MainView = .init()
override func viewDidLoad() {
super.viewDidLoad()
// ... //
bind()
}
private func bind() {
// view model의 pokemonList 구독
vm.pokemonList
.observe(on: MainScheduler.instance) // ui 업데이트 사항이기 때문에 main 스레드에서 작업
.subscribe(
// 값이 방출됐을 때 동작
.onNext: { [weak self] pokemons in
// snapshot에 추가 - containerView 안에 collectionView가 있기 때문에 collectionView의 updateDataSource 함수를 호출해주는 함수를 또 만든 것이다
self?.containerView.updateCollectionViewDataSource(with: pokemons)
// 전역변수에 추가 - UICollectionViewDelegate에서 detail view를 띄울 item에 접근할 때 쓸 값
self?.pokemons.append(contentOf: pokemons)
},
.onError: { error in
print(error)
}
)
.disposed(by: disposeBag)
}
}
우선 DetailView
와 연동할 데이터 모델인 Pokemon
의 형태는 다음과 같다.
struct Pokemon: Decodable {
let id: Int
let name: String
let types: [PokemonType]
let height: Double
let weight: Double
}
struct PokemonType: Decodable {
let type: PokemonTypeName
}
struct PokemonTypeName: Decodable {
let name: String
}
id
:MainView
의 컬렉션 뷰 셀에 적용한 것처럼url
에 넣어 이미지 뷰를 띄우는 데 사용name
: 영어로 되어있기 때문에 한국어로 번역하여 뷰에 바인딩types
: 포켓몬의 속성이 하나 이상으로 구성되어 있으며, 역시 번역이 필요하다.height
: API가decimetre
단위로 제공하기 때문에m
단위 표시를 위해서는 10분의 1을 해준 뒤 표시 필요weight
: API가hectogram
단위로 제공하기 때문에kg
단위 표시를 위해서는 10분의 1을 해준 뒤 표시 필요
name
과 type
은 과제 요구사항에서 번역을 도와주는 소스 코드를 제공해줘서 그걸 활용해 계산 프로퍼티로 정의하였다.
var translatedName: String {
PokemonNameTranslator.getKoreanName(for: name)
}
var translatedType: String {
PokemonTypeTranslator(rawValue: name)?.toKorean ?? name
}
height
와 weight
의 경우 처음에는 configure
함수에서 * 0.1
을 해주었었다.
heightLabel.text = "키 : \(pokemon.height * 0.1)m"
weightLabel.text = "몸무게 : \(pokemon.weight * 0.1)kg"
그런데 이렇게 해주니까 아래와 같은 화면이 연출되기도 했다.
삐삐의 키가 영점육영영영영영영영영영영영영영영일미터로 표시된 모습
그래서 소수점 첫번째 자리까지 잘라서 표시하도록 조치하였다. Double
의 extension
을 활용했다.
extension Double {
var converted: String {
String(format: "%.1f", self * 0.1)
}
}
삐삐의 키가 영점육미터로 표시된 모습
또, 포켓몬 속성의 경우 피카츄는 전기
타입 하나만 있지만 이상해씨는 풀
과 독
으로 2개다. 처음에는 types[0]
로 첫번째 타입만 보이도록 처리했는데, 2개 이상일 경우 ,
로 구분되어 모두 표시되도록 처리했다.
이렇게 해서 configure
함수가 아래와 같이 완성되었다.
func configure(_ pokemon: Pokemon) {
guard let url = URL(string: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/\(pokemon.id).png") else { return }
pokemonImageView.kf.setImage(with: url)
nameLabel.text = "No.\(pokemon.id) \(pokemon.translatedName)"
typeLabel.text = "타입 : \(pokemon.types[0].type.translatedType)"
heightLabel.text = "키 : \(pokemon.height.converted)m"
weightLabel.text = "몸무게 : \(pokemon.weight.converted)kg"
// type이 2개 이상일 경우
if pokemon.types.count > 1 {
typeLabel.text?.append(", \(pokemon.types[1].type.translatedType)")
}
}
이제 configure
함수의 파라미터인 Pokemon
을 바인딩 해줄 차례다.
class DetailViewModel {
let pokemonDetail = PublishSubject<Pokemon>()
init(_ urlString: String = "https://pokeapi.co/api/v2/pokemon/132") {
fetchPokemonDetail(urlString)
}
}
class DetailViewController: UIViewController {
private let disposeBag = DisposeBag()
var vm: DetailViewModel
private lazy var containerView: DetailView = .init()
init(vm: DetailViewModel) {
self.vm = vm
super.init(nibName: nil, bundle: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
// ... //
bind()
}
private func bind() {
vm.pokemonDetail
.observe(on: MainScheduler.instance)
.subscribe(
onNext: { [weak self] pokemon in
// configure 함수에 방출 받은 pokemon 데이터를 전달해준다.
self?.containerView.configure(pokemon)
},
onError: {
print($0)
}
)
.disposed(by: disposeBag)
}
}
이 때 중요한 점은 DetailViewModel
이 private
이 아니고 DetailViewController
의 생성자에서 주입이 된다는 점인데, 그 이유는 DetailView
를 그릴 포켓몬 정보를 MainViewController
로부터 받아 DetailViewModel
의 생성자에 전달해줘야 하기 때문이다.
extension MainViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let vc = DetailViewController(vm: .init(pokemons[indexPath.item].url))
navigationController?.pushViewController(vc, animated: true)
}
}
컬렉션 뷰를 탭 했을 때 호출되는 didSelectItemAt
메소드에서 DetailViewController
의 생성자를 통해 DetailViewModel
을 생성하고 DetailViewModel
의 생성자에 API
통신으로 데이터를 불러와야 하는 특정 포켓몬의 url
값을 넣어주었다. API request
가 가능한 특정 포켓몬의 고유 url
은 이전에 포켓몬 리스트를 불러왔을 때 PokemonResult
모델의 프로퍼티로 저장되어 있었다.
데이터 바인딩이 완료된 모습
안 들어올 수 없는 썸네일의 0.60000000000000000001