HeaderView + MVVM + ViewModel

주방·2023년 4월 27일
0

MovieRank

목록 보기
2/8

배경

  1. MovieRank App의 MVC를 적용한 기본적인 표현이 마무리되었다.
  2. MVVM으로 리팩토링을 하며, 해당 아키텍쳐가 가지고 있는 장점이 무엇이 있는지 살펴본다.

진행

  1. MVC로 구현하기를 마무리함. 포스터를 선택할 경우 DetailView로 넘어가 해당 영화의 정보를 표시(포스터, 이름, 평점, 발매일, 줄거리)
  2. ViewController에서 직접 model을 관리하고 있었음. 이를 viewModel에서 처리하고, ViewController에서는 뷰 관리에 집중할 수 있도록 분리함.

구현GIF(좌: MVC, 우: MVVM + 필터)

이미지 설명이미지 설명

HeaderView

  1. 평소 TableView를 많이 사용하다보니 CollectionView의 HeaderView와 footerView를 사용해보지 못했음.

  2. detail view에서 영화 상세 부분을 보여주고자 하였음.

  3. 포스터 이미지와 구분해 Overview를 보여줄 때 하나의 섹션에서 처리하는 것보다 여러개의 섹션을 구분해 표시하는 것이 좋겠다 생각했음. (현재는 영화 이미지, 정보만 표시하지만 추가적으로 관련된 영화라던지 기타 정보를 추가하고 싶을 경우를 고려)

  4. 그래서 섹션에 대한 구분을 HeaderView로 하는 것이 적절할 것으로 생각됨.

  5. 막상 찾아보니 UICollectionReusableView가 필요함.

  6. final class CollectionReusableView: UICollectionReusableView {
        static var identifier = "collectionReusableView"
        
        lazy var label: UILabel = {
            let view = UILabel()
            view.textAlignment = .left
            view.font = UIFont.systemFont(ofSize: 20)
            view.translatesAutoresizingMaskIntoConstraints = false
            return view
        }()
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            setUp()
        }
        
        required init?(coder: NSCoder) {
            super.init(coder: coder)
        }
        private func setUp() {
            addSubview(label)
            NSLayoutConstraint.activate([
                label.topAnchor.constraint(equalTo: topAnchor),
                label.bottomAnchor.constraint(equalTo: bottomAnchor),
            ])
        }
    }
  7. 그리고 해당 뷰를 사용하기 위해 DetailView에 CollectionReusableView를 등록한다.

  8. // DetailView.swift 
    func setUI(){
      collectionView.register(CollectionReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: CollectionReusableView.identifier)
    }
  9. 마지막으로 DetailViewController에서 viewForSupplementaryElementOfKind를 사용한다.

  10. func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
            if kind == UICollectionView.elementKindSectionHeader {
                let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: CollectionReusableView.identifier, for: indexPath) as! CollectionReusableView
                if indexPath.section == 1 {
                    header.label.text = "Overview"
                } else {
                    header.label.text = ""
                }
                return header
            }
            return UICollectionReusableView()
        }
    // header을 사용할 경우 모든 section에 헤더를 사용하고 싶지 않을 수 있다.
    // 이때 본인이 원하는 섹션에 header의 타이틀을 작성하여 표시할 수 있다.
    // 더불어 헤더의 높이를 통해 표시하고자 하는 영역을 선별적으로 표시할 수 있게 된다.
  11. header에 대한 text를 각 섹션마다 사용하고 싶지 않아, 해당 섹션에만 텍스트를 추가했음.

MVVM + ViewModel

  1. 평소 MVC 패턴으로 빠르게 구현하였음. MVVM 패턴 자체도 잘 이해가 되지 않았으며, ViewModel의 역할이 크게 공감되지 않았음.

  2. ViewController에서 Model를 관리하는게 굉장히 자연스럽게 여겨졌음.

  3. 그러나 비동기 + RxSwift + Combine 등의 기술 스택이 요구되는 경우가 많아 이를 이해하고 직접 사용함.

  4. 간단하게 ViewModel에 Model를 관리하도록 분리하고, ViewController에서는 View를 관리하도록 함

  5. MainViewController에서 직접 MovieManager fetchMovies 메소드를 호출해 Model(movies)에 데이터를 넣고 있음.

  6. class MainViewController: UIViewController{
        
        let mainView = MainView()
        let movieManager = MovieManager()
        
        var movies = [Movie]()
                    
        override func viewDidLoad() {
            movieManager.fetchMovies { [weak self] result in
                DispatchQueue.main.async {
                    switch result {
                    case .success(let response):
                        self?.movies = response.results
                        self?.mainView.collectionView.reloadData()
                    case .failure(let error):
                        print(error)
                    }
                }
            }
        }
    }
  7. Class MovieManager의 코드

  8. class MovieManager{
        private func performRequest(url: URL, completion: @escaping (Result<Data, Error>) -> Void) {
            let config = URLSessionConfiguration.default
            let session = URLSession(configuration: config)
            
            let dataTask = session.dataTask(with: url) { data, response, error in
                if let error = error {
                    completion(.failure(error))
                    return
                }
                
                guard let data = data else {
                    let error = NSError(domain: "", code: 0)
                    completion(.failure(error))
                    return
                }
                
                completion(.success(data))
            }
            dataTask.resume()
        }
        
        
        func fetchMovies(completion: @escaping (Result<MovieResponse, Error>) -> Void){
            let requestURL = URL(string: "https://api.themoviedb.org/3/trending/movie/week?api_key=\(APIKey.apiKey)")
            
            guard let url = requestURL else {
                let error = NSError(domain: "", code: 0)
                completion(.failure(error))
                return
            }
            performRequest(url: url) { result in
                switch result {
                case .success(let data):
                    do {
                        let decoder = JSONDecoder()
                        let movieResponse = try decoder.decode(MovieResponse.self, from: data)
                        completion(.success(movieResponse))
                    } catch let error {
                        completion(.failure(error))
                    }
                case .failure(let error):
                    completion(.failure(error))
                }
            }
        }
        
        
        func downloadImage(posterPath: String, completion: @escaping(Result<UIImage, Error>) -> Void) {
            guard let url = URL(string: posterPath) else {
                let error = NSError(domain: "", code: 0)
                completion(.failure(error))
                return
            }
            
            performRequest(url: url) { result in
                switch result {
                case .success(let data):
                    if let image = UIImage(data: data) {
                        completion(.success(image))
                    } else {
                        let error = NSError(domain: "", code: 0)
                        completion(.failure(error))
                    }
                case .failure(let error):
                    completion(.failure(error))
                }
            }
        }
    }
  9. 먼저 ViewModel에서 movieManager, movie 인스턴스를 생성함.

  10. MainViewController에서 작업한 데이터 받아오는 작업을 ViewModel에서 처리함.

  11. class ViewModel{
        
        let movieManager = MovieManager()
        var movie = [Movie]()
        
        func fetchMovies(completion: @escaping () -> Void){
            movieManager.fetchMovies { [weak self] result in
                DispatchQueue.main.async {
                    switch result{
                    case .success(let response):
                        self?.movie = response.results
                        completion()
                    case .failure(let error):
                        print("fail error: \(error)")
                    }
                }
            }
        }
       
        func downloadImage(posterPath: String, completion: @escaping(Result<UIImage, Error>)-> Void){
            movieManager.downloadImage(posterPath: posterPath) { result in
                DispatchQueue.main.async {
                    completion(result)
                }
            }
        }
    }
  12. 이 경우, viewModel에서 직접 model을 관리하고, viewcontroller에서는 viewmodel의 fetchMovies 메소드만 호출하고, CollectionView만 reload처리함.

  13. 그러나 이렇게만 분리하니, ViewModel의 역할이 크게 와닿지 않았음. 오히려 ViewController에서 관리한 이전 코드가 훨씬 가독성이 좋게 여겨졌음.

  14. 그래서 MainViewController에 필터 기능을 추가했음.

  15. 이름, 개봉일, 평점을 기준으로 필터 처리할 경우 viewModel에서 model에 대한 조작이 필요할 것으로 생각됨.

  16. class ViewModel{
        let movieManager = MovieManager()
        var movie = [Movie]()
    //    (중략)
    // 추가된 코드    
        func sortMoviesByTitle() {
            movie.sort(by: { $0.title < $1.title })
        }
        
        func sortMoviesByReleaseDate() {
            movie.sort(by: { $0.releaseDate < $1.releaseDate })
        }
        
        func sortMoviesByVoteAverage() {
            movie.sort(by: { $0.voteAverage > $1.voteAverage })
        }
    }
  17. MainViewController에서는 filterButton을 추가하고, 이를 클릭할 경우에 대해서 ViewModel의 필터메소드를 호출하는 형식을 적용하였음

  18. class MainViewController: UIViewController{
        
        let mainView = MainView()
        let viewModel = ViewModel()
                        
        override func viewDidLoad() {
            let filterButton = UIBarButtonItem(image: UIImage(systemName: "line.horizontal.3.decrease.circle"), style: .plain, target: self, action: #selector(filterButtonTapped))
            navigationItem.rightBarButtonItem = filterButton
        }
        
        @objc func filterButtonTapped(){
            
            let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
            
            let titleAction = UIAlertAction(title: "영화명", style: .default) { [weak self] _ in
                self?.viewModel.sortMoviesByTitle()
                self?.mainView.collectionView.reloadData()
            }
            
            let releaseDateAction = UIAlertAction(title: "발매일", style: .default) { [weak self] _ in
                self?.viewModel.sortMoviesByReleaseDate()
                self?.mainView.collectionView.reloadData()
            }
            
            let voteAverageAction = UIAlertAction(title: "별점", style: .default) { [weak self] _ in
                self?.viewModel.sortMoviesByVoteAverage()
                self?.mainView.collectionView.reloadData()
            }
            
            let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
            
            alertController.addAction(titleAction)
            alertController.addAction(releaseDateAction)
            alertController.addAction(voteAverageAction)
            alertController.addAction(cancelAction)
            
            present(alertController, animated: true, completion: nil)
        }
    }
  19. 필터 기능을 viewmodel에 추가하니 viewcontroller에서 모델에 대한 관리 영역을 분리됨을 더 명확히 알 수 있었음. viewcontroller가 View만 관리하니 코드가 더 명확해짐

  20. model 역시 viewmodel에서 관리하니 역할이 명확해짐.

  21. 현재 CompletionHandler로 데이터를 넘기고 있는데, RxSwift를 활용해 보다 간편하게 데이터 전달을 해야할 필요성을 느낌.


정리

  1. CollectionView를 사용하다보니 view에서 테이블 형태 뿐 아니라 다양한 레이아웃을 유연하게 적용해볼 수 있음을 알게 됨.
  2. MVVM을 적용해보니 생각 이상으로 관리적인 측면에서 장점이 느껴짐.
  3. 예를 들어 model에 대한 추가 작업을 진행할 경우를 살펴보자.(필터링)
  4. 곧바로 viewModel에서 해당 필터 메소드를 작성하고, viewController에서는 필터버튼을 작성하고, 간단하게 ViewModel에서 필터 메소드만 호출함.
  5. 각 영역이 분명하게 나눠져 있으니 수정사항의 범위 예측이 손쉬워짐.
  6. 데이터 전달할 경우 completionHandler로 처리하고 있는데, 이마저도 RxSwift를 활용하면 보다 손쉽겠다고 생각됨.

0개의 댓글