상영 중인 영화와 상영 예정인 영화 두 가지로 나눠 검색 및 탐색을 할 수 있도록 segmentedControl 기능 추가하였다.
protocol textFieldDelegate: AnyObject {
func searchingMovie(_ input: String)
func pressReturnKey()
func didChangeSegment(index: Int) // 추가
}
기존에 SearchView에서 텍스트 필드 이벤트 처리를 위해 정의해 놓은 프로토콜에 새로운 메서드를 추가하였다. 프로토콜 이름이 textFieldDelegate라 조금 맞지 않지만, 이름은 알맞게 변경하면 될 것이다.
매개변수인 index값은 segmentedControl의 메뉴가 상영중, 상영예정 두가지기 때문에 0 아니면 1이 넘어가게 될 것이다.
// SearchView에 추가
private let height = UIScreen.main.bounds.height // UI 제약사항을 동적으로 주기 위한 뷰높이 계산
weak var delegate: textFieldDelegate?
// segmented Control
private let segmentedControl: UISegmentedControl = {
let control = UISegmentedControl(items: ["상영중", "상영예정"])
control.selectedSegmentIndex = 0
control.backgroundColor = .systemGray6
control.selectedSegmentTintColor = .white
control.setTitleTextAttributes([.font: UIFont.boldSystemFont(ofSize: 16)], for: .selected)
control.setTitleTextAttributes([.font: UIFont.systemFont(ofSize: 15)], for: .normal)
control.addTarget(self, action: #selector(segmentChanged), for: .valueChanged)
return control
}()
// MARK: - UI 셋업
private func setupUI() {
backgroundColor = .white
[
searchTextField,
movieCollectionView,
segmentedControl
].forEach { addSubview($0) }
searchTextField.snp.makeConstraints {
$0.top.equalToSuperview().offset(height / 6)
$0.leading.trailing.equalToSuperview().inset(16)
$0.height.equalTo(44)
}
segmentedControl.snp.makeConstraints {
$0.top.equalTo(searchTextField.snp.bottom).offset(16)
$0.leading.trailing.equalToSuperview().inset(16)
$0.height.equalTo(40)
}
// segmentedControl 메뉴 선택 처리 메서드
@objc func segmentChanged() {
delegate?.didChangeSegment(index: segmentedControl.selectedSegmentIndex)
}
segmentedControl을 클릭할 때 해당 이벤트를 컨트롤러가 처리해야 하므로 delegate 패턴을 통해 컨트롤러로 이벤트를 전달하였다.
class SearchViewController: UIViewController {
// 세그먼트 선택 메뉴 저장할 변수, 초기값은 .nowPlaying
private var selectedCategory = NetworkManager.URLEndpointSet.nowPlaying
// 세그먼트에 따른 파라미터, 기본값은 common
private var selectedParameter = NetworkManager.URLParameterSet.common
// 검색 키워드 저장할 변수
private var searchKeyword: String = ""
// 기존 코드
}
extension SearchViewController: textFieldDelegate {
// 기존 코드
// 추가된 코드
// Segmented Control 선택 메서드
func didChangeSegment(index: Int) {
selectedCategory = (index == 0) ? .nowPlaying : .upcoming
selectedParameter = (index == 0) ? NetworkManager.URLParameterSet.common : NetworkManager.URLParameterSet.secondPage
fetchMovieData(selectedCategory, parameter: selectedParameter)
}
}
extension SearchViewController {
private func fetchMovieData(_ endPoint: NetworkManager.URLEndpointSet, parameter: URLParameters) {
Task { [weak self] in
do {
// 1. MovieDataManager를 호출해 서버에서 영화 데이터 가져오기
let movieData: MovieData = try await NetworkManager.shared.fetchData(
endpoint: endPoint, // 'nowPlaying' 엔드포인트를 사용하여 현재 상영 중인 영화 목록을 요청
parameters: parameter
)
var tempMovies = [MovieDataSource]() // 임시로 데이터를 저장할 배열
// 2. 가져온 영화 목록을 순회하며 각 영화에 대한 이미지 데이터를 요청
for movie in movieData.results {
let posterPath = movie.posterPath // 영화의 포스터 이미지 경로
let image = try await ImageManager.shared.fetchImage(
from: posterPath ?? "",
size: .w342
)
// MovieDataSource 구조체로 영화 데이터와 이미지를 묶어 저장
let movieSource = MovieDataSource(movieData: movie, image: image)
tempMovies.append(movieSource) // // 임시 배열에 추가
}
// 3. 가져온 데이터를 메인 데이터 소스와 검색 결과 배열에 할당
self?.movieDataSource = tempMovies // 컬렉션 뷰에 표시할 배열 업데이트
// 저장된 검색 키워드로 필터링
if let keyword = self?.searchKeyword, !keyword.isEmpty {
self?.searchMovies = tempMovies.filter { $0.movieData.title.contains(keyword) }
} else {
self?.searchMovies = tempMovies // 초기 화면에 데이터를 표시
}
// 4. UI 업데이트는 메인 스레드에서 실행
DispatchQueue.main.async {
self?.searchView.reloadCollectionView() // 컬렉션 뷰 리로드
}
} catch {
// 5. 데이터 가져오기 실패 시 에러 메시지 출력
print("데이터 불러오기 실패")
}
}
}
}
아무 매개변수도 없던 fetchMovieData 메서드에 매개 변수를 추가하여 현재 상영중인 영화 데이터 목록과, 상영 예정인 영화 데이터 목록을 받아오도록 변경하였다.
그리고
self?.movieDataSource = tempMovies
self?.searchMovies = tempMovies
이던 3번 코드를 변경하여, 다른 탭을 눌러도 텍스트 필드에 입력한 검색 키워드가 유지되어 적용되도록 하였다.
이 코드를 처음엔 쓰지 않아서, 탭한 메뉴에만 검색이 적용되고 그 상태에서 다른 탭을 누르면 다시 영화 전체 목록이 나왔었다.
서버에서 현재 상영작과 상영 예정작에 같은 영화가 나오는 경우가 있어서, 우리 문제는 아니지만 결과 화면이 아름답지 않았다.
그래서 fetchMovieData 메서드의 매개변수에 parameter 코드를 추가한 것이다.
extension NetworkManager {
enum URLParameterSet {
static let empty: URLParameters = [:]
static let common: URLParameters = ["language": "ko-KR", "page": "1"]
static let secondPage: URLParameters = ["language": "ko-KR", "page": "2"] // 추가한 api 파라미터
// 기존 코드
}
처음엔 현재 상영 중인 영화 목록과 상영 예정 영화 목록 둘 다 api 파라미터를 common으로 했다. 즉, 해당 목록의 1페이지만 가져오는 것이었다.
서버가 왜 상영 예정작과 상영 중인 영화 목록에 같은 영화가 섞이도록 반환 해주는지는 모르겠지만,
결과 화면에서 제대로 다른 api 요청임을 확인하기 위해 다른 페이지를 불러오는 secondPage 파라미터를 추가한 것이다.
fetchMovieData 메서드에 넣어준 parameter가 바로 이 secondPage를 저장한 프로퍼티인 것이다.
이제 사용자가 현재 상영중인 영화만 확인할 수 있는게 아니라, 상영 예정인 영화도 확인할 수 있게 되었다.
MVC에 대해 확실히 배우는 느낌이다.
view와 Controller를 명확히 분리해 놓으니 새로운 기능을 추가하려고 할 때 어디에 추가해야 할지 확실히 알 수 있었다.
그리고 기능별로 메서드들도 분리해서 책임을 최소화 한 덕분에, 약간의 코드 수정 및 재사용만으로도 기능을 추가할 수 있었다.
덕분에 '현재 상영 중인 영화만 확인할 수 있는게 아니라, 상영 예정인 영화도 확인할 수 있게 하자'는 이야기가 나온지 한 시간여 만에 기능을 추가할 수 있었다.
불과 2~3주 전만 해도 이거 하나 하는데 꼬박 하루가 걸렸을 것이라고 생각한다.
나 뿐만 아니라 다른 팀원들도 다같이 코드 컨벤션을 지키고, 코드 리뷰도 진행하면서 리뷰하기 편하도록 주석도 달고 PR에 설명도 꼼꼼히 적어준 덕분에 기능을 추가하거나 변경하는게 무섭고 힘들기보단 흥미로운 경험이 될 수 있었다.