이번에는 지난번에 하드코딩한 샘플 데이터를 실제 데이터로 바꿔 셀에 적용해 본다.
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()
}
예상 결과물
일단 출력은 정상적으로 잘 된다. 라이트/다크모드 지원도 가능하다. 다만 사진이 살짝 불편하게 영역을 차지한다. 다음에 바꾸자
끝
'Swift > Rick & Morty' 카테고리의 다른 글
[iOS] Rick&Morty - #10 Pagination Indicator (0) | 2023.03.07 |
---|---|
[iOS] Rick&Morty - #9 Character Detail View (0) | 2023.03.06 |
[iOS] Rick&Morty - #7 CollectionViewCell (0) | 2023.03.04 |
[iOS] Rick&Morty - #6 Character List View (0) | 2023.03.03 |
[iOS] Rick&Morty - #5 API Call (0) | 2023.03.02 |