Swift/Rick & Morty

[iOS] Rick&Morty - #8 Showing Characters

devKen 2023. 3. 5. 15:00

#8

 

이번에는 지난번에 하드코딩한 샘플 데이터를 실제 데이터로 바꿔 셀에 적용해 본다.

 

fetchCharacters 메서드 수정

- 데이터가 바뀌면 리로드가 되어야 하는 걸 기억해두고 작업하자

 

final class RMCharacterListViewViewModel: NSObject {
    ...
    private var characters: [RMCharacter] = []
    ...
    public func fetchCharacters() {
        RMService.shared.execute(.listCharactersRequests, expecting: RMGetAllCharacterResponse.self) { [weak self] result in
            switch result {
            case .success(let responseModel):
                let results = responseModel.results
                self?.characters = results
            case .failure(let error):
                print(String(describing: error))
            }
        }
    }
}

 

CollectionView 추가 생성

컬렉션 뷰에서 데이터를 업데이트할 때 두 가지 중 하나의 방법을 취한다.

  • 기존 컬렉션 뷰 업데이트
  • 새로 생성

지금 선택한 방법은 뷰 모델을 생성하여 업데이트하는 방식이다.

만약 페이지네이션 혹은 스크롤로 데이터를 3~4번 다시 불러오는 상황을 가정하자. 이 상황에서는 뷰 모델을 계산해서 다시 계산하게 하는데 리소스가 많이 들어간다. 아래 코드로 characters가 생성될 때, 암묵적으로 뷰모델을 생성하고 유지할 것이다.

private var characters: [RMCharacter] = [] {
        didSet {
            for character in characters {
                let viewModel = RMCharacterCollectionViewCellViewModel(
					characterName: character.name, 
					characterStatus: character.status, 
					characterImageUrl: URL(string: character.image))
                cellViewModels.append(viewModel)
            }
        }
    }
    
    private var cellViewModels: [RMCharacterCollectionViewCellViewModel] = []

 

Cell의 DataSource 만들기

numberOfItemsInSection에서 하드코딩된 숫자를 cellViewModel.count로 바꾸자

cell.configure(with: cellViewModels[indexPath.row]도 마찬가지

func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return cellViewModels.count
    }
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: RMCharacterCollectionViewCell.cellIdentifier, for: indexPath) as? RMCharacterCollectionViewCell else {
            fatalError("unsupported cell")
        }
        cell.configure(with: cellViewModels[indexPath.row])
        return cell
    }

 

데이터를 로드할 수 있는 protocol 생성

이 부분이 조금 까다로운 부분이다.

우리는 delegate를 이용하여 데이터를 조작하게 될 것인데 프로토콜을 선언하고 쓰이는 곳에서 준수하여 활용하게 된다.

이를 위해 RMCharacterListView에서 delegate를 self로 하자. 다음으로 아래의 프로토콜을 RMCharacterListViewViewModel에서  선언해 주자

protocol RMCharacterListViewViewModelDelegate: AnyObject {
    func didLoadInitialCharacters()
}

 

이제 delegate 객체를 만들어 주자. weak로 메모리를 관리해야 하기 때문에 기존에 struct로 제작된 타입을 class로 바꾸자

public weak var delegate: RMCharacterListViewViewModelDelegate?

 

fetchCharacters 메서드에서 case .success에서 비동기로 UI를 다시 그리게 한다.

DispatchQueue.main.async {
	self?.delegate?.didLoadInitialCharacters()
}

 

RMCharacterListView

이제 V에서 실제로 데이터 모델을 이용해서 화면을 업데이트하도록 하자. 이를 위해서는 방금 만든 델리게이트를 적용하여 함수를 선언해 주자. 보기 깔끔한 방법은 extension으로 선언하고 이 내부에서 로직을 작성하는 것이다.

extension RMCharacterListView: RMCharacterListViewViewModelDelegate {
    func didLoadInitialCharacters() {
        collectionView.reloadData()
    }
}

 

추가적으로 더미로 만들어 둔 스피너를 지우고 멈추는 로직을 넣어두자. 

extension RMCharacterListView: RMCharacterListViewViewModelDelegate {
    ...
    func didLoadInitialCharacters() {
        spinner.stopAnimating()
        collectionView.isHidden = false
        collectionView.reloadData()
        UIView.animate(withDuration: 0.4) {
            self.collectionView.alpha = 1
        }
    }
    ...
}

 

이제 VM을 생성하여 델리게이트와 데이터소스를 컨트롤할 수 있도록 한다. 이 과정을 통해 아까 정의했던 동작들을 V에서 표현이 가능하다. 

final class RMCharacterListView: UIView {
	...
    private let viewModel = RMCharacterListViewViewModel()

    override init(frame: CGRect) {
        super.init(frame: frame)
        ...
        setUpCollectionView()
    }

    private func setUpCollectionView() {
        // Self를 하거나 ViewModel의 프로토콜을 따르게 할 수도 있다.
        collectionView.delegate = viewModel
        collectionView.dataSource = viewModel
    }
}

 

만약 여기까지 했다면 데이터가 화면에 성공적으로 보여야 하며, 스피너는 거의 안 보여야 정상이다. API에서 보내는 정보가 너무 빠르게 와서 그렇다.

 

Status 값 바꾸기

데이터의 경우 표시될 때 유저가 해당 데이터를 잘 이해할 수 있는지를 살펴야 한다. 이번 프로젝트의 경우 다짜고짜 Alive, unknown 같은 문자열이 있으면 이게 뭔지 이해를 못 할 가능성이 크다. 그렇기에 설명하는 "Status: "를 prefix로 붙여준다.

  • RMCharacterCollectionViewCellViewModel
public var characterStatusText: String {
        return "Status: \(characterStatus.text)"
    }

 

아래에서 switch로 하긴 했지만 CodingKey를 쓰는 게 더 깔끔하다

  • RMCharacterStatus
enum RMCharacterStatus: String, Codable {
    case alive = "Alive"
    case dead = "Dead"
    case unknown = "unknown"
    var text: String {
        switch self {
        case .alive, .dead:
            return rawValue
        case .unknown:
            return "Unknown"
        }
    }
}

 

 

조금 더 이쁘게

이미지를 scaleAspectFill로 수정하고 clipsToBounds를 true로 하자.

우리는 카드가 살짝 튀어나와 있는 효과를 원하니까 shadow를 준다. 이때 shadowColor를 설정시 .label로 하지 않고 UIColor.label.CGColor로 해야 한다.

  • CoreGraphics color인데 그림자는 연산 비용이 많이 드니까 그래픽 코어에서 처리하도록 하는 것이다.
private func setupLayer() {
        contentView.layer.cornerRadius = 8
        contentView.layer.shadowColor = UIColor.label.cgColor
        contentView.layer.cornerRadius = 4
        contentView.layer.shadowOffset = CGSize(width: -4, height: 4)
        contentView.layer.shadowOpacity = 0.3
    }

 

라이트/다크모드에서 주의할 점

traitCollectionDidChange 메서드가 중요하다.

다크, 라이트모드로 변경할 때, SizeClass가 변경될 때 traitCollection이 바뀐 걸 알고 그에 맞는 조건을 만들어 주는 녀석이다.

  • 우리가 setupLayer로 만들어주는 UI 요소는 UIColor가 label 색상으로 라이트, 다크일 때 색상이 달라질 것이니 이런 조치가 필요하다.
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        super.traitCollectionDidChange(previousTraitCollection)
        setupLayer()
    }

 

예상 결과물

완성 화면

 

일단 출력은 정상적으로 잘 된다. 라이트/다크모드 지원도 가능하다. 다만 사진이 살짝 불편하게 영역을 차지한다. 다음에 바꾸자

끝