스파르타코딩 클럽/개인프로젝트

03. 개인프로젝트2 [포켓몬스터 도감 앱 만들기]

UDDT 2025. 5. 16. 11:11

완성 결과물

 

     위와 같은 포켓몬스터 도감 앱을 만들고자 한다.

   대략적인 흐름은 다음과 같다.

1. API 링크를 사용하여 서버로부터 포켓몬 데이터를 받아온다.
2. CollectionView를 만든다.
     - flowLayout? CompositionalLayout?
3. 파싱한 데이터에서 포켓몬 이미지를 CollectionView에 반영해준다.
4. CollectionView Item을 클릭하면, 다음 화면으로 이동하게 한다.
     - presented? navigationPush?
5. 다음 화면에서 해당 포켓몬의 상세 정보를 보여준다.
6. 영어로 된 데이터를 한글화한다.
7. 무한 스크롤 기능을 구현한다.

이후 RxCocoa를 사용하여 리팩토링을 하려고 한다.

1. NetworkManager 만들기

import Foundation
import RxSwift

class NetworkManager {

    static let shared = NetworkManager()
    private init() {}

    func fetch<T: Decodable>(url: URL) -> Single<T> {
        return Single.create { observer in
            let task = URLSession.shared.dataTask(with: URLRequest(url: url)) { data, response, error in
                if let error = error {
                    observer(.failure(NetworkError.invalidUrl))
                    return
                }

                guard let data = data,
                      let response = response as? HTTPURLResponse,
                      (200..<300).contains(response.statusCode) else {
                    observer(.failure(NetworkError.dataFetchFail))
                    return
                }

                do {
                    let decodedData = try JSONDecoder().decode(T.self, from: data)
                    observer(.success(decodedData))
                } catch {
                    observer(.failure(NetworkError.dataFetchFail))
                }
            }
            task.resume()

            return Disposables.create()
        }
    }
}

    

  먼저 네트워크 매니저를 싱글톤 패턴으로 작성해주었다

 싱글톤 패턴은 객체의 인스턴스를 단 하나만 생성하도록 하는 디자인 패턴으로,

객체의 공유, 전역 접근, 메모리 절약 등의 목적으로 사용되는데

 

 바로 이 부분이 싱글톤 패턴을 적용한 부분이다

static let shared = NetworkManager()
    private init() {}

 

   shared라는 NetworkManager 타입에 속하는 전역 상수를 만들어준 뒤,

  init을 할 수 없도록 막아 다른 곳에서는 NetworkManager 인스턴스를 생성하지 못하도록 했다.

    func fetch<T: Decodable>(url: URL) -> Single<T> {
        return Single.create { observer in
            var request = URLRequest(url: url)
            request.httpMethod = "GET"
            request.allHTTPHeaderFields = ["Content-type": "application/json"]
            let task = URLSession.shared.dataTask(with: request) { data, response, error in
                if let error = error {
                    observer(.failure(NetworkError.invalidUrl))
                    return
                }

                guard let data = data,
                      let response = response as? HTTPURLResponse,
                      (200..<300).contains(response.statusCode) else {
                    observer(.failure(NetworkError.dataFetchFail))
                    return
                }

                do {
                    let decodedData = try JSONDecoder().decode(T.self, from: data)
                    observer(.success(decodedData))
                } catch {
                    observer(.failure(NetworkError.dataFetchFail))
                }
            }
            task.resume()

            return Disposables.create()
        }
    }

 

   이 부분은 서버와 통신하기 위한 메서드로,

  url을 파라미터로 받아서 Single<T> Type으로 리턴하는 메서드이다.

   Single<T> Type으로 리턴하게 되면, 하나의 값을 방출(또는 에러를 방출)하고 스트림이 종료된다

  서버로부터 정상적으로 데이터를 받아오면, decodeData를 방출하도록 코드를 작성했다.

 

  https://developer.apple.com/documentation/foundation/urlrequest/httpmethod

 

httpMethod | Apple Developer Documentation

The HTTP request method.

developer.apple.com

 

   request의 HttpMethod는 default 값으로 "GET' 메서드를 사용하지만,

 명시적으로 작성해주었다

 

2. Model: 데이터 구조체 만들기

import Foundation

struct LimitPokeData: Decodable {
    let count: Int
    let next: String
    let result: [shortInfoResult]
}

extension LimitPokeData {
    struct shortInfoResult: Decodable {
        let name: String
        let url: String
    }
}
import Foundation

struct DetailPokeData: Decodable {
    let height: Int
    let id: Int
    let name: String
    let species: Species
    let sprites: Sprites
    let types: [TypeElement]
    let weight: Int
}

extension DetailPokeData {
    struct Species: Decodable {
        let name: String
        let url: String
    }

    struct Sprites: Decodable {
        let frontDefault: String

        enum CodingKeys: String, CodingKey {
            case frontDefault = "front_default"
        }
    }

    struct TypeElement: Decodable {
        let slot: Int
        let type: pokeType
    }
}

extension DetailPokeData.TypeElement {
    struct pokeType: Decodable {
        let name: String
        let url: String
    }
}

 

 

  extension으로 모델의 계층이 보이도록 작성했다.

  그러나 위 모델로 서버에 데이터를 요청하면, response는 200으로 잘 나오는데 데이터는 nil이 나오게 된다.

  왜 그런 오류가 있었는지 살펴보고자 한다.

트러블 슈팅 : 데이터 모델 작성 시 주의할 점

   모델을 작성할 때는 주의해야할 점이 있다.

  내가 작성한 모델을 다시 보자.

import Foundation

struct LimitPokeData: Decodable {
    let count: Int
    let next: String
    let result: [shortInfoResult]
}

extension LimitPokeData {
    struct shortInfoResult: Decodable {
        let name: String
        let url: String
    }
}

 

  위의 응답을 잘 보면 모델과 다른 점이 있다.

 바로 result가 아니라 results라는 점...    

 

  서버로부터 받는 데이터 모델링을 할 때는 이러한 사소한 오류를 범하지 않도록 주의해야 한다.

 Swift에서 JSON 데이터에 접근할 때는 Key와 value 형태로 접근하게 되는데

 서버에 있는 key와 모델의 key가 다르면, 서버와 연결은 제대로 되었더라도 데이터를 제대로 파싱하지 못한다.

 

  따라서 직접 손으로 치는 것보다는, 서버에 있는 key 값을 복사해서 사용하거나

  https://quicktype.io/ 같은 사이트를 사용하는 것을 권장한다.

3-1. MainViewModel 구현

import UIKit
import RxSwift

class MainViewModel {

    private let disposeBag = DisposeBag()

    let limitPokeSubject = BehaviorSubject(value: [LimitPokeData]())
    let DetailPokeSubject = BehaviorSubject(value: [DetailPokeData]())

    init() {
        fetchLimitPokeData()
    }

    func fetchLimitPokeData() {
        guard let url = NetworkManager.shared.getLimitPokeUrl(limit: "20", offset: "0") else {
            limitPokeSubject.onError(NetworkError.invalidUrl)
            return
        }

        NetworkManager.shared.fetch(url: url)
            .subscribe(onSuccess: { [weak self]
                (limitPokeData: LimitPokeData) in
                self?.limitPokeSubject.onNext([limitPokeData])
            }, onFailure: { [weak self] error in
                self?.limitPokeSubject
                    .onError(NetworkError.dataFetchFail)
            }).disposed(by: disposeBag)
    }
}

 

    RxSwift를 사용하여 MainViewModel을 작성했다.

   포켓몬 데이터는 BehaviorSubject를 사용했는데, 사용한 이유는 다음과 같다.

   1. BehaviorSubject는 초기값을 가지며, 구독을 시작하면 가장 최근에 방출되었던 값을 받으며 구독을 시작

   2. 포켓몬 데이터는 20개씩만 불러오는데, 20개의 로딩이 끝나면 다음 값을 받아와야 함

   

   결론적으로, 포켓몬 데이터는 기존의 값과 다음 값을 누적해서 가져와야한다.

   따라서 나는 포켓몬 데이터를 BehaviorSubject Type으로 설정해주었다.

    let limitPokeSubject = BehaviorSubject(value: [LimitPokeData]())
    let DetailPokeSubject = BehaviorSubject(value: [DetailPokeData]())

 

    이후, 데이터를 불러오는 함수를 작성해주었다

    func fetchLimitPokeData() {
        guard let url = NetworkManager.shared.getLimitPokeUrl(limit: "20", offset: "0") else {
            limitPokeSubject.onError(NetworkError.invalidUrl)
            return
        }

        NetworkManager.shared.fetch(url: url)
            .subscribe(onSuccess: { [weak self]
                (limitPokeData: LimitPokeData) in
                self?.limitPokeSubject.onNext([limitPokeData])
            }, onFailure: { [weak self] error in
                self?.limitPokeSubject
                    .onError(NetworkError.dataFetchFail)
            }).disposed(by: disposeBag)
    }

 

  MainViewModel은 NetworkManager를 구독하고 있다가,

 NetworkManager가 fetch 메서드를 실행하면, limitPokeData를 방출하도록 작성해주었다.

 

3-2. MainViewModel 개선하기

   위에서 작성한 코드는 2가지 개선할 점이 있다.

   1. LimitPokeData를 방출하면 results에 한번 더 접근해주어야 한다는 점.

   2. MainViewModel이 DetailView의 데이터까지 가지고 있다는 점이다.

 

    위 2가지를 개선하기 위해 코드를 다음과 같이 수정해주었다.

import UIKit
import RxSwift

class MainViewModel {

    private let disposeBag = DisposeBag()

    let limitPokeSubject = BehaviorSubject(value: [LimitPokeData.shortInfoResult]())

    init() {
        fetchLimitPokeData()
    }

    func fetchLimitPokeData() {
        guard let url = NetworkManager.shared.getLimitPokeUrl(limit: "20", offset: "0") else {
            limitPokeSubject.onError(NetworkError.invalidUrl)
            return
        }

        NetworkManager.shared.fetch(url: url)
            .subscribe(onSuccess: { [weak self]
                (limitPokeData: LimitPokeData) in
                self?.limitPokeSubject.onNext(limitPokeData.results)
            }, onFailure: { [weak self] error in
                self?.limitPokeSubject
                    .onError(NetworkError.dataFetchFail)
            }).disposed(by: disposeBag)
    }
}

4. MainViewController 초기 세팅

     ViewController에서 반복적으로 하는 작업은 UI세팅과 제약 설정이다.

   이번에는 BaseViewController를 만들고 해당 VC를 상속하여 기능구현을 해보고자 한다.

import UIKit

class BaseViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        configureUI()
        setupConstraints()
    }

    func configureUI() {

    }

    func setupConstraints() {

    }
}

 

  위와 같은 BaseViewController를 만들어주고

  MainViewController에서 BaseViewController를 상속하여 작성하였다

5. MainViewController 작성

mport UIKit
import SnapKit
import RxSwift

final class MainViewController: BaseViewController {

    private let logoImageView = UIImageView()

    private let disposeBag = DisposeBag()

    private let viewModel = MainViewModel()

    private var limitPokeData = [LimitPokeData.shortInfoResult]()
    private var detailPokeData = [DetailPokeData]()

    private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: self.setCompositionalLayout())

    override func viewDidLoad() {
        super.viewDidLoad()
        bind()
    }

    override func configureUI() {
        super.configureUI()

        view.backgroundColor = .mainRed

        [logoImageView, collectionView].forEach {
            view.addSubview($0)
        }
        logoImageView.image = UIImage(named: "pokemonBall")

        collectionView.register(MainCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: MainCollectionViewCell.self))
        collectionView.backgroundColor = .darkRed
        collectionView.delegate = self
        collectionView.dataSource = self
    }

    override func setupConstraints() {
        super.setupConstraints()

        logoImageView.snp.makeConstraints {
            $0.top.equalTo(view.safeAreaLayoutGuide).inset(10)
            $0.size.equalTo(100)
            $0.centerX.equalToSuperview()
        }

        collectionView.snp.makeConstraints {
            $0.top.equalTo(logoImageView.snp.bottom).offset(10)
            $0.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide)
        }
    }


    private func setCompositionalLayout() -> UICollectionViewCompositionalLayout {
        let layout = UICollectionViewCompositionalLayout(section: createCollectionViewSection())
        return layout
    }


    private func createCollectionViewSection() -> NSCollectionLayoutSection {
        let itemSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1/3),
            heightDimension: .fractionalHeight(1.0)
        )

        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        item.contentInsets = .init(top: 5, leading: 0, bottom: 5, trailing: 0)

        let groupSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .fractionalHeight(1/5)
        )

        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
        group.interItemSpacing = NSCollectionLayoutSpacing.fixed(10)

        let section = NSCollectionLayoutSection(group: group)
        return section
    }

    private func bind() {
        viewModel.limitPokeSubject
            .observe(on: MainScheduler.instance)
            .subscribe(onNext: { [weak self] limitData in
                self?.limitPokeData = limitData
                self?.collectionView.reloadData()
            }, onError: { error in
                print(NetworkError.dataFetchFail)
            }).disposed(by: disposeBag)
    }

}

extension MainViewController: UICollectionViewDelegate {
    // 작성 예정
}

extension MainViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        limitPokeData.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: MainCollectionViewCell.self), for: indexPath) as? MainCollectionViewCell else { return .init() }
        cell.updatePokeImage(imageData: limitPokeData[indexPath.row])
        return cell
    }
}

 

 MainViewController의 작업에서의 핵심은 CollectionView와 데이터 바인딩이므로,

 두 가지 파트를 중점적으로 기록하고자 한다.

 

  1. 컬렉션 뷰 만들기

mport UIKit
import SnapKit
import RxSwift

final class MainViewController: BaseViewController {

    private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: self.setCompositionalLayout())

    override func viewDidLoad() {
        super.viewDidLoad()
        bind()
    }

    override func configureUI() {
        super.configureUI()

        { ... } 
        
        [logoImageView, collectionView].forEach {
            view.addSubview($0)
        }
       
        collectionView.register(MainCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: MainCollectionViewCell.self))
        collectionView.backgroundColor = .darkRed
        collectionView.delegate = self
        collectionView.dataSource = self
    }

    override func setupConstraints() {
        super.setupConstraints()
 
        { ... }

        collectionView.snp.makeConstraints {
            $0.top.equalTo(logoImageView.snp.bottom).offset(10)
            $0.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide)
        }
    }


    private func setCompositionalLayout() -> UICollectionViewCompositionalLayout {
        let layout = UICollectionViewCompositionalLayout(section: createCollectionViewSection())
        return layout
    }


    private func createCollectionViewSection() -> NSCollectionLayoutSection {
        let itemSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1/3),
            heightDimension: .fractionalHeight(1.0)
        )

        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        item.contentInsets = .init(top: 5, leading: 0, bottom: 5, trailing: 0)

        let groupSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .fractionalHeight(1/5)
        )

        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
        group.interItemSpacing = NSCollectionLayoutSpacing.fixed(10)

        let section = NSCollectionLayoutSection(group: group)
        return section
    }

}

extension MainViewController: UICollectionViewDelegate {
    // 작성 예정
}

extension MainViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        limitPokeData.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: MainCollectionViewCell.self), for: indexPath) as? MainCollectionViewCell else { return .init() }
        cell.updatePokeImage(imageData: limitPokeData[indexPath.row])
        return cell
    }
}

 

   CollectionView로 Layout을 구성하는 방식에는 2가지가 있는데,

  포켓몬 도감 앱은 단순한 View이긴 하지만 학습을 위해 compositionalLayout으로 구성했다

 

  CollectionView의 인스턴스를 만들 때는 frame과 collectionViewLayout을 설정해주어야 한다.

 

private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: self.setCompositionalLayout())

 

    위와 같은 코드를 작성하기 위해 먼저 UICollectionViewLayout을 리턴하는 함수를 만들어주었다.

 

    private func setCompositionalLayout() -> UICollectionViewCompositionalLayout {
        let layout = UICollectionViewCompositionalLayout(section: createCollectionViewSection())
        return layout
    }

 

    위와 같은 코드를 작성하려면 section을 넣어주어야 한다.

    따라서 section을 return하는 함수를 만들어주었다.

 

    section을 만들어서 return하려면 group이 필요하고,

 

 

    group을 만들 때는 horizontal로 할지 vertical로 할지 선택할 수 있고,

    group의 size와 subitem들이 필요하다.

 

 

    groupSize를 원하는 크기로 지정해준 뒤,

    group subitems에 들어갈 item을 만들어주었다.

 

 

     item을 만들 때도 layoutSize가 필요하고,

    원하는 크기를 설정해주었다.

 

     아래는 최종적으로 완성된 코드이다.

    private func createCollectionViewSection() -> NSCollectionLayoutSection {
        let itemSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1/3),
            heightDimension: .fractionalHeight(1.0)
        )

        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        item.contentInsets = .init(top: 5, leading: 0, bottom: 5, trailing: 0)

        let groupSize = NSCollectionLayoutSize(
            widthDimension: .fractionalWidth(1.0),
            heightDimension: .fractionalHeight(1/4.5)
        )

        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
        group.interItemSpacing = NSCollectionLayoutSpacing.fixed(10)

        let section = NSCollectionLayoutSection(group: group)
        return section
    }

 

     fractionalWidth, fractionalHeight는 부모의 값을 기준으로 비율을 설정하게 되는 것으로,

    item의 입장에서 부모는 group이다.

    따라서 item의 넓이는 group의 넓이의 1/3로 설정하고, item의 높이는 group의 높이와 동일하게 설정했다.

 

     마찬가지로 group 입장에서 부모는 section이므로,

    group의 넓이는 section의 넓이와 동일하고, group의 높이는 section의 높이에서 1/4.5를 한 값이다.

     section의 크기는 collectionView를 기준으로 하기 때문에 section이 하나일 경우에 collectionView의 크기와 동일하다.   

 

  2. 데이터 바인딩하기

mport UIKit
import SnapKit
import RxSwift

final class MainViewController: BaseViewController {

    private let disposeBag = DisposeBag()

    private let viewModel = MainViewModel()

    private var limitPokeData = [LimitPokeData.shortInfoResult]()
    private var detailPokeData = [DetailPokeData]()

    override func viewDidLoad() {
        super.viewDidLoad()
        bind()
    }

    { ... }

    private func bind() {
        viewModel.limitPokeSubject
            .observe(on: MainScheduler.instance)
            .subscribe(onNext: { [weak self] limitData in
                self?.limitPokeData = limitData
                self?.collectionView.reloadData()
            }, onError: { error in
                print(NetworkError.dataFetchFail)
            }).disposed(by: disposeBag)
    }
}

extension MainViewController: UICollectionViewDelegate {
    // 작성 예정
}

extension MainViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        limitPokeData.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: MainCollectionViewCell.self), for: indexPath) as? MainCollectionViewCell else { return .init() }
        cell.updatePokeImage(imageData: limitPokeData[indexPath.row])
        return cell
    }
}

 

     viewModel의 프로퍼티인 limitPokeSubject는 Disposable한 객체로,

    limitPokeSubject를 구독하면 해당 데이터를 받아볼 수 있다.

    

    따라서 해당 프로퍼티에 접근하기 위해 viewModel 인스턴스를 생성해주었고,

    private let viewModel = MainViewModel()

 

    viewModel로부터 방출된 데이터를 저장할 limitPokeData 변수를 만들어주었다

    private var limitPokeData = [LimitPokeData.shortInfoResult]()

 

 

    stream이 종료되고 나면, 데이터 찌꺼기를 담아야하기 때문에 disposebag 객체도 만들어주었다.

    private let disposeBag = DisposeBag()

 

    bind라는 메서드를 만들어서, viewModel에 있는 subject를 구독하고

   데이터 방출이 있을 때 limitData를 limitPokeData에 저장한 뒤,

   collectionView를 reload하도록 코드를 작성하였다.

    private func bind() {
        viewModel.limitPokeSubject
            .observe(on: MainScheduler.instance)
            .subscribe(onNext: { [weak self] limitData in
                self?.limitPokeData = limitData
                self?.collectionView.reloadData()
            }, onError: { error in
                print(NetworkError.dataFetchFail)
            }).disposed(by: disposeBag)
    }
}

 

     데이터가 있다면 limitPokeData의 개수만큼 item을 생성하고,

    각 cell의 이미지를 업데이트하도록 처리했다.

extension MainViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        limitPokeData.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: String(describing: MainCollectionViewCell.self), for: indexPath) as? MainCollectionViewCell else { return .init() }
        cell.updatePokeImage(imageData: limitPokeData[indexPath.row])
        return cell
    }
}

 

6. MainCollectionViewCell 작성

import UIKit
import SnapKit

final class MainCollectionViewCell: BaseCollectionViewCell {

    private let imageView = UIImageView()

    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.addSubview(imageView)
        imageView.frame = contentView.bounds
    }

    @MainActor required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func configureUI() {
        self.addSubview(imageView)
        imageView.clipsToBounds = true
        imageView.layer.cornerRadius = 10
        imageView.frame = contentView.bounds
        imageView.backgroundColor = .white
    }

    func updatePokeImage(imageData: LimitPokeData.shortInfoResult) {
        var rawId = imageData.url.suffix(4)
        let id = rawId.filter { $0.isNumber }
        guard let url = URL(string: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/\(id).png") else {
            return
        }

        // 추후 KingFisher 적용 예정
        DispatchQueue.global().async {
            if let data = try? Data(contentsOf: url) {
                if let image = UIImage(data: data) {
                    DispatchQueue.main.async {
                        self.imageView.image = image
                    }
                }
            }
        }
    }
}

 

    imageData.url은 다음과 같은 형태를 가지고 있다.

url": "https://pokeapi.co/api/v2/pokemon/1/

 

    해당 url에서 숫자만 파싱하기 위해 suffix(4)를 사용하고,

   마지막 4자리 중에서 숫자만 filter하도록 로직을 작성하였다

   이미지 캐싱을 하기 위해 KingFisher를 적용해줘야하는데 이는 향후 리팩토링을 하면서 적용하고자 한다.

   (물론 4로 설정된 suffix 내부의 값도 추후 수정해야 한다)

 

7. DetailViewModel 작성

import UIKit
import RxSwift

final class DetailViewModel {

    private let disposeBag = DisposeBag()

    let detailPokeSubject: PublishSubject<DetailPokeData>

    init(detailPokeSubject: PublishSubject<DetailPokeData>) {
        self.detailPokeSubject = detailPokeSubject
        fetchDetailPokeData()
    }


    func fetchDetailPokeData(pokemonName: String? = nil) {
        guard let pokemonName else {
            return
        }
        guard let url = NetworkManager.shared.detailPokeUrl(pokemonName: pokemonName) else {
            detailPokeSubject.onError(NetworkError.invalidUrl)
            return
        }

        NetworkManager.shared.fetch(url: url)
            .subscribe(onSuccess: { [weak self] (detailPokeData: DetailPokeData) in
                self?.detailPokeSubject.onNext(detailPokeData)
            }, onFailure: { [weak self] error in
                self?.detailPokeSubject.onError(NetworkError.dataFetchFail)
            }).disposed(by: disposeBag)
    }

}

 

    앞서 MainViewModel은 BehaviorSubject를 사용했는데,

   DetailViewModel는 PublishSubject를 사용했다.

    

   PublishSubject는  초기 값을 가지지 않으며, 구독을 시작했어도 가장 최근에 방출된 값을 받지 않는다는 특징이 있다.

   DetailViewModel은 선택된 Item에 해당하는 데이터의 값만 보여주면 되고,

   값이 누적될 필요가 없기 때문에 해당 Subject를 선택했다.

   

   상세 정보의 url은 다음의 형태를 가지고 있다

https://pokeapi.co/api/v2/pokemon/{pokemonId}/

 

   따라서 viewMdoel의 fetchDetailPokeData 메서드를 실행할 때,

  파라미터로 id의 값을 주입해주도록 코드를 작성했다.

 

8. DetailView 작성

import UIKit
import SnapKit

final class DetailView: UIView {

    private let imageView = UIImageView()
    private let titleLabel = UILabel()
    private let typeLabel = UILabel()
    private let heightLabel = UILabel()
    private let weightLabel = UILabel()

    private let stackView = UIStackView()

    // 목데이터
    private let samplePokedata =
    DetailPokeData(
        height: 10, id: 1, name: "샘플",
        species: DetailPokeData.Species(name: "샘플", url: ""),
        sprites: DetailPokeData.Sprites(
            other: DetailPokeData.Sprites.Other(
                officialArtwork: DetailPokeData.Sprites.Other.OfficialArtwork(
                    frontDefault: ""))),
        types: [
        DetailPokeData.TypeElement(slot: 1, type: DetailPokeData.TypeElement.pokeType(
            name: "독", url: "")),
        DetailPokeData.TypeElement(slot: 2, type: DetailPokeData.TypeElement.pokeType(
            name: "풀", url: ""))], weight: 10
    )

    override init(frame: CGRect) {
        super.init(frame: frame)
        configureUI()
        setDetailStackView(detailPokeData: samplePokedata)
        setConstraints()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func configureUI() {
        self.addSubview(stackView)

        [imageView, titleLabel, typeLabel, heightLabel, weightLabel].forEach {
            stackView.addArrangedSubview($0)
        }

        stackView.axis = .vertical
        stackView.alignment = .center
        stackView.distribution = .equalSpacing
        stackView.backgroundColor = .darkRed
        stackView.spacing = 10
        stackView.isLayoutMarginsRelativeArrangement = true
        stackView.directionalLayoutMargins = .init(top: 40, leading: 0, bottom: 40, trailing: 0)

        titleLabel.font = .boldSystemFont(ofSize: 40)
        titleLabel.textColor = .white

        [typeLabel, heightLabel, weightLabel].forEach {
            $0.textColor = .white
            $0.font = .systemFont(ofSize: 25)
        }
    }

    func setConstraints() {
        stackView.snp.makeConstraints {
            $0.top.equalTo(self.safeAreaLayoutGuide)
            $0.leading.trailing.equalTo(self.safeAreaLayoutGuide).inset(30)
            $0.bottom.equalTo(self.safeAreaLayoutGuide).inset(300)
        }

        imageView.snp.makeConstraints {
            $0.size.equalTo(180)
        }
    }

    func setDetailStackView(detailPokeData: DetailPokeData) {
        imageView.image = UIImage(systemName: "questionmark")
        titleLabel.text = "No.\(detailPokeData.id) \(detailPokeData.name)"
        typeLabel.text = "\(detailPokeData.types[0].type.name), \(detailPokeData.types[1].type.name)"
        heightLabel.text = "\(detailPokeData.height)"
        weightLabel.text = "\(detailPokeData.weight)"
    }
}

 

   DetailView는 데이터 모델에 임의의 값을 주입해서 목데이터로 UI가 잘 나오는지 확인하면서 작업했다.

9. DetailViewController 작성 및 MainViewController 화면 전환

import UIKit

class DetailViewController: BaseViewController {

    override func loadView() {
        self.view = DetailView()
    }

    override func configureUI() {
        super.configureUI()
        view.backgroundColor = .mainRed
    }

    override func setupConstraints() {
        super.setupConstraints()
    }
}

 

 

   이후 SceneDelegate를 수정하여 navigationController를 rootVC로 설정해주었다.

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?


    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

        guard let windowScene = (scene as? UIWindowScene) else { return }
        let window = UIWindow(windowScene: windowScene)
        let mainVC = MainViewController()
        window.rootViewController = UINavigationController(rootViewController: mainVC)
        window.makeKeyAndVisible()

        self.window = window
    }
    
    { ... }

 

    SceneDelegate 수정 후, MainViewController에서 CollectionViewItem의 터치 이벤트를 구현해주었다.

extension MainViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        let detailVC = DetailViewController()
        self.navigationController?.pushViewController(detailVC, animated: true)
    }

 

10-1. DetailView 수정

    func setDetailStackView(detailPokeData: DetailPokeData) {
        DispatchQueue.global().async {
            guard let url = URL(
                string: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/\(detailPokeData.id).png"
            ) else { return }

            do {
                let image = try UIImage(data: Data(contentsOf: url))
                DispatchQueue.main.async {
                    self.imageView.image = image
                    self.titleLabel.text = "No.\(detailPokeData.id) \(detailPokeData.name)"
                    self.typeLabel.text = "Type: \(detailPokeData.types[0].type.name), \(detailPokeData.types[1].type.name)"
                    self.heightLabel.text = "height: \(detailPokeData.height)"
                    self.weightLabel.text = "weight: \(detailPokeData.weight)"
                }
            } catch {
                print(NetworkError.dataFetchFail.errorTitle)
            }
        }
    }

 

    DetailView의 목데이터를 삭제하고, 서버로부터 데이터를 받아올 수 있도록 수정해주었다.

 

 10-2. 트러블 슈팅: Index out of range 오류 해결하기

      위에서 작성한 코드를 실행하면, Index out of range 오류가 발생한다.

self.typeLabel.text = "Type: \(detailPokeData.types[0].type.name), \(detailPokeData.types[1].type.name)"

 

    바로 이 Type 부분이 문제인데, 

   어떤 포켓몬은 타입이 2개(풀, 독)이고 어떤 포켓몬은 단일 타입(불)을 가지고 있다.

 

    따라서 index 범위를 초과하지 않도록, 조건문으로 해당 데이터를 처리해주었다.

if detailPokeData.types.count == 2 {
    self.typeLabel.text = "Type: \(detailPokeData.types[0].type.name), \(detailPokeData.types[1].type.name)"
} else {
    self.typeLabel.text = "Type: \(detailPokeData.types[0].type.name)"
}

 

 11. 포켓몬 데이터 한글화하기

     enum Type으로 된 PokemonTranslator를 사용하여 이름을 매핑해줬다.

import Foundation

// 포켓몬 영어 이름을 한국어로 변환해주는 enum
enum PokemonTranslator {
  // 영어 이름과 한국어 이름 매핑 테이블
  private static let koreanNames: [String: String] = [
    "bulbasaur": "이상해씨",
    "ivysaur": "이상해풀",
    "venusaur": "이상해꽃",
    "charmander": "파이리",
    "charmeleon": "리자드",
    "charizard": "리자몽",
    "squirtle": "꼬부기",
    "wartortle": "어니부기",
    "blastoise": "거북왕",
    "caterpie": "캐터피",
    "metapod": "단데기",
    "butterfree": "버터플",
    "weedle": "뿔충이",
    "kakuna": "딱충이",
    "beedrill": "독침붕",
    "pidgey": "구구",
    "pidgeotto": "피죤",
    "pidgeot": "피죤투",
    "rattata": "꼬렛",
    "raticate": "레트라",
    "spearow": "깨비참",
    "fearow": "깨비드릴조",
    "ekans": "아보",
    "arbok": "아보크",
    "pikachu": "피카츄",
    "raichu": "라이츄",
    "sandshrew": "모래두지",
    "sandslash": "고지",
    "nidoran♀": "니드런♀",
    "nidorina": "니드리나",
    "nidoqueen": "니드퀸",
    "nidoran♂": "니드런♂",
    "nidorino": "니드리노",
    "nidoking": "니드킹",
    "clefairy": "삐삐",
    "clefable": "픽시",
    "vulpix": "식스테일",
    "ninetales": "나인테일",
    "jigglypuff": "푸린",
    "wigglytuff": "푸크린",
    "zubat": "주뱃",
    "golbat": "골뱃",
    "oddish": "뚜벅쵸",
    "gloom": "냄새꼬",
    "vileplume": "라플레시아",
    "paras": "파라스",
    "parasect": "파라섹트",
    "venonat": "콘팡",
    "venomoth": "도나리",
    "diglett": "디그다",
    "dugtrio": "닥트리오",
    "meowth": "나옹",
    "persian": "페르시온",
    "psyduck": "고라파덕",
    "golduck": "골덕",
    "mankey": "망키",
    "primeape": "성원숭",
    "growlithe": "가디",
    "arcanine": "윈디",
    "poliwag": "발챙이",
    "poliwhirl": "슈륙챙이",
    "poliwrath": "강챙이",
    "abra": "캐이시",
    "kadabra": "윤겔라",
    "alakazam": "후딘",
    "machop": "알통몬",
    "machoke": "근육몬",
    "machamp": "괴력몬",
    "bellsprout": "모다피",
    "weepinbell": "우츠동",
    "victreebel": "우츠보트",
    "tentacool": "왕눈해",
    "tentacruel": "독파리",
    "geodude": "꼬마돌",
    "graveler": "데구리",
    "golem": "딱구리",
    "ponyta": "포니타",
    "rapidash": "날쌩마",
    "slowpoke": "야돈",
    "slowbro": "야도란",
    "magnemite": "코일",
    "magneton": "레어코일",
    "farfetch'd": "파오리",
    "doduo": "두두",
    "dodrio": "두트리오",
    "seel": "쥬쥬",
    "dewgong": "쥬레곤",
    "grimer": "질퍽이",
    "muk": "질뻐기",
    "shellder": "셀러",
    "cloyster": "파르셀",
    "gastly": "고오스",
    "haunter": "고우스트",
    "gengar": "팬텀",
    "onix": "롱스톤",
    "drowzee": "슬리프",
    "hypno": "슬리퍼",
    "krabby": "크랩",
    "kingler": "킹크랩",
    "voltorb": "찌리리공",
    "electrode": "붐볼",
    "exeggcute": "아라리",
    "exeggutor": "나시",
    "cubone": "탕구리",
    "marowak": "텅구리",
    "hitmonlee": "시라소몬",
    "hitmonchan": "홍수몬",
    "lickitung": "내루미",
    "koffing": "또가스",
    "weezing": "또도가스",
    "rhyhorn": "뿔카노",
    "rhydon": "코뿌리",
    "chansey": "럭키",
    "tangela": "덩쿠리",
    "kangaskhan": "캥카",
    "horsea": "쏘드라",
    "seadra": "시드라",
    "goldeen": "콘치",
    "seaking": "왕콘치",
    "staryu": "별가사리",
    "starmie": "아쿠스타",
    "mr. mime": "마임맨",
    "scyther": "스라크",
    "jynx": "루주라",
    "electabuzz": "에레브",
    "magmar": "마그마",
    "pinsir": "쁘사이저",
    "tauros": "켄타로스",
    "magikarp": "잉어킹",
    "gyarados": "갸라도스",
    "lapras": "라프라스",
    "ditto": "메타몽",
    "eevee": "이브이",
    "vaporeon": "샤미드",
    "jolteon": "쥬피썬더",
    "flareon": "부스터",
    "porygon": "폴리곤",
    "omanyte": "암나이트",
    "omastar": "암스타",
    "kabuto": "투구",
    "kabutops": "투구푸스",
    "aerodactyl": "프테라",
    "snorlax": "잠만보",
    "articuno": "프리져",
    "zapdos": "썬더",
    "moltres": "파이어",
    "dratini": "미뇽",
    "dragonair": "신뇽",
    "dragonite": "망나뇽",
    "mewtwo": "뮤츠",
    "mew": "뮤",
    "chikorita": "치코리타",
    "bayleef": "베이리프",
    "meganium": "메가니움",
    "cyndaquil": "브케인",
    "quilava": "마그케인",
    "typhlosion": "블레이범",
    "totodile": "리아코",
    "croconaw": "엘리게이",
    "feraligatr": "장크로다일",
    "sentret": "꼬리선",
    "furret": "다꼬리",
    "hoothoot": "부우부",
    "noctowl": "야부엉",
    "ledyba": "레디바",
    "ledian": "레디안",
    "spinarak": "페이검",
    "ariados": "아리아도스",
    "crobat": "크로뱃",
    "chinchou": "초라기",
    "lanturn": "랜턴",
    "pichu": "피츄",
    "cleffa": "삐",
    "igglybuff": "푸푸린",
    "togepi": "토게피",
    "togetic": "토게틱",
    "natu": "네이티",
    "xatu": "네이티오",
    "mareep": "메리프",
    "flaaffy": "보송송",
    "ampharos": "전룡",
    "bellossom": "아르코",
    "marill": "마릴",
    "azumarill": "마릴리",
    "sudowoodo": "꼬지모",
    "politoed": "왕구리",
    "hoppip": "통통코",
    "skiploom": "두코",
    "jumpluff": "솜솜코",
    "aipom": "에이팜",
    "sunkern": "해너츠",
    "sunflora": "해루미",
    "yanma": "왕자리",
    "wooper": "우파",
    "quagsire": "누오",
    "espeon": "에브이",
    "umbreon": "블래키",
    "murkrow": "니로우",
    "slowking": "야도킹",
    "misdreavus": "무우마",
    "unown": "안농",
    "wobbuffet": "마자용",
    "girafarig": "키링키",
    "pineco": "피콘",
    "forretress": "쏘콘",
    "dunsparce": "노고치",
    "gligar": "글라이거",
    "steelix": "강철톤",
    "snubbull": "블루",
    "granbull": "그랑블루",
    "qwilfish": "침바루",
    "scizor": "핫삼",
    "shuckle": "단단지",
    "heracross": "헤라크로스",
    "sneasel": "포푸니",
    "teddiursa": "깜지곰",
    "ursaring": "링곰",
    "slugma": "마그마그",
    "magcargo": "마그카르고",
    "swinub": "꾸꾸리",
    "piloswine": "메꾸리",
    "corsola": "코산호",
    "remoraid": "총어",
    "octillery": "대포무노",
    "delibird": "딜리버드",
    "mantine": "만타인",
    "skarmory": "무장조",
    "houndour": "델빌",
    "houndoom": "헬가",
    "kingdra": "킹드라",
    "phanpy": "코코리",
    "donphan": "코리갑",
    "porygon2": "폴리곤2",
    "stantler": "노라키",
    "smeargle": "루브도",
    "tyrogue": "배루키",
    "hitmontop": "카포에라",
    "smoochum": "뽀뽀라",
    "elekid": "에레키드",
    "magby": "마그비",
    "miltank": "밀탱크",
    "blissey": "해피너스",
    "raikou": "라이코",
    "entei": "앤테이",
    "suicune": "스이쿤",
    "larvitar": "애버라스",
    "pupitar": "데기라스",
    "tyranitar": "마기라스",
    "lugia": "루기아",
    "ho-oh": "칠색조",
    "celebi": "세레비",
    "treecko": "나무지기",
    "grovyle": "나무돌이",
    "sceptile": "나무킹",
    "torchic": "아차모",
    "combusken": "영치코",
    "blaziken": "번치코",
    "mudkip": "물짱이",
    "marshtomp": "늪짱이",
    "swampert": "대짱이",
    "poochyena": "포챠나",
    "mightyena": "그라에나",
    "zigzagoon": "지그제구리",
    "linoone": "직구리",
    "wurmple": "개무소",
    "silcoon": "실쿤",
    "beautifly": "뷰티플라이",
    "cascoon": "카스쿤",
    "dustox": "독케일",
    "lotad": "연꽃몬",
    "lombre": "로토스",
    "ludicolo": "로파파",
    "seedot": "도토링",
    "nuzleaf": "잎새코",
    "shiftry": "다탱구",
    "taillow": "테일로",
    "swellow": "스왈로",
    "wingull": "갈모매",
    "pelipper": "패리퍼",
    "ralts": "랄토스",
    "kirlia": "킬리아",
    "gardevoir": "가디안",
    "surskit": "비구술",
    "masquerain": "비나방",
    "shroomish": "버섯꼬",
    "breloom": "버섯모",
    "slakoth": "게을로",
    "vigoroth": "발바로",
    "slaking": "게을킹",
    "nincada": "토중몬",
    "ninjask": "아이스크",
    "shedinja": "껍질몬",
    "whismur": "소곤룡",
    "loudred": "노공룡",
    "exploud": "폭음룡",
    "makuhita": "마크탕",
    "hariyama": "하리뭉",
    "azurill": "루리리",
    "nosepass": "코코파스",
    "skitty": "에나비",
    "delcatty": "델케티",
    "sableye": "깜까미",
    "mawile": "입치트",
    "aron": "가보리",
    "lairon": "갱도라",
    "aggron": "보스로라",
    "meditite": "요가랑",
    "medicham": "요가램",
    "electrike": "썬더라이",
    "manectric": "썬더볼트",
    "plusle": "플러시",
    "minun": "마이농",
    "volbeat": "볼비트",
    "illumise": "네오비트",
    "roselia": "로젤리아",
    "gulpin": "꼴깍몬",
    "swalot": "꿀꺽몬",
    "carvanha": "샤프니아",
    "sharpedo": "샤크니아",
    "wailmer": "고래왕자",
    "wailord": "고래왕",
    "numel": "둔타",
    "camerupt": "폭타",
    "torkoal": "코터스",
    "spoink": "피그점프",
    "grumpig": "피그킹",
    "spinda": "얼루기",
    "trapinch": "톱치",
    "vibrava": "비브라바",
    "flygon": "플라이곤",
    "cacnea": "선인왕",
    "cacturne": "밤선인",
    "swablu": "파비코",
    "altaria": "파비코리",
    "zangoose": "쟝고",
    "seviper": "세비퍼",
    "lunatone": "루나톤",
    "solrock": "솔록",
    "barboach": "미꾸리",
    "whiscash": "메깅",
    "corphish": "가재군",
    "crawdaunt": "가재장군",
    "baltoy": "오뚝군",
    "claydol": "점토도리",
    "lileep": "릴링",
    "cradily": "릴리요",
    "anorith": "아노딥스",
    "armaldo": "아말도",
    "feebas": "빈티나",
    "milotic": "밀로틱",
    "castform": "캐스퐁",
    "kecleon": "켈리몬",
    "shuppet": "어둠대신",
    "banette": "다크펫",
    "duskull": "해골몽",
    "dusclops": "미라몽",
    "tropius": "트로피우스",
    "chimecho": "치렁",
    "absol": "앱솔",
    "wynaut": "마자",
    "snorunt": "눈꼬마",
    "glalie": "얼음귀신",
    "spheal": "대굴레오",
    "sealeo": "씨레오",
    "walrein": "씨카이저",
    "clamperl": "진주몽",
    "huntail": "헌테일",
    "gorebyss": "분홍장이",
    "relicanth": "시라칸",
    "luvdisc": "사랑동이",
    "bagon": "아공이",
    "shelgon": "쉘곤",
    "salamence": "보만다",
    "beldum": "메탕",
    "metang": "메탕구",
    "metagross": "메타그로스",
    "regirock": "레지락",
    "regice": "레지아이스",
    "registeel": "레지스틸",
    "latias": "라티아스",
    "latios": "라티오스",
    "kyogre": "가이오가",
    "groudon": "그란돈",
    "rayquaza": "레쿠쟈",
    "jirachi": "지라치",
    "deoxys": "테오키스",
    "turtwig": "모부기",
    "grotle": "수풀부기",
    "torterra": "토대부기",
    "chimchar": "불꽃숭이",
    "monferno": "파이숭이",
    "infernape": "초염몽",
    "piplup": "팽도리",
    "prinplup": "팽태자",
    "empoleon": "엠페르트",
    "starly": "찌르꼬",
    "staravia": "찌르버드",
    "staraptor": "찌르호크",
    "bidoof": "비버니",
    "bibarel": "비버통",
    "kricketot": "귀뚤뚜기",
    "kricketune": "귀뚤톡크",
    "shinx": "꼬링크",
    "luxio": "럭시오",
    "luxray": "렌트라",
    "budew": "꼬몽울",
    "roserade": "로즈레이드",
    "cranidos": "두개도스",
    "rampardos": "램펄드",
    "shieldon": "방패톱스",
    "bastiodon": "바리톱스",
    "burmy": "도롱충이",
    "wormadam": "도롱마담",
    "mothim": "나메일",
    "combee": "세꿀버리",
    "vespiquen": "비퀸",
    "pachirisu": "파치리스",
    "buizel": "브이젤",
    "floatzel": "플로젤",
    "cherubi": "체리버",
    "cherrim": "체리꼬",
    "shellos": "깝질무",
    "gastrodon": "트리토돈",
    "ambipom": "겟핸보숭",
    "drifloon": "흔들풍손",
    "drifblim": "둥실라이드",
    "buneary": "이어롤",
    "lopunny": "이어롭",
    "mismagius": "무우마직",
    "honchkrow": "돈크로우",
    "glameow": "나옹마",
    "purugly": "몬냥이",
    "chingling": "랑딸랑",
    "stunky": "스컹뿡",
    "skuntank": "스컹탱크",
    "bronzor": "동미러",
    "bronzong": "동탁군",
    "bonsly": "꼬지지",
    "mime jr.": "흉내내",
    "happiny": "핑복",
    "chatot": "페라페",
    "spiritomb": "화강돌",
    "gible": "딥상어동",
    "gabite": "한바이트",
    "garchomp": "한카리아스",
    "munchlax": "먹고자",
    "riolu": "리오르",
    "lucario": "루카리오",
    "hippopotas": "히포포타스",
    "hippowdon": "하마돈",
    "skorupi": "스콜피",
    "drapion": "드래피온",
    "croagunk": "삐딱구리",
    "toxicroak": "독개굴",
    "carnivine": "무스틈니",
    "finneon": "형광어",
    "lumineon": "네오라이트",
    "mantyke": "타만타",
    "snover": "눈쓰개",
    "abomasnow": "눈설왕",
    "weavile": "포푸니라",
    "magnezone": "자포코일",
    "lickilicky": "내룸벨트",
    "rhyperior": "거대코뿌리",
    "tangrowth": "덩쿠림보",
    "electivire": "에레키블",
    "magmortar": "마그마번",
    "togekiss": "토게키스",
    "yanmega": "메가자리",
    "leafeon": "리피아",
    "glaceon": "글레이시아",
    "gliscor": "글라이온",
    "mamoswine": "맘모꾸리",
    "porygon-z": "폴리곤Z",
    "gallade": "엘레이드",
    "probopass": "대코파스",
    "dusknoir": "야느와르몽",
    "froslass": "눈여아",
    "rotom": "로토무",
    "uxie": "유크시",
    "mesprit": "엠라이트",
    "azelf": "아그놈",
    "dialga": "디아루가",
    "palkia": "펄기아",
    "heatran": "히드런",
    "regigigas": "레지기가스",
    "giratina": "기라티나",
    "cresselia": "크레세리아",
    "phione": "피오네",
    "manaphy": "마나피",
    "darkrai": "다크라이",
    "shaymin": "쉐이미",
    "arceus": "아르세우스",
    "victini": "비크티니",
    "snivy": "주리비얀",
    "servine": "샤비",
    "serperior": "샤로다",
    "tepig": "뚜꾸리",
    "pignite": "챠오꿀",
    "emboar": "염무왕",
    "oshawott": "수댕이",
    "dewott": "쌍검자비",
    "samurott": "대검귀",
    "patrat": "보르쥐",
    "watchog": "보르그",
    "lillipup": "요테리",
    "herdier": "하데리어",
    "stoutland": "바랜드",
    "purrloin": "쌔비냥",
    "liepard": "레파르다스",
    "pansage": "야나프",
    "simisage": "야나키",
    "pansear": "바오프",
    "simisear": "바오키",
    "panpour": "앗차프",
    "simipour": "앗차키",
    "munna": "몽나",
    "musharna": "몽얌나",
    "pidove": "콩둘기",
    "tranquill": "유토브",
    "unfezant": "켄호로우",
    "blitzle": "줄뮤마",
    "zebstrika": "제브라이카",
    "roggenrola": "단굴",
    "boldore": "암트르",
    "gigalith": "기가이어스",
    "woobat": "또르박쥐",
    "swoobat": "맘박쥐",
    "drilbur": "두더류",
    "excadrill": "몰드류",
    "audino": "다부니",
    "timburr": "으랏차",
    "gurdurr": "토쇠골",
    "conkeldurr": "노보청",
    "tympole": "동챙이",
    "palpitoad": "두까비",
    "seismitoad": "두빅굴",
    "throh": "던지미",
    "sawk": "타격귀",
    "sewaddle": "두르보",
    "swadloon": "두르쿤",
    "leavanny": "모아머",
    "venipede": "마디네",
    "whirlipede": "휠구",
    "scolipede": "펜드라",
    "cottonee": "소미안",
    "whimsicott": "엘풍",
    "petilil": "치릴리",
    "lilligant": "드레디어",
    "basculin": "배쓰나이",
    "sandile": "깜눈크",
    "krokorok": "악비르",
    "krookodile": "악비아르",
    "darumaka": "달막화",
    "darmanitan": "불비달마",
    "maractus": "마라카치",
    "dwebble": "돌살이",
    "crustle": "암팰리스",
    "scraggy": "곤율랭",
    "scrafty": "곤율거니",
    "sigilyph": "심보러",
    "yamask": "데스마스",
    "cofagrigus": "데스니칸",
    "tirtouga": "프로토가",
    "carracosta": "늑골라",
    "archen": "아켄",
    "archeops": "아케오스",
    "trubbish": "깨봉이",
    "garbodor": "더스트나",
    "zorua": "조로아",
    "zoroark": "조로아크",
    "minccino": "치라미",
    "cinccino": "치라치노",
    "gothita": "고디탱",
    "gothorita": "고디보미",
    "gothitelle": "고디모아젤",
    "solosis": "유니란",
    "duosion": "듀란",
    "reuniclus": "란쿨루스",
    "ducklett": "꼬지보리",
    "swanna": "스완나",
    "vanillite": "바닐프티",
    "vanillish": "바닐리치",
    "vanilluxe": "배바닐라",
    "deerling": "사철록",
    "sawsbuck": "바라철록",
    "emolga": "에몽가",
    "karrablast": "딱정곤",
    "escavalier": "슈바르고",
    "foongus": "깜놀버슬",
    "amoonguss": "뽀록나",
    "frillish": "탱그릴",
    "jellicent": "탱탱겔",
    "alomomola": "맘복치",
    "joltik": "파쪼옥",
    "galvantula": "전툴라",
    "ferroseed": "철시드",
    "ferrothorn": "너트령",
    "klink": "기어르",
    "klang": "기기어르",
    "klinklang": "기기기어르",
    "tynamo": "저리어",
    "eelektrik": "저리릴",
    "eelektross": "저리더프",
    "elgyem": "리그레",
    "beheeyem": "벰크",
    "litwick": "불켜미",
    "lampent": "램프라",
    "chandelure": "샹델라",
    "axew": "터검니",
    "fraxure": "액슨도",
    "haxorus": "액스라이즈",
    "cubchoo": "코고미",
    "beartic": "툰베어",
    "cryogonal": "프리지오",
    "shelmet": "쪼마리",
    "accelgor": "어지리더",
    "stunfisk": "메더",
    "mienfoo": "비조푸",
    "mienshao": "비조도",
    "druddigon": "크리만",
    "golett": "골비람",
    "golurk": "골루그",
    "pawniard": "자망칼",
    "bisharp": "절각참",
    "bouffalant": "버프론",
    "rufflet": "수리둥보",
    "braviary": "워글",
    "vullaby": "벌차이",
    "mandibuzz": "버랜지나",
    "heatmor": "앤티골",
    "durant": "아이앤트",
    "deino": "모노두",
    "zweilous": "디헤드",
    "hydreigon": "삼삼드래",
    "larvesta": "활화르바",
    "volcarona": "불카모스",
    "cobalion": "코바르온",
    "terrakion": "테라키온",
    "virizion": "비리디온",
    "tornadus": "토네로스",
    "thundurus": "볼트로스",
    "reshiram": "레시라무",
    "zekrom": "제크로무",
    "landorus": "랜드로스",
    "kyurem": "큐레무",
    "keldeo": "케르디오",
    "meloetta": "메로엣타",
    "genesect": "게노세크트",
    "chespin": "도치마론",
    "quilladin": "도치보구",
    "chesnaught": "브리가론",
    "fennekin": "푸호꼬",
    "braixen": "테르나",
    "delphox": "마폭시",
    "froakie": "개구마르",
    "frogadier": "개굴반장",
    "greninja": "개굴닌자",
    "bunnelby": "파르빗",
    "diggersby": "파르토",
    "fletchling": "화살꼬빈",
    "fletchinder": "불화살빈",
    "talonflame": "파이어로",
    "scatterbug": "분이벌레",
    "spewpa": "분떠도리",
    "vivillon": "비비용",
    "litleo": "레오꼬",
    "pyroar": "화염레오",
    "flabébé": "플라베베",
    "floette": "플라엣테",
    "florges": "플라제스",
    "skiddo": "메이클",
    "gogoat": "고고트",
    "pancham": "판짱",
    "pangoro": "부란다",
    "furfrou": "트리미앙",
    "espurr": "냐스퍼",
    "meowstic": "냐오닉스",
    "honedge": "단칼빙",
    "doublade": "쌍검킬",
    "aegislash": "킬가르도",
    "spritzee": "슈쁘",
    "aromatisse": "프레프티르",
    "swirlix": "나룸퍼프",
    "slurpuff": "나루림",
    "inkay": "오케이징",
    "malamar": "칼라마네로",
    "binacle": "거북손손",
    "barbaracle": "거북손데스",
    "skrelp": "수레기",
    "dragalge": "드래캄",
    "clauncher": "완철포",
    "clawitzer": "블로스터",
    "helioptile": "목도리키텔",
    "heliolisk": "일레도리자드",
    "tyrunt": "티고라스",
    "tyrantrum": "견고라스",
    "amaura": "아마루스",
    "aurorus": "아마루르가",
    "sylveon": "님피아",
    "hawlucha": "루차불",
    "dedenne": "데덴네",
    "carbink": "멜리시",
    "goomy": "미끄메라",
    "sliggoo": "미끄네일",
    "goodra": "미끄래곤",
    "klefki": "클레피",
    "phantump": "나목령",
    "trevenant": "대로트",
    "pumpkaboo": "호바귀",
    "gourgeist": "펌킨인",
    "bergmite": "꽁어름",
    "avalugg": "크레베이스",
    "noibat": "음뱃",
    "noivern": "음번",
    "xerneas": "제르네아스",
    "yveltal": "이벨타르",
    "zygarde": "지가르데",
    "diancie": "디안시",
    "hoopa": "후파",
    "volcanion": "볼케니온",
    "rowlet": "나몰빼미",
    "dartrix": "빼미스로우",
    "decidueye": "모크나이퍼",
    "litten": "냐오불",
    "torracat": "냐오히트",
    "incineroar": "어흥염",
    "popplio": "누리공",
    "brionne": "키요공",
    "primarina": "누리레느",
    "pikipek": "콕코구리",
    "trumbeak": "크라파",
    "toucannon": "왕큰부리",
    "yungoos": "영구스",
    "gumshoos": "형사구스",
    "grubbin": "턱지충이",
    "charjabug": "전지충이",
    "vikavolt": "투구뿌논",
    "crabrawler": "오기지게",
    "crabominable": "모단단게",
    "oricorio": "춤추새",
    "cutiefly": "에블리",
    "ribombee": "에리본",
    "rockruff": "암멍이",
    "lycanroc": "루가루암",
    "wishiwashi": "약어리",
    "mareanie": "시마사리",
    "toxapex": "더시마사리",
    "mudbray": "머드나기",
    "mudsdale": "만마드",
    "dewpider": "물거미",
    "araquanid": "깨비물거미",
    "fomantis": "짜랑랑",
    "lurantis": "라란티스",
    "morelull": "자마슈",
    "shiinotic": "마셰이드",
    "salandit": "야도뇽",
    "salazzle": "염뉴트",
    "stufful": "포곰곰",
    "bewear": "이븐곰",
    "bounsweet": "달콤아",
    "steenee": "달무리나",
    "tsareena": "달코퀸",
    "comfey": "큐아링",
    "oranguru": "하랑우탄",
    "passimian": "내던숭이",
    "wimpod": "꼬시레",
    "golisopod": "갑주무사",
    "sandygast": "모래꿍",
    "palossand": "모래성이당",
    "pyukumuku": "해무기",
    "type: null": "타입:널",
    "silvally": "실버디",
    "minior": "메테노",
    "komala": "자말라",
    "turtonator": "폭거북스",
    "togedemaru": "토게데마루",
    "mimikyu": "따라큐",
    "bruxish": "치갈기",
    "drampa": "할비롱",
    "dhelmise": "타타륜",
    "jangmo-o": "짜랑꼬",
    "hakamo-o": "짜랑고우",
    "kommo-o": "짜랑고우거",
    "tapu koko": "카푸꼬꼬꼭",
    "tapu lele": "카푸나비나",
    "tapu bulu": "카푸브루루",
    "tapu fini": "카푸느지느",
    "cosmog": "코스모그",
    "cosmoem": "코스모움",
    "solgaleo": "솔가레오",
    "lunala": "루나아라",
    "nihilego": "텅비드",
    "buzzwole": "매시붕",
    "pheromosa": "페로코체",
    "xurkitree": "전수목",
    "celesteela": "철화구야",
    "kartana": "종이신도",
    "guzzlord": "악식킹",
    "necrozma": "네크로즈마",
    "magearna": "마기아나",
    "marshadow": "마샤도",
    "poipole": "베베놈",
    "naganadel": "아고용",
    "stakataka": "차곡차곡",
    "blacephalon": "두파팡",
    "zeraora": "제라오라",
    "meltan": "멜탄",
    "melmetal": "멜메탈",
    "grookey": "흥나숭",
    "thwackey": "채키몽",
    "rillaboom": "고릴타",
    "scorbunny": "염버니",
    "raboot": "래비풋",
    "cinderace": "에이스번",
    "sobble": "울머기",
    "drizzile": "누겔레온",
    "inteleon": "인텔리레온",
    "skwovet": "탐리스",
    "greedent": "요씽리스",
    "rookiedee": "파라꼬",
    "corvisquire": "파크로우",
    "corviknight": "아머까오",
    "blipbug": "두루지벌레",
    "dottler": "레돔벌레",
    "orbeetle": "이올브",
    "nickit": "훔처우",
    "thievul": "폭슬라이",
    "gossifleur": "꼬모카",
    "eldegoss": "백솜모카",
    "wooloo": "우르",
    "dubwool": "배우르",
    "chewtle": "깨물부기",
    "drednaw": "갈가부기",
    "yamper": "멍파치",
    "boltund": "펄스멍",
    "rolycoly": "탄동",
    "carkol": "탄차곤",
    "coalossal": "석탄산",
    "applin": "과사삭벌레",
    "flapple": "애프룡",
    "appletun": "단지래플",
    "silicobra": "모래뱀",
    "sandaconda": "사다이사",
    "cramorant": "윽우지",
    "arrokuda": "찌로꼬치",
    "barraskewda": "꼬치조",
    "toxel": "일레즌",
    "toxtricity": "스트린더",
    "sizzlipede": "태우지네",
    "centiskorch": "다태우지네",
    "clobbopus": "때때무노",
    "grapploct": "케오퍼스",
    "sinistea": "데인차",
    "polteageist": "포트데스",
    "hatenna": "몸지브림",
    "hattrem": "손지브림",
    "hatterene": "브리무음",
    "impidimp": "메롱꿍",
    "morgrem": "쏘겨모",
    "grimmsnarl": "오롱털",
    "obstagoon": "가로막구리",
    "perrserker": "나이킹",
    "cursola": "산호르곤",
    "sirfetch'd": "창파나이트",
    "mr. rime": "마임꽁꽁",
    "runerigus": "데스판",
    "milcery": "마빌크",
    "alcremie": "마휘핑",
    "falinks": "대여르",
    "pincurchin": "찌르성게",
    "snom": "누니머기",
    "frosmoth": "모스노우",
    "stonjourner": "돌헨진",
    "eiscue": "빙큐보",
    "indeedee": "에써르",
    "morpeko": "모르페코",
    "cufant": "끼리동",
    "copperajah": "대왕끼리동",
    "dracozolt": "파치래곤",
    "arctozolt": "파치르돈",
    "dracovish": "어래곤",
    "arctovish": "어치르돈",
    "duraludon": "두랄루돈",
    "dreepy": "드라꼰",
    "drakloak": "드래런치",
    "dragapult": "드래펄트",
    "zacian": "자시안",
    "zamazenta": "자마젠타",
    "eternatus": "무한다이노",
    "kubfu": "치고마",
    "urshifu": "우라오스",
    "zarude": "자루도",
    "regieleki": "레지에레키",
    "regidrago": "레지드래고",
    "glastrier": "블리자포스",
    "spectrier": "레이스포스",
    "calyrex": "버드렉스",
    "wyrdeer": "신비록",
    "kleavor": "사마자르",
    "ursaluna": "다투곰",
    "basculegion": "대쓰여너",
    "sneasler": "포푸니크",
    "overqwil": "장침바루",
    "enamorus": "러브로스",
    "sprigatito": "나오하",
    "floragato": "나로테",
    "meowscarada": "마스카나",
    "fuecoco": "뜨아거",
    "crocalor": "악뜨거",
    "skeledirge": "라우드본",
    "quaxly": "꾸왁스",
    "quaxwell": "아꾸왁",
    "quaquaval": "웨이니발",
    "lechonk": "맛보돈",
    "oinkologne": "퍼퓨돈",
    "tarountula": "타랜툴라",
    "spidops": "트래피더",
    "nymble": "콩알뚜기",
    "lokix": "엑스레그",
    "pawmi": "빠모",
    "pawmo": "빠모트",
    "pawmot": "빠르모트",
    "tandemaus": "두리쥐",
    "maushold": "파밀리쥐",
    "fidough": "쫀도기",
    "dachsbun": "바우첼",
    "smoliv": "미니브",
    "dolliv": "올리뇨",
    "arboliva": "올리르바",
    "squawkabilly": "시비꼬",
    "nacli": "베베솔트",
    "naclstack": "스태솔트",
    "garganacl": "콜로솔트",
    "charcadet": "카르본",
    "armarouge": "카디나르마",
    "ceruledge": "파라블레이즈",
    "tadbulb": "빈나두",
    "bellibolt": "찌리배리",
    "wattrel": "찌리비",
    "kilowattrel": "찌리비크",
    "maschiff": "오라티프",
    "mabosstiff": "마피티프",
    "shroodle": "땃쭈르",
    "grafaiai": "태깅구르",
    "bramblin": "그푸리",
    "brambleghast": "공푸리",
    "toedscool": "들눈해",
    "toedscruel": "육파리",
    "klawf": "절벼게",
    "capsakid": "캡싸이",
    "scovillain": "스코빌런",
    "rellor": "구르데",
    "rabsca": "베라카스",
    "flittle": "하느라기",
    "espathra": "클레스퍼트라",
    "tinkatink": "어리짱",
    "tinkatuff": "벼리짱",
    "tinkaton": "두드리짱",
    "wiglett": "바다그다",
    "wugtrio": "바닥트리오",
    "bombirdier": "떨구새",
    "finizen": "맨돌핀",
    "palafin": "돌핀맨",
    "varoom": "부르롱",
    "revavroom": "부르르룸",
    "cyclizar": "모토마",
    "orthworm": "꿈트렁",
    "glimmet": "초롱순",
    "glimmora": "킬라플로르",
    "greavard": "망망이",
    "houndstone": "묘두기",
    "flamigo": "꼬이밍고",
    "cetoddle": "터벅고래",
    "cetitan": "우락고래",
    "veluza": "가비루사",
    "dondozo": "어써러셔",
    "tatsugiri": "싸리용",
    "annihilape": "저승갓숭",
    "clodsire": "토오",
    "farigiraf": "키키링",
    "dudunsparce": "노고고치",
    "kingambit": "대도각참",
    "great tusk": "위대한엄니",
    "scream tail": "우렁찬꼬리",
    "brute bonnet": "사나운버섯",
    "flutter mane": "날개치는머리",
    "slither wing": "땅을기는날개",
    "sandy shocks": "모래털가죽",
    "iron treads": "무쇠바퀴",
    "iron bundle": "무쇠보따리",
    "iron hands": "무쇠손",
    "iron jugulis": "무쇠머리",
    "iron moth": "무쇠독나방",
    "iron thorns": "무쇠가시",
    "frigibax": "드니차",
    "arctibax": "드니꽁",
    "baxcalibur": "드닐레이브",
    "gimmighoul": "모으령",
    "gholdengo": "타부자고",
    "wo-chien": "총지엔",
    "chien-pao": "파오젠",
    "ting-lu": "딩루",
    "chi-yu": "위유이",
    "roaring moon": "고동치는달",
    "iron valiant": "무쇠무인",
    "koraidon": "코라이돈",
    "miraidon": "미라이돈",
    "walking wake": "굽이치는물결",
    "iron leaves": "무쇠잎새",
    "dipplin": "과미르",
    "poltchageist": "차데스",
    "sinistcha": "그우린차",
    "okidogi": "조타구",
    "munkidori": "이야후",
    "fezandipiti": "기로치",
    "ogerpon": "오거폰",
    "archaludon": "브리두라스",
    "hydrapple": "과미드라",
    "gouging fire": "꿰뚫는화염",
    "raging bolt": "날뛰는우레",
    "iron boulder": "무쇠암석",
    "iron crown": "무쇠감투",
    "terapagos": "테라파고스",
    "pecharunt": "복숭악동"
  ]

  // 영어 이름에 해당하는 한국어 이름을 반환, 없으면 영어 이름 그대로 반환
  static func getKoreanName(for englishName: String) -> String {
    return koreanNames[englishName.lowercased()] ?? englishName
  }
}

enum PokemonTypeName: String, CaseIterable, Codable {
    case normal
    case fire
    case water
    case electric
    case grass
    case ice
    case fighting
    case poison
    case ground
    case flying
    case psychic
    case bug
    case rock
    case ghost
    case dragon
    case dark
    case steel
    case fairy

    var displayName: String {
        switch self {
        case .normal: return "노말"
        case .fire: return "불꽃"
        case .water: return "물"
        case .electric: return "전기"
        case .grass: return "풀"
        case .ice: return "얼음"
        case .fighting: return "격투"
        case .poison: return "독"
        case .ground: return "땅"
        case .flying: return "비행"
        case .psychic: return "에스퍼"
        case .bug: return "벌레"
        case .rock: return "바위"
        case .ghost: return "고스트"
        case .dragon: return "드래곤"
        case .dark: return "어둠"
        case .steel: return "강철"
        case .fairy: return "페어리"
        }
    }
}

 

       

   이후 DetailStackView에서 

   PokemonTranslator를 사용해 getKoreanName 메서드를 사용하여 return된 값을 반영해주었다.

    func setDetailStackView(detailPokeData: DetailPokeData) {
        DispatchQueue.global().async {
            guard let url = URL(
                string: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/\(detailPokeData.id).png"
            ) else { return }

            do {
                let image = try UIImage(data: Data(contentsOf: url))
                DispatchQueue.main.async {
                    self.imageView.image = image

                    let pokemonName = detailPokeData.name
                    let koreanName = PokemonTranslator.getKoreanName(for: pokemonName)
                    self.titleLabel.text = "No.\(detailPokeData.id) \(koreanName)"

                    guard let pokemonType = PokemonTypeName(rawValue: detailPokeData.types[0].type.name)?.displayName else {
                        return
                    }

                    if detailPokeData.types.count == 2 {
                        guard let pokemonType2 = PokemonTypeName(rawValue: detailPokeData.types[1].type.name)?.displayName else {
                            return
                        }
                        self.typeLabel.text = "Type: \(pokemonType), \(pokemonType2)"
                    } else {
                        self.typeLabel.text = "Type: \(pokemonType)"
                    }

                    self.heightLabel.text = "height: \(detailPokeData.height)"
                    self.weightLabel.text = "weight: \(detailPokeData.weight)"
                }
            } catch {
                print(NetworkError.dataFetchFail.errorTitle)
            }
        }
    }

 

   이후, 포켓몬 이름 뿐만 아니라 단위도 한글화하기 위해 Measurement를 적용했다.

    func setDetailStackView(detailPokeData: DetailPokeData) {
        DispatchQueue.global().async {
            guard let url = URL(
                string: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/\(detailPokeData.id).png"
            ) else { return }

            do {
                let image = try UIImage(data: Data(contentsOf: url))
                DispatchQueue.main.async {
                    self.imageView.image = image

                    let pokemonName = detailPokeData.name
                    let koreanName = PokemonTranslator.getKoreanName(for: pokemonName)
                    self.titleLabel.text = "No.\(detailPokeData.id) \(koreanName)"

                    guard let pokemonType = PokemonTypeName(rawValue: detailPokeData.types[0].type.name)?.displayName else {
                        return
                    }

                    if detailPokeData.types.count == 2 {
                        guard let pokemonType2 = PokemonTypeName(rawValue: detailPokeData.types[1].type.name)?.displayName else {
                            return
                        }
                        self.typeLabel.text = "타입: \(pokemonType), \(pokemonType2)"
                    } else {
                        self.typeLabel.text = "타입: \(pokemonType)"
                    }

                    let height = Measurement(value: (Double(detailPokeData.height) / 10),
                                             unit: UnitLength.meters)
                    self.heightLabel.text = "키: \(height)"

                    let weight = Measurement(value: (Double(detailPokeData.weight) / 10),
                                             unit: UnitMass.kilograms)
                    self.weightLabel.text = "몸무게: \(weight)"
                }
            } catch {
                print(NetworkError.dataFetchFail.errorTitle)
            }
        }
    }

 

   Measurement는 Foundation에 있는 Framework로, 단위를 별도로 쓰지 않아도 단위까지 포함하여 표시해준다.

 

+ UIColor Extension 

    자주 사용하는 컬러를 Asset에 등록하여 코드를 작성할 수도 있겠지만,

  이번에는 UIColor를 Extension 하여 사용했다.

import UIKit

//MARK: 자주 사용하는 컬러 extension
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 cellBackgroundColor = UIColor(
        red: 245/255,
        green: 245/255,
        blue: 235/255,
        alpha: 1.0)
}

 

 12. KingFisher 적용하기

     이미지 캐싱을 위해, KingFisher를 적용하여 리팩토링 해주었다

// 리팩토링 전 코드
DispatchQueue.global().async {
    if let data = try? Data(contentsOf: url) {
        if let image = UIImage(data: data) {
            DispatchQueue.main.async {
                self.imageView.image = image
            }
        }
    }
     
// 리팩토링 후 코드
DispatchQueue.main.async { [weak self] in
    self?.imageView.kf.setImage(with: url)
}

 13-1. rxCocoa Relay로 리팩토링하기

     rxCocoa를 적용하여 UI 최적화되어 있는 Relay를 사용하여 리팩토링을 했다.

import UIKit
import RxSwift
import RxCocoa

final class MainViewModel {

    { ... }

    let limitPokeRelay = PublishRelay<[LimitPokeData.shortInfoResult]>()

    init() {
        fetchLimitPokeData()
    }

    { ... }

    private func fetchLimitPokeData() {
        guard let url = NetworkManager.shared.getLimitPokeUrl(limit: "20", offset: "\(offset)") else {
            return
        }

        NetworkManager.shared.fetch(url: url)
            .asDriver(onErrorDriveWith: .empty())
            .drive(onNext: { [weak self] data in
                self?.limitPokeRelay.accept(data)
            })
            .disposed(by: disposeBag)
    }
}

 

    위와 같은 형태로 viewModel을 수정하고 빌드를 했더니

   데이터가 안넘어오는 문제가 발생했다.

 13-2. 트러블 슈팅 : 데이터가 안넘어오는 문제   

      데이터를 바인딩했는데, 데이터가 안넘어오는 문제가 생겼다.

   이는 Type이 올바르지 않아서 생긴 이슈인데,

   데이터를 바인딩할 때는 Type 설정을 잘 해줘야한다.

    private func fetchLimitPokeData() {
        guard let url = NetworkManager.shared.getLimitPokeUrl(limit: "20", offset: "\(offset)") else {
            return
        }

        NetworkManager.shared.fetch(url: url)
            .asDriver(onErrorDriveWith: .empty())
            .drive(onNext: { [weak self] (data: LimitPokeData) in
                self?.limitPokeRelay.accept(data.results)
            })
            .disposed(by: disposeBag)
    }

 

 13-3. 트러블 슈팅 : 데이터를 누적할 때는 BehaviorRelay를 쓰자!

      앞서 Relay를 선언해줄 때 

    let limitPokeRelay = PublishRelay<[LimitPokeData.shortInfoResult]>()

 

     위와 같이 PublishRelay로 선언해줬다.

    PublishRelay의 특징으로는 초기값을 갖지 않으며 구독 이후의 값만 받아오는데,

    바꿔 말하면  이러한 특징 때문에 페이지가 바뀔 때마다 데이터가 바뀌어 버리는 상황이 생긴다.

 

     이를 위해 PokemonData를 배열에 담아 append해줘도 되지만,

    BehaviorRelay를 적용하여 개선하는 것으로 했다.

 

     mainVIewModel에서 Relay를 BehaviorRelay로 변경했다

let limitPokeRelay = BehaviorRelay(value: [LimitPokeData.shortInfoResult]())

  

    이후 currentData라는 것을 만들어 기존의 값과 새롭게 방출되는 값을 더해 limitPokeRelay에 담아주도록 했다.

 

    private func fetchLimitPokeData() {
        guard let url = NetworkManager.shared.getLimitPokeUrl(limit: "20", offset: "\(offset)") else {
            return
        }

        NetworkManager.shared.fetch(url: url)
            .asDriver(onErrorDriveWith: .empty())
            .drive(onNext: { [weak self] (data: LimitPokeData) in
                guard let currentData = self?.limitPokeRelay.value else { return () }
                self?.limitPokeRelay.accept(currentData + data.results)
                self?.isInfiniteScroll = false
            })
            .disposed(by: disposeBag)
    }

 

   그리고 Relay를 MainViewController에서 구독하게 하고,

  데이터 방출이 있을 때 cell에 반영할 수 있도록 코드를 작성했다

        viewModel.limitPokeRelay
            .asDriver(onErrorDriveWith: .empty())
            .drive(collectionView.rx.items(cellIdentifier: String(describing: MainCollectionViewCell.self), cellType: MainCollectionViewCell.self)) { [weak self] (row, element, cell) in
                cell.updatePokeImage(imageData: element)
            }
            .disposed(by: disposeBag)

   

 14-1. 트러블 슈팅 : DetailView로 넘어갈 때 첫번째 데이터가 안나오는 문제

   

 

    포켓몬스터 리팩토링을 마쳤다고 생각했는데, 첫번째 데이터만 안나오는 문제가 발생했다.

   왜 그런가 하고 DetailViewModel을 살펴보다가, 원인을 알 수 있었다.

let detailPokeSubject = PublishSubject<DetailPokeData>()

 

    detailViewModel의 데이터 또한 PublishSubject이기 때문이다.

  이를 개선해주기 위해 BehaviorRelay로 수정하여 코드를 작성하였다.

 

최종 코드

    https://github.com/uddt-ds/PokeMonday

 

GitHub - uddt-ds/PokeMonday: PokeMonday는 RxSwift와 MVVM 아키텍처를 사용한 포켓몬스터 도감 앱입니다.

PokeMonday는 RxSwift와 MVVM 아키텍처를 사용한 포켓몬스터 도감 앱입니다. - uddt-ds/PokeMonday

github.com