Swift/Rick & Morty

[iOS] Rick&Morty - #9 Character Detail View

devKen 2023. 3. 6. 20:28

#9

셀을 탭 하여 DetailView라는 곳으로 이동하게 해 보자

 

설계

DetailView를 만들기 위해 어떤 걸 만들어야 할까? MVVM 구조를 채택하고 있는 우리 구조에서 먼저 새로운 VC, View, VM을 만들어야 한다. 이때, Delegate를 이용하여 탭 했을 때 정보를 전달하는 구조를 만든다.

델리게이트를 다룰 때는 VM에서 델리게이트 처리하고 -> ListView에서 다시 델리게이트 처리하고 VC에서 다시 델리게이트 처리하는 순서로 나아갈 것이다.

사소한  UI 수정

디테일 뷰를 만들기 전에 우리가 생각 못 한 부분을 처리하자. 현재 컬렉션 뷰를 최하단으로 내리면 가장 아래 셀은 그림자가 약간 잘린다. 

collectionView UI 잡는 곳으로 가서 sectionInset의 bottom을 10 올려보자

구현

첫 번째 delegate

Cell의 tab을 구현해 보자. 일단 가지고 있는 구조에서 데이터를 보내는 흐름을 만들어보겠다. collectionView에는 didSelectItemAt이 있다. 이것을 익스텐션으로 빼서 구현해보자 

extension RMCharacterListViewViewModel: .. {
				func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        collectionView.deselectItem(at: indexPath, animated: true)
        let character = characters[indexPath.row]
    }
}

 

이제 지난번에 만들어둔 델리게이트 객체에게 맡겨 처리를 진행한다. 델리게이트 프로토콜에 didSelectCharacter를 정의하고 캐릭터를 넘겨버린다고 정해두자

 

protocol RMCharacterListViewViewModelDelegate: AnyObject {
    ...
    func didSelectCharacter(_ character: RMCharacter)
}

...

extension RMCharacterListViewViewModel: ... {
		func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
		...
		delegate?.didSelectCharacter(character)
    }
}

 

이제 View로 넘어가자. 프로토콜을 준수하면 작동이 안 될 뿐이지 빌드는 될 거다.

extension RMCharacterListView: RMCharacterListViewViewModelDelegate {
    func didSelectCharacter(_ character: RMCharacter) {
        // 현재 비어있음. 이따 추가함
    }
    
    func didLoadInitialCharacters() {
        ...
    }
}

 

두 번째 delegate

이번에는 RMCharacterListViewDelegate를 만들어보자. 이름에서 짐작 가능하듯이 View에서 만든다. 이걸 왜 만들까? Cell에서 CollectionView로 데이터를 보내는 건 좋은데 여기서 다시 토스해 줄 녀석이 필요하기 때문이다. 안의 내용은 VC에서 작성해서 활용하게 된다. 

  • 난 여기서 rm이 자꾸 리눅스 쉘 명령어 생각나서 잘못 지은 접두어인가 생각이 들었다.
protocol RMCharacterListViewDelegate: AnyObject {
    func rmCharacterListView( _ characterListView: RMCharacterListView, didSelectCharacter character: RMCharacter)
}

final class RMCharacterListView: UIView {
		public weak var delegate: RMCharacterListViewDelegate?
}

 

이제 다시 didSelectCharacter 안에 delegate에서 프로토콜에 정의한 메서드를 실행하게 하자. 여기서  받은 character 객체를 다시 전달해 준다.

extension RMCharacterListView: RMCharacterListViewViewModelDelegate {
    func didSelectCharacter(_ character: RMCharacter) {
        delegate?.rmCharacterListView(self, didSelectCharacter: character)
    }
		...
}

이제 이 메서드를 VC에서 받아서 쓰면 정보가 VM에서 VC까지 내려오게 되었다.

VC는 ListView에서 선언한 두 번째 델리게이트를 준수하고 delgate를 self로 처리한다. 

final class RMCharacterViewController: UIViewController, RMCharacterListViewDelegate {
    
    ...
    override func viewDidLoad() {
        ...
        setUPView()
    }
    private func setUPView() {
        characterListView.delegate = self
        ...
    }
    func rmCharacterListView(_ characterListView: RMCharacterListView, didSelectCharacter character: RMCharacter) {
        
    }
}

 

데이터는 잘 오겠는데 문제는 보낼 곳이 아예 없다. 이제 만들어준다.

 

Character Detail View 생성

이 VC는 Core로 설정한 다른 VC들과는 조금 다르다. 왜냐면 독립적으로 존재하는 것이 아니라. 탭과 같은 이벤트가 발생했을 때, 어떠한 정보가 있어야 생성하게 된다.

-> init으로 VM을 받아 초기화하는 구조를 만들어주자

정말 기본적인 처리만 해놨다.

final class RMCharacterDetailViewController: UIViewController {
    
    init(viewModel: ) {
        super.init(nibName: nil, bundle: nil)
    }
    
    required init(coder: NSCoder) {
        fatalError("Unsupported")
    }
                   
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
    }
}

Character Detail View와 ViewModel 생성

  • 이쯤 되니 생각난 건데 왜 ViewViewModel이라는 명명법을 사용할까? 통일성은 있겠지만 굳이 View를 붙여야 하나?

아무튼 해당 VM에서는 아까 VC까지 전달된 Character를 받아 사용하게 된다. 선언해 주자. title은 탭 하면 캐릭터의 이름을 소문자로 사용하게 한다.

잊지 말아야 할 것은 RMCharacterDetailViewController에서 viewModel로 방금 만든 RMCharacterDetailViewViewModel을 넣어야 하는 것이다.

final class RMCharacterDetailViewViewModel {
    private let character: RMCharacter
    init(character: RMCharacter) {
        self.character = character
    }
    
    public var title: String {
        character.name.uppercased()
    }
}

 

이제 CharacterVC에서 만들어둔 rmCharacterListView메서드에 탭이라는 이벤트에 대한 반응을 추가한다. 우리는 내비게이션 할 것이다. 먼저 VM으로 결과물을 생성하고 VC로 VM을 받아서 VC로 넘겨주자. 실행해 보면 제대로 잘 이동한다.

final class RMCharacterViewController: ...{
	...
func rmCharacterListView(_ characterListView: RMCharacterListView, didSelectCharacter character: RMCharacter) {
        let viewModel = RMCharacterDetailViewViewModel(character: character)
        let detailVC = RMCharacterDetailViewController(viewModel: viewModel)
        detailVC.navigationItem.largeTitleDisplayMode = .never
        navigationController?.pushViewController(detailVC, animated: true)
    }
}

 

데이터는 제대로 들어갈까? 확인해 본다. DetailVC에서 VM을 생성하고 title로 VM의 타이틀을 붙여보자

final class RMCharacterDetailViewController: UIViewController {
    private let viewModel: RMCharacterDetailViewViewModel
    
    init(viewModel: RMCharacterDetailViewViewModel) {
        self.viewModel = viewModel
        ...
    }
    
    ...
                   
    override func viewDidLoad() {
        ...
        title = viewModel.title
    }
}

final class RMCharacterViewController: ...{
	...
func rmCharacterListView(_ characterListView: RMCharacterListView, didSelectCharacter character: RMCharacter) {
        ...
        detailVC.navigationItem.largeTitleDisplayMode = .never
        navigationController?.pushViewController(detailVC, animated: true)
    }
}

 

무제한 스크롤

우리는 무제한 스크롤을 구현해서 캐릭터의 목록을 계속 갱신하는 기능을 만들 것이다. 하지만 이는 시간이 걸리기 때문에 기본적으로 알아야 하는 요소를 점검하자. 이를 위해서는 두 가지가 있다.

  1. 원하는 때(API call)에 스피너(인디케이터)를 출력시키기
  2. User가 컬렉션 뷰의 최하단까지 스크롤하는지 위치 판단

 

1번 이슈

이것은 UIScrollViewDelegate를 이용하여 해결할 수 있다.

  • 스크롤뷰 델리게이트? 우린 컬렉션뷰로 캐릭터 목록을 구성하는데?
    • 컬렉션뷰는 스크롤뷰를 상속해서 만들어졌으므로 사용 가능하다.

 

먼저 캐릭터 리스트 VM에서 현재 상태를 판단하기 위한 변수 shouldShowLoadMoreIndicator를 생성한다. 이 변수는 스피너를 출력해야 하는지를 판단하는 변수이다.

다음으로는 extension으로 scrollViewDidScroll 메서드를 만들어준다. 여기서 방금 만든 변수를 사용해서 상태를 판단한다. 만약 shouldShowLoadMoreIndicator가 false면 guard로 방어해서 scrollViewdidScroll을 진행하지 않도록 하자

마지막으로 fetchAdditiionalCharacters 메서드를 만들어 추가적으로 캐릭터 정보를 가져오게 하자.

 

final class RMCharacterListViewViewModel: NSObject {
    public func fetchAdditionalCharacters() {
        
    }
    public var shouldShowLoadMoreIndicator: Bool {
        return false
    }
}

...

extension RMCharacterListViewViewModel: UIScrollViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        guard shouldShowLoadMoreIndicator else {
            return
        }
    }
}

 

이제 API Call의 결과물을 받아줄 모델이 필요하다. 우리는 이전에 한번 만들어뒀다. RMGetAllCharacterResponse 모델인데 해당 모델에서 페이지의 앞의 주소, 뒤의 주소를 가지고 있다. 물론 우리는 뒤의 주소만 필요하다. 

struct RMGetAllCharacterResponse: Codable {
    struct Info: Codable {
        let count: Int
        let pages: Int
        let next: String? // for unlimited scrolling a.k.a pagination
        let prev: String?
    }
    let info: Info
    let results: [RMCharacter]
}

우리는 해당 Response에서도 Info에 담긴 정보가 필요하다.

먼저 항상 VM은 Info 정보를 가지고 있어야 하기 때문에 class에서 apiInfo 프로퍼티를 만들어 두자. 

다음으로, API 통신의 결과로 info를 받고 이를 가지고 있는 apiInfo 프로퍼티에 넣는다.

이렇게 까지 한다면 apiInfo의 next는 nil인 경우는

  • 통신이 이뤄지지 않았을 때
  • 캐릭터 페이지 끝까지 진행해서 api에서 nil을 보내줬을 때이다.

그렇다면 이제 false로 처리했던 shouldShowLoadMoreIndicator 변수 리턴값을  apiInfo?.next != nil으로 설정할 수 있다.

final class RMCharacterListViewViewModel: NSObject {
    ...
    public var apiInfo: RMGetAllCharacterResponse.Info? = nil
    ...

    /// Fetch initial set of characters(20)
    public func fetchCharacters() {
        RMService.shared.execute(.listCharactersRequests, expecting: RMGetAllCharacterResponse.self) { [weak self] result in
            switch result {
            case .success(let responseModel):
                ...
                let info = responseModel.info
                ...
                self?.apiInfo = info
                ...
            ...
        }
    }

    public var shouldShowLoadMoreIndicator: Bool {
        return apiInfo?.next != nil
    }
}

 

아직 1번 이슈를 완전하게 해결하지 못했다. 나머진 다음에 이어서 한다.