안녕하세요! Deah 입니다.
제가 하루 만에 돌아왔습니다.....✌️
어제 작업에서 검색 결과에 대한 비슷한 콘텐츠, 추천 콘텐츠를 보여주는 화면을 구현했었는데요!
오늘은 같은 화면의 리팩토링 작업을 해볼까 합니다.
지난 작업에선 컬렉션 뷰만 사용했기 때문에 스크린의 상하 스크롤은 불가한 상태였어요. 하지만 오늘 리팩토링을 통해 많이들 사용하고 계시는 넷플릭스 같이 상하좌우 스크롤이 모두 되는 형태로 바꿔보겠습니다.
위 사진처럼 상하좌우 스크롤이 모두 되는 화면을 만들기 위해서는,
TableView 안에 CollectionView를 구현하는 상태로 만들어주어야 해요.
즉.. 어제 컬렉션 뷰로 만들어둔 화면을 갈.아.엎.어.야.합.니.다.
그럼 바로 츄라이.. 해보겠습니다.
✨ 작업일시: 2024-06-25 (Tue)
기존에는 RecommendViewController + RecommendCollectionViewCell 조합으로만 화면을 구성했다면, 이번 작업에서는 한 화면에 TableView까지 제어를 해줘야하기 때문에 뷰 컨트롤러와 셀 파일에서 실행되는 것들을 요약해보면 요렇습니다!
기존
- RecommendViewController.swift
- UICollectionView, UICollectionViewDelegate, UICollectionViewDataSource
- RecommendCollectionViewCell.swift
변경
- RecommendViewController.swift
- UITableView, UITableViewDelegate, UITableViewDataSource
- UICollectionViewDelegate, UICollectionViewDataSource
- RecommendTableViewCell.swift
- UICollectionView
변경된 부분을 보시면 CollectionView는 RecommendTableViewCell 안에서 구현되는데, RecommendViewController에서 CollectionView의 delegate, dataSource 프로토콜을 사용하는 부분이 신기하지 않나요? (나만 그런가 ㅎ_ㅎ)
무튼! 이전 작업들을 모두 들어내고 테이블 뷰를 만들어주겠습니다.
그리고 익스텐션을 만들어서 delegate, dataSource 프로토콜을 연결해 줄게요.
lazy var tableView = {
let view = UITableView()
view.delegate = self
view.dataSource = self
view.register(RecommendTableViewCell.self, forCellReuseIdentifier: RecommendTableViewCell.id)
view.rowHeight = 230
view.separatorStyle = .none
return view
}()
extension RecommendViewController: UITableViewDelegate, UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
...
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
...
}
}
그리고 테이블 뷰에서 사용할 셀 파일 안에서 각 컬렉션 뷰에서 사용할 타이틀 레이블과 컬렉션 뷰도 만들어주었습니다!
let titleLabel = {
let label = UILabel()
label.font = Constants.Font.subTitle
return label
}()
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout())
static func layout() -> UICollectionViewLayout {
let layout = UICollectionViewFlowLayout()
layout.itemSize = CGSize(width: 120, height: 160)
layout.minimumLineSpacing = 8
layout.minimumInteritemSpacing = 0
layout.sectionInset = UIEdgeInsets(top: 0, left: 16, bottom: 16, right: 0)
layout.scrollDirection = .horizontal
return layout
}
TMDb - similar - movie
TMDb - similar - TV
TMDb - recommendations - movie
TMDb - recommendations - TV
저는 TMDb에서 Similar API, Recommendations API를 통해 비슷한 콘텐츠와 추천 콘텐츠의 포스터를 노출해주려고 합니다. 그래서 해당하는 API들의 응답값을 확인해보면 공통적으로 poster_path
를 가지고 있어요.
"poster_path": "/ukVVnY9ovwl78WE5KndcpA6SnAm.jpg",
다양한 응답 값들이 있어서 두루 활용하면 좋겠지만 4개의 API가 각각 영화/TV로 나뉘어져있고 영화/TV마다 세부적인 응답 데이터의 키값이 미묘하게 달라서 공통적인 poster_path
만 구조체로 만들어 모든 API 통신에 공용으로 사용해보겠습니다!
struct SimilarRecommend: Decodable {
let results: [SimilarRecommendResults]
}
struct SimilarRecommendResults: Decodable {
let poster_path: String
}
그리고 RecommendViewController에서 응답 데이터를 할당해줄 프로퍼티를 만들어줄게요.🤓
여기서 이중 배열을 사용해보겠습니다. (임시로 초기화도 해두었어요)
var contentsList: [[SimilarRecommendResults]] = [
[SimilarRecommendResults(poster_path: "")],
[SimilarRecommendResults(poster_path: "")]
]
데이터를 저장할 공간을 만들어두었으니 이제 API를 연결해보겠습니다.
지난 작업에 만들어둔 네트워크 매니저를 활용해보려고 해요.
하지만 그 전에 [검색 결과 - 추천 콘텐츠 화면] 전환되는 플로우를 한 번 확인하고 가겠습니다 🏃♀️
검색 화면에서는 검색할 콘텐츠의 카테고리를 선택해야, 해당 API에 맞게 검색이 이뤄집니다.
그리고 검색 결과에서 아이템을 클릭하고 추천 콘텐츠 화면으로 넘어갈 때
사용자가 이미 선택한 아이템의 카테고리 값(.selectedSegmentIndex)과 콘텐츠명, 콘텐츠ID가 함께 전달되도록 해주었어요.
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let item = searchList.results[indexPath.item]
let recommendVC = RecommendViewController()
recommendVC.itemTitle = item.name ?? item.title!
recommendVC.itemType = selectedSearchCategory
recommendVC.itemId = item.id
navigationController?.pushViewController(recommendVC, animated: true)
}
그래서 추천 콘텐츠 화면에서는 사용자가 검색 후 선택했던 아이템의 3가지 정보를 가지고 있게 되어요.
// e.g) 데이터 예시
var itemTitle: String = "미스터 션샤인"
var itemType: Int = 1
var itemId: Int = 75820
이 데이터를 가지고 추천 콘텐츠 화면에 사용할 4가지 네트워크 통신을 진행합니다!
type에는 작품의 카테고리, 즉 사용자가 Segmented Control에서 선택한 값과 id 값을 넘겨주어 API를 호출 해주겠습니다.
// 비슷한 콘텐츠
func getSimilarContents(type: Int, id: Int, completionHandler: @escaping ([SimilarRecommendResults]) -> Void
) {
let MOVIE_URL = "\(API.URL.KMDB.Similar.movie)\(id)/similar?language=ko"
let TV_URL = "\(API.URL.KMDB.Similar.tv)\(id)/similar?language=ko"
let URL = type == 0 ? MOVIE_URL : TV_URL
AF.request(URL, headers: headers)
.responseDecodable(of: SimilarRecommend.self) { res in
switch res.result {
case .success(let value):
completionHandler(value.results)
case .failure(let error):
print(error)
}
}
}
// 추천 콘텐츠
func getRecommendContents(type: Int, id: Int, completionHandler: @escaping ([SimilarRecommendResults]) -> Void
) {
let MOVIE_URL = "\(API.URL.KMDB.Recommend.movie)\(id)/recommendations?language=ko"
let TV_URL = "\(API.URL.KMDB.Recommend.tv)\(id)/recommendations?language=ko"
let URL = type == 0 ? MOVIE_URL : TV_URL
AF.request(URL, headers: headers)
.responseDecodable(of: SimilarRecommend.self) { res in
switch res.result {
case .success(let value):
completionHandler(value.results)
case .failure(let error):
print(error)
}
}
}
extension RecommendViewController {
func callRequest() {
let group = DispatchGroup()
group.enter()
DispatchQueue.global().async(group: group) {
NetworkManager.shared.getSimilarContentstype(type: self.itemType, id: self.itemId) { similarList in
self.contentsList[0] = similarList
}
group.leave()
}
}
group.enter()
DispatchQueue.global().async(group: group) {
NetworkManager.shared.getRecommendContents(type: self.itemType, id: self.itemId) { recommendList in
self.contentsList[1] = recommendList
}
group.leave()
}
}
// 통신 완료되면 테이블 뷰 리로드
group.notify(queue: .main) {
self.tableView.reloadData()
}
}
}
DispatchQueue.global()을 통해 Concurrent Queue로 비동기 처리를 해주고, 비동기 특성상 작업의 종료 시점을 알 수 없기 때문에 DispatchGroup으로 묶어서 모든 작업이 완료되는 시점에 테이블 뷰를 리로드 해주었습니다.
그리고 API로 응답 받은 데이터를 초반에 정의해둔 contentList에 넣어줍니다!
group.notify(queue: .main) {
self.tableView.reloadData()
}
참고로 이 부분에서 Main Queue로 노티를 받는 이유는 테이블 뷰를 갱신한다는 것은 UI가 업데이트된다는 의미이고, UI는 Main 스레드가 담당하기 때문에 queue를 .main으로 설정해주는 거랍니다-!
데이터까지 받아두었으니 이제 드디어 화면에 뿌려줄 차례입니다 ✨
extension RecommendViewController: UITableViewDelegate, UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return contentsList.count
}
.
.
.
우선 테이블 뷰에서는 가지고 있는 contentList의 개수에 맞춰 테이블 뷰 셀이 출력될 수 있도록 하고,
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: RecommendTableViewCell.id, for: indexPath) as! RecommendTableViewCell
cell.collectionView.tag = indexPath.row
cell.collectionView.delegate = self
cell.collectionView.dataSource = self
cell.collectionView.register(RecommendCollectionViewCell.self, forCellWithReuseIdentifier: RecommendCollectionViewCell.id)
let title = titleList[indexPath.row]
cell.configureCellData(title: title)
cell.collectionView.reloadData()
return cell
}
각각의 테이블 뷰 셀에서는 셀 안에 만들어두었던 컬렉션 뷰의 태그값으로 indexPath.row를 할당하고,
사용할 프로토콜을 위임 받아줍니다!!!!!!! (중요)
cell.collectionView.delegate = self
cell.collectionView.dataSource = self
셀 안에서는 트랜지션 등이 이뤄지기 어렵기 때문에 뷰 컨트롤러가 해당 일을 해주어야 하는데, 컬렉션 뷰 자체가 테이블 뷰 셀 안에 구현되어있다보니 UICollectionViewDelegate, UICollectionViewDataSource를 RecommendViewController에서 해줄 수 있도록 처리하는 과정입니다. 🤓
셀..안에서..컬렉션 뷰가 할 일을..셀 바깥에서..할 수 있도록...한다..(메모)...✍️
이렇게 처리가 잘 되었다면 이제 RecommendViewController에서 아래와 같이 코드를 작성할 수 있게 됩니다.
extension RecommendViewController: UICollectionViewDelegate, UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return contentsList[collectionView.tag].count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: RecommendCollectionViewCell.id, for: indexPath) as! RecommendCollectionViewCell
let data = contentsList[collectionView.tag][indexPath.item]
cell.configureCellData(data: data)
return cell
}
}
영화 | TV | |
---|---|---|
![]() |
모든 작업이 끝나면 요런 화면이 나옵니다!!!!!! 짝짝짝짝......
생긴건 어제자 화면과 비슷하지만, 테이블 뷰로 베이스 작업을 해두었기 때문에 추천 콘텐츠 화면에서 보여줄 컬렉션이 계속해서 늘어나더라도 상하 스크롤과 각 좌우 스크롤이 함께 가능해지겠죠!?
오늘은 리팩토링을 하면서 테이블 뷰 안에서 컬렉션 뷰를 구현하는 학습을 해보았습니다.
이 조합은 다양한 앱에서 많이 사용되는 구조이기 때문에 더 많이 구현해보고 계속해서 익숙해져야겠다는 생각을 많이 하게된 거 같아요. 특히 기존의 ViewController에서 Extension을 통해 테이블 뷰와 컬렉션 뷰의 프로토콜로 작업하는 거에 익숙해져있다가, 테이블 뷰 셀 안에 컬렉션 뷰를 구현하고, 또 컬렉션 뷰의 제어는 바깥에서 해주다보니 작업 중간중간 헷갈리는 부분들이 자꾸 생겨 여러 파일을 왔다갔다 하면서 구현한 거 같습니다. 🤯
고통스럽지만 재밌는 작업이었네요,, 다음엔 또 어떤 작업이 저를 기다리고 있을지 두근거립니다. (아님)
그럼 다음 편으로 찾아뵙겠습니다. 안녕!