평소 TableView를 많이 사용하다보니 CollectionView의 HeaderView와 footerView를 사용해보지 못했음.
detail view에서 영화 상세 부분을 보여주고자 하였음.
포스터 이미지와 구분해 Overview를 보여줄 때 하나의 섹션에서 처리하는 것보다 여러개의 섹션을 구분해 표시하는 것이 좋겠다 생각했음. (현재는 영화 이미지, 정보만 표시하지만 추가적으로 관련된 영화라던지 기타 정보를 추가하고 싶을 경우를 고려)
그래서 섹션에 대한 구분을 HeaderView로 하는 것이 적절할 것으로 생각됨.
막상 찾아보니 UICollectionReusableView가 필요함.
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),
])
}
}
그리고 해당 뷰를 사용하기 위해 DetailView에 CollectionReusableView를 등록한다.
// DetailView.swift
func setUI(){
collectionView.register(CollectionReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: CollectionReusableView.identifier)
}
마지막으로 DetailViewController에서 viewForSupplementaryElementOfKind를 사용한다.
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의 타이틀을 작성하여 표시할 수 있다.
// 더불어 헤더의 높이를 통해 표시하고자 하는 영역을 선별적으로 표시할 수 있게 된다.
header에 대한 text를 각 섹션마다 사용하고 싶지 않아, 해당 섹션에만 텍스트를 추가했음.
평소 MVC 패턴으로 빠르게 구현하였음. MVVM 패턴 자체도 잘 이해가 되지 않았으며, ViewModel의 역할이 크게 공감되지 않았음.
ViewController에서 Model를 관리하는게 굉장히 자연스럽게 여겨졌음.
그러나 비동기 + RxSwift + Combine 등의 기술 스택이 요구되는 경우가 많아 이를 이해하고 직접 사용함.
간단하게 ViewModel에 Model를 관리하도록 분리하고, ViewController에서는 View를 관리하도록 함
MainViewController에서 직접 MovieManager fetchMovies 메소드를 호출해 Model(movies)에 데이터를 넣고 있음.
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)
}
}
}
}
}
Class MovieManager의 코드
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))
}
}
}
}
먼저 ViewModel에서 movieManager, movie 인스턴스를 생성함.
MainViewController에서 작업한 데이터 받아오는 작업을 ViewModel에서 처리함.
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)
}
}
}
}
이 경우, viewModel에서 직접 model을 관리하고, viewcontroller에서는 viewmodel의 fetchMovies 메소드만 호출하고, CollectionView만 reload처리함.
그러나 이렇게만 분리하니, ViewModel의 역할이 크게 와닿지 않았음. 오히려 ViewController에서 관리한 이전 코드가 훨씬 가독성이 좋게 여겨졌음.
그래서 MainViewController에 필터 기능을 추가했음.
이름, 개봉일, 평점을 기준으로 필터 처리할 경우 viewModel에서 model에 대한 조작이 필요할 것으로 생각됨.
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 })
}
}
MainViewController에서는 filterButton을 추가하고, 이를 클릭할 경우에 대해서 ViewModel의 필터메소드를 호출하는 형식을 적용하였음
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)
}
}
필터 기능을 viewmodel에 추가하니 viewcontroller에서 모델에 대한 관리 영역을 분리됨을 더 명확히 알 수 있었음. viewcontroller가 View만 관리하니 코드가 더 명확해짐
model 역시 viewmodel에서 관리하니 역할이 명확해짐.
현재 CompletionHandler로 데이터를 넘기고 있는데, RxSwift를 활용해 보다 간편하게 데이터 전달을 해야할 필요성을 느낌.