[TIL] iOS 심화 주차 과제 : 포켓몬 도감 앱 만들기 day.03

Emily·2025년 1월 2일
1

PictorialBookApp

목록 보기
3/5

Model, View, ViewModel의 구현을 마쳤다. 이제 남은 것은 데이터 바인딩이다. 나는 이번에 새로운 방식을 연습해보고 싶어서 UICollectionViewDiffableDataSource를 사용했는데, 개념을 정리하면서 내가 구현한 내용을 함께 작성해보려 한다.

UICollectionViewDiffableDataSource

UICollectionViewDataSource프로토콜이었지만 이 친구는 클래스다. 그래서 프로토콜을 채택한 뒤 메소드를 통해 구현하는 것이 아니라 인스턴스를 생성하여 컬렉션 뷰의 dataSource에 할당해준다.

https://developer.apple.com/documentation/uikit/uicollectionviewdiffabledatasource-9tqpa/

diffable data source 객체는 collection view 객체와 함께 동작한다. 컬렉션 뷰의 데이터와 UI의 업데이트를 간단하고 효율적으로 관리할 수 있는 기능을 제공한다. 또한 UICollectionViewDataSouce 프로토콜을 따르기 때문에 여기서 제공하는 메소드를 사용할 수 있다.

컬렉션 뷰에 데이터를 채우기 위해서는 다음을 수행해야 한다.
1. collection viewdiffable data source를 연결 - initializer 사용
2. cell을 설정하기 위해 cell provider를 구현 - initializer 사용
3. 데이터의 최신 버전을 공급 - snapshot 사용
4. 데이터를 UI에 적용

1. collection view에 diffable data source를 연결

공식문서에서 생성자를 사용해서 연결하라고 했으니, diffable data source를 생성해보겠다. 우선 생성할 때 SectionIdentifierTypeItemIdentifierType을 제네릭에 넣어줘야 한다. Sectionenum으로 만들어서 넣어주고, ItemModel의 타입을 그대로 넣어주겠다.

enum Section {
	case main
}

포켓몬 도감 앱 컬렉션 뷰에는 섹션이 하나 밖에 없기 때문에 main만 선언해주었다.

나는 collection view를 컨테이너 뷰에 정의하지 않고 클래스를 분리해서 구현했기 때문에, diffable data source의 생성자에 collectionView 파라미터로 self를 넣어줘야 했다. 이건 self인 컬렉션 뷰가 메모리에 올라간 뒤에 인식이 가능하므로, diffable data sourcelazy 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를 할당해 준 것이다.

2. cell provider 구현

cell provider는 기존 UICollectionViewDataSourcecellForItemAt 메소드의 구현부를 그대로 복붙한다고 생각하면 된다.

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
    }
}()

이 때, cellconfigure 함수의 내용은 다음과 같다.

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의 프로퍼티인 이미지 뷰에 바인딩 해주는 것이다.

3. snapshot 사용하여 데이터의 최신 버전을 공급

snapshot이 본격적인 데이터 바인딩 역할을 한다. 1. 스냅샷을 선언하고, 2. 스냅샷에 section을 추가하고, 3. sectionitems(데이터)를 추가한 뒤 4. data source에 적용시킨다.

  1. snapshot을 정의할 때 diffableDataSource 선언부에 넣은 sectionitem 타입의 제네릭을 똑같이 넣어주어야 한다.
private var snapshot = NSDiffableDataSourceSnapshot<Section, PokemonResult>()
  1. snapshotsection을 추가해준다. 이 때 appendSections 메소드를 사용하며 파라미터로는 section의 배열([SectionIdentifierType])을 넣어준다.
// section을 추가하는 건 초기 설정이기 때문에, collection view의 생성자에서 추가해주었다.
class PokemonCollectionView: UICollectionView {
	private var snapshot = NSDiffableDataSourceSnapshot<Section, PokemonResult>()
    
    init() {
    	// ... //
        
        snapshot.appendSections([.main])
    }
  1. collection view와 바인딩할 데이터를 받아 snapshot에 추가해준다.
func updateDataSource(with items: [PokemonResult]) {
	snapshot.appendItems(items, toSection: .main)
}
  1. 최신 상태로 업데이트 된 snapshotdata source에 적용한다.
func updateDataSource(with items: [PokemonResult]) {
	snapshot.appendItems(items, toSection: .main)
    // data source에 적용
    diffableDataSource.apply(snapshot, animatingDifferences: true)
}

animatingDifferences의 경우 true로 해주면 데이터가 추가될 때 자연스러운 애니메이션이 적용된다.

데이터 바인딩

MainView

여기까지 했으면 이제 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

우선 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을 해준 뒤 표시 필요

nametype은 과제 요구사항에서 번역을 도와주는 소스 코드를 제공해줘서 그걸 활용해 계산 프로퍼티로 정의하였다.

var translatedName: String {
	PokemonNameTranslator.getKoreanName(for: name)
}
    
var translatedType: String {
    PokemonTypeTranslator(rawValue: name)?.toKorean ?? name
}

heightweight의 경우 처음에는 configure 함수에서 * 0.1을 해주었었다.

heightLabel.text = "키 : \(pokemon.height * 0.1)m"
weightLabel.text = "몸무게 : \(pokemon.weight * 0.1)kg"

그런데 이렇게 해주니까 아래와 같은 화면이 연출되기도 했다.

삐삐의 키가 영점육영영영영영영영영영영영영영영일미터로 표시된 모습

그래서 소수점 첫번째 자리까지 잘라서 표시하도록 조치하였다. Doubleextension을 활용했다.

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)
    }
}

이 때 중요한 점은 DetailViewModelprivate이 아니고 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 모델의 프로퍼티로 저장되어 있었다.

데이터 바인딩이 완료된 모습
profile
iOS Junior Developer

2개의 댓글

comment-user-thumbnail
2025년 1월 2일

안 들어올 수 없는 썸네일의 0.60000000000000000001

1개의 답글