✅ MVVM + Combine + UISearchViewController를 사용한 검색 기능 구현 순서
📍 Network 설정
TMDB API를 통해 데이터를 가져올 함수 구현
// MARK: - APICaller
class NetworkManager {
static let shared = NetworkManager()
...
// MARK: - Search
/// 검색어를 통해 영화 목록 결과
func searchMovie(with keyword: String, page: Int = 1) async throws -> SearchResultMedia {
let url = URL(string: "\(Constants.baseURL)/search/movie")!
var components = URLComponents(url: url, resolvingAgainstBaseURL: true)!
let queryItems: [URLQueryItem] = [
URLQueryItem(name: "query", value: "\(keyword)"),
URLQueryItem(name: "include_adult", value: "false"),
URLQueryItem(name: "language", value: "en-US"),
URLQueryItem(name: "page", value: "\(page)"),
]
components.queryItems = components.queryItems.map { $0 + queryItems } ?? queryItems
var request = URLRequest(url: components.url!)
request.httpMethod = "GET"
request.timeoutInterval = 10
request.allHTTPHeaderFields = [
"accept": "application/json",
"Authorization": "Bearer \(Constants.API_KEY)"
]
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw APIError.failedToGetData
}
let movieSearchResult = try JSONDecoder().decode(SearchResultMedia.self, from: data)
return movieSearchResult
}
/// 검색어를 통해 TV 목록 결과
func searchTV(with keyword: String, page: Int = 1) async throws -> SearchResultMedia {
let url = URL(string: "\(Constants.baseURL)/search/tv")!
var components = URLComponents(url: url, resolvingAgainstBaseURL: true)!
let queryItems: [URLQueryItem] = [
URLQueryItem(name: "query", value: "\(keyword)"),
URLQueryItem(name: "include_adult", value: "false"),
URLQueryItem(name: "language", value: "en-US"),
URLQueryItem(name: "page", value: "\(page)"),
]
components.queryItems = components.queryItems.map { $0 + queryItems } ?? queryItems
var request = URLRequest(url: components.url!)
request.httpMethod = "GET"
request.timeoutInterval = 10
request.allHTTPHeaderFields = [
"accept": "application/json",
"Authorization": "Bearer \(Constants.API_KEY)"
]
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw APIError.failedToGetData
}
let tvSearchResult = try JSONDecoder().decode(SearchResultMedia.self, from: data)
return tvSearchResult
}
/// 검색어를 통해 사람 목록 결과
func searchPerson(with keyword: String, page: Int = 1) async throws -> SearchResultPerson {
let url = URL(string: "\(Constants.baseURL)/search/person")!
var components = URLComponents(url: url, resolvingAgainstBaseURL: true)!
let queryItems: [URLQueryItem] = [
URLQueryItem(name: "query", value: "\(keyword)"),
URLQueryItem(name: "include_adult", value: "false"),
URLQueryItem(name: "language", value: "en-US"),
URLQueryItem(name: "page", value: "\(page)"),
]
components.queryItems = components.queryItems.map { $0 + queryItems } ?? queryItems
var request = URLRequest(url: components.url!)
request.httpMethod = "GET"
request.timeoutInterval = 10
request.allHTTPHeaderFields = [
"accept": "application/json",
"Authorization": "Bearer \(Constants.API_KEY)"
]
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw APIError.failedToGetData
}
let personSearchResult = try JSONDecoder().decode(SearchResultPerson.self, from: data)
return personSearchResult
}
}
📌 검색어를 통해 "영화, 티비, 인물" 정보를 받아오는 함수 통합 (별도관리- 유지 보수 목적)
class SearchService {
// MARK: - Variable
private let networkhManager = NetworkManager.shared
// MARK: - Function
func searchAll(with keyword: String, page: Int = 1) async throws -> (movies: SearchResultMedia, tvShows: SearchResultMedia, people: SearchResultPerson) {
async let movies = try networkhManager.searchMovie(with: keyword)
async let tvShows = try networkhManager.searchTV(with: keyword)
async let people = try networkhManager.searchPerson(with: keyword)
return try await (movies: movies, tvShows: tvShows, people: people)
}
}
📍 UISearchViewController 설정 및 초기 UI 구성
✅ 목표: UISearchController를 설정하고, 검색 결과를 표시할 SearchResultViewController를 연결
✅ 구현 요소:
- SearchViewController에서 searchController를 생성하고, searchResultsUpdater 설정
- SearchResultViewController를 검색 결과를 표시할 searchResultsController로 연결
- SearchViewController에서 검색어 입력 시 ViewModel에 전달
class SearchViewController: UIViewController {
// MARK: - Variable
private let viewModel: SearchViewModel = SearchViewModel()
private var cancellables = Set<AnyCancellable>()
// MARK: - UI Component
private let searchController: UISearchController
private let resultViewController: SearchResultViewController
// MARK: - Init
init() {
self.resultViewController = SearchResultViewController(viewModel: viewModel)
self.searchController = UISearchController(searchResultsController: resultViewController)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
configureSearchController()
configureNavigationBarAppearance()
searchController.searchBar.delegate = self
}
// MARK: - Function
private func configureNavigationBarAppearance() {
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
// ✅ 네비게이션 바 배경 검은색
appearance.backgroundColor = .black
// ✅ 큰 타이틀 색상 흰색
appearance.largeTitleTextAttributes = [.foregroundColor: UIColor.white]
// ✅ 일반 타이틀 색상 흰색
appearance.titleTextAttributes = [.foregroundColor: UIColor.white]
navigationController?.navigationBar.standardAppearance = appearance
navigationController?.navigationBar.scrollEdgeAppearance = appearance
// ✅ 네비에기션 타이틀 설정
navigationItem.title = "Search"
navigationController?.navigationBar.prefersLargeTitles = true
}
private func configureSearchController() {
searchController.searchBar.placeholder = "Search for a Movie or Tv or Person"
searchController.searchBar.searchBarStyle = .minimal
// ✅ 검색 컨트롤러 추가
searchController.obscuresBackgroundDuringPresentation = false
navigationItem.searchController = searchController
// ✅ 스크롤 시에도 검색창이 사라지지 않도록 설정
navigationItem.hidesSearchBarWhenScrolling = false
// ✅ 서치바의 글색상 및 돋보기 색상 변경
let textFieldInsideSearchBar = navigationItem.searchController?.searchBar.value(forKey: "searchField") as? UITextField
textFieldInsideSearchBar?.textColor = .white
textFieldInsideSearchBar?.leftView?.tintColor = .white
}
}
// MARK: - Extension: UISearchBarDelegate
// ✅ SearchViewController에서 검색어 입력 시 ViewModel에 전달
extension SearchViewController: UISearchBarDelegate {
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
guard let keyword = searchBar.text,
!keyword.trimmingCharacters(in: .whitespaces).isEmpty,
keyword.trimmingCharacters(in: .whitespaces).count >= 2
else { return }
viewModel.search(query: keyword)
searchBar.resignFirstResponder()
}
}
📍 SearchViewModel에서 검색 API 호출 및 데이터 저장
✅ 목표: 검색어를 받아서 API 요청을 보내고, 결과를 @Published 변수로 저장
✅ 구현 요소:
- @Published var movies: [MediaResult] = [] 같은 상태 변수 선언
- search(query:) 메서드를 통해 API 요청 및 데이터 업데이트
- loadMore() 메서드를 통해 추가 데이터를 요청할 수 있도록 구현
import Foundation
import Combine
class SearchViewModel: ObservableObject {
@Published var movies: [MediaResult] = [] // 🎬 검색된 영화 목록 저장
@Published var tvShows: [MediaResult] = [] // 📺 검색된 TV 프로그램 목록 저장
@Published var people: [PersonResult] = [] // 👤 검색된 인물 목록 저장
@Published var canLoadMoreMovies = false // 🎬 더 많은 영화 데이터를 로드할 수 있는지 여부
@Published var canLoadMoreTVShows = false // 📺 더 많은 TV 데이터를 로드할 수 있는지 여부
@Published var canLoadMorePeople = false // 👤 더 많은 인물 데이터를 로드할 수 있는지 여부
@Published var translatedMovieOverviews: [Int: String] = [:] // 영화 overview 번역 저장
@Published var translatedTVOverviews: [Int: String] = [:] // TV overview 번역 저장
@Published var translatedPeopleBiographies: [Int: String] = [:] // 사람 biography 번역 저장
private let searchService = SearchService()
private var cancellables = Set<AnyCancellable>()
private var currentQuery = "" // 🔍 현재 검색 중인 키워드 저장
private var moviePage = 1 // 🎬 현재 영화 데이터 페이지 번호
private var tvPage = 1 // 📺 현재 TV 데이터 페이지 번호
private var peoplePage = 1 // 👤 현재 인물 데이터 페이지 번호
private var totalMoviesCount = 0 // 🎬 검색된 영화 총 개수
private var totalTVShowsCount = 0 // 📺 검색된 TV 프로그램 총 개수
private var totalPeopleCount = 0 // 👤 검색된 인물 총 개수
// MARK: - 검색 실행
func search(query: String) {
currentQuery = query
resetState()
Task {
do {
let results = try await searchService.searchAll(with: query, page: 1)
DispatchQueue.main.async {
self.movies = results.movies.results
self.tvShows = results.tvShows.results
self.people = results.people.results
self.totalMoviesCount = results.movies.totalResults
self.totalTVShowsCount = results.tvShows.totalResults
self.totalPeopleCount = results.people.totalResults
self.translateOverviews(for: .movie(self.movies))
self.translateOverviews(for: .tv(self.tvShows))
self.updateLoadMoreStatus()
}
} catch {
print("❌ 검색 요청 실패: \(error.localizedDescription)")
}
}
}
// MARK: - 더보기 실행 (불필요한 요청 방지)
func loadMore(for type: SearchSection) {
switch type {
case .movie:
guard movies.count < totalMoviesCount else { return }
fetchMoreMovies()
case .tv:
guard tvShows.count < totalTVShowsCount else { return }
fetchMoreTVShows()
case .people:
guard people.count < totalPeopleCount else { return }
fetchMorePeople()
}
}
// MARK: - API 호출하여 추가 데이터 가져오기
private func fetchMoreMovies() {
moviePage += 1
Task {
do {
let moreMovies = try await NetworkManager.shared.searchMovie(with: currentQuery, page: moviePage)
DispatchQueue.main.async {
self.movies.append(contentsOf: moreMovies.results)
self.updateLoadMoreStatus()
}
} catch {
print("❌ 추가 영화 로드 실패: \(error.localizedDescription)")
}
}
}
private func fetchMoreTVShows() {
tvPage += 1
Task {
do {
let moreTVShows = try await NetworkManager.shared.searchTV(with: currentQuery, page: tvPage)
DispatchQueue.main.async {
self.tvShows.append(contentsOf: moreTVShows.results)
self.updateLoadMoreStatus()
}
} catch {
print("❌ 추가 TV 로드 실패: \(error.localizedDescription)")
}
}
}
private func fetchMorePeople() {
peoplePage += 1
Task {
do {
let morePeople = try await NetworkManager.shared.searchPerson(with: currentQuery, page: peoplePage)
DispatchQueue.main.async {
self.people.append(contentsOf: morePeople.results)
self.updateLoadMoreStatus()
}
} catch {
print("❌ 추가 인물 로드 실패: \(error.localizedDescription)")
}
}
}
// MARK: - "더보기" 버튼 활성화 여부 업데이트
private func updateLoadMoreStatus() {
canLoadMoreMovies = movies.count < totalMoviesCount
canLoadMoreTVShows = tvShows.count < totalTVShowsCount
canLoadMorePeople = people.count < totalPeopleCount
}
// MARK: - 상태 초기화
private func resetState() {
moviePage = 1
tvPage = 1
peoplePage = 1
totalMoviesCount = 0
totalTVShowsCount = 0
totalPeopleCount = 0
canLoadMoreMovies = false
canLoadMoreTVShows = false
canLoadMorePeople = false
movies.removeAll()
tvShows.removeAll()
people.removeAll()
}
private func translateOverviews(for media: SearchTranslatedItem) {
switch media {
case .movie(let movies):
for movie in movies {
Task {
let translatedText = await GoogleTranslateAPI.translateText(movie.overview ?? "정보 없음 😅")
DispatchQueue.main.async {
self.translatedMovieOverviews[movie.id] = translatedText
}
}
}
case .tv(let tvs):
for tv in tvs {
Task {
let translatedText = await GoogleTranslateAPI.translateText(tv.overview ?? "정보 없음 😅")
DispatchQueue.main.async {
self.translatedTVOverviews[tv.id] = translatedText
}
}
}
}
}
}
📍SearchResultViewController에서 ViewModel의 데이터를 구독
✅ 목표: SearchViewModel의 데이터가 변경될 때 자동으로 UI 업데이트
✅ 구현 요소:
- viewModel.$movies 등을 구독하여 변경될 때 reloadData() 실행
class SearchResultViewController: UIViewController {
private let viewModel: SearchViewModel
private var cancellables = Set<AnyCancellable>()
init(viewModel: SearchViewModel) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
override func viewDidLoad() {
super.viewDidLoad()
bindViewModel()
}
private func bindViewModel() {
viewModel.$movies
.sink { [weak self] _ in self?.reloadData() }
.store(in: &cancellables)
viewModel.$tvShows
.sink { [weak self] _ in self?.reloadData() }
.store(in: &cancellables)
viewModel.$people
.sink { [weak self] _ in self?.reloadData() }
.store(in: &cancellables)
}
private func reloadData() {
var snapshot = NSDiffableDataSourceSnapshot<SearchSection, SearchItem>()
if !viewModel.movies.isEmpty {
snapshot.appendSections([.movie])
snapshot.appendItems(viewModel.movies.map { SearchItem.movie($0) }, toSection: .movie)
}
if !viewModel.tvShows.isEmpty {
snapshot.appendSections([.tv])
snapshot.appendItems(viewModel.tvShows.map { SearchItem.tv($0) }, toSection: .tv)
}
if !viewModel.people.isEmpty {
snapshot.appendSections([.people])
snapshot.appendItems(viewModel.people.map { SearchItem.people($0) }, toSection: .people)
}
dataSource?.apply(snapshot, animatingDifferences: true)
}
}
🔷 reloadData() 함수의 역할 및 동작 방식
https://explorer89.tistory.com/355
🤔 reloadData() 함수의 역할 및 동작 방식
✅ reloadData() 함수의 역할 및 동작 방식이 함수가 실행되면 UICollectionViewDiffableDataSource를 업데이트하여 최신 검색 결과를 UI에 표시 🔹 1. reloadData() 함수의 전체적인 흐름private func reloadData() { //
explorer89.tistory.com
📍UICollectionViewCompositionalLayout으로 검색 결과 UI 구성
✅ 목표: UICollectionViewCompositionalLayout을 사용해 검색 결과를 섹션별로 표시
✅ 구현 요소:
- UICollectionViewDiffableDataSource를 사용해 데이터를 관리
- createCompositionalLayout()을 통해 UI 구성
private func createCompostionalLayout() -> UICollectionViewLayout {
return UICollectionViewCompositionalLayout { sectionIndex, _ in
let sectionType = SearchSection.allCases[sectionIndex]
switch sectionType {
default:
return self.createSearchResultSection()
}
}
}
private func createSearchResultSection() -> NSCollectionLayoutSection {
// ✅ 아이템 크기 (각 셀 크기)
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(0.33))
let layoutItem = NSCollectionLayoutItem(layoutSize: itemSize)
// ✅ 그룹 설정 (수직 그룹)
let layoutGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.93), heightDimension: .estimated(400))
let layoutGroup = NSCollectionLayoutGroup.vertical(layoutSize: layoutGroupSize, subitems: [layoutItem])
// ✅ 섹션 설정
let layoutSection = NSCollectionLayoutSection(group: layoutGroup)
layoutSection.interGroupSpacing = 10
layoutSection.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
// ✅ "검색 결과 더 보기" 버튼 추가 (Supplementary Item - elementKindSectionFooter)
let footerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.93), heightDimension: .estimated(50))
let footer = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: footerSize, elementKind: UICollectionView.elementKindSectionFooter, alignment: .bottom)
// ✅ 섹션 헤더 추가 (Supplementary Item - elementKindSectionHeader)
let headerSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(50))
let header = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: headerSize, elementKind: UICollectionView.elementKindSectionHeader, alignment: .top)
layoutSection.boundarySupplementaryItems = [header, footer]
return layoutSection
}
📍 UICollectionViewDiffableDataSource로 데이터 적용
✅ 목표: DiffableDataSource를 사용해 UI를 업데이트
✅ 구현 요소:
- reloadData()에서 NSDiffableDataSourceSnapshot을 생성하여 UI 갱신
private func configure<T: SelfConfiguringSearchCell>(_ cell: T, with model: SearchItem) {
cell.configure(with: model)
}
private func createDataSource() {
dataSource = UICollectionViewDiffableDataSource<SearchSection, SearchItem>(collectionView: searchResultCollectionView) { searchResultCollectionView, indexPath, item in
guard let cell = searchResultCollectionView.dequeueReusableCell(withReuseIdentifier: SearchResultCell.reuseIdentifier, for: indexPath) as? SearchResultCell else { return UICollectionViewCell() }
cell.setViewModel(self.viewModel)
switch item {
case .movie(let movie):
self.configure(cell, with: .movie(movie))
case .tv(let tv):
self.configure(cell, with: .tv(tv))
case .people(let person):
self.configure(cell, with: .people(person))
}
return cell
}
dataSource?.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
guard let self = self else { return UICollectionReusableView() }
if kind == UICollectionView.elementKindSectionHeader {
// ✅ 섹션 헤더 처리
let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: SearchSectionHeader.reuseIdentifier, for: indexPath) as? SearchSectionHeader
let section = SearchSection.allCases[indexPath.section]
header?.configure(with: section.title)
return header ?? UICollectionReusableView() // 🔴 nil 반환 방지
}
if kind == UICollectionView.elementKindSectionFooter {
// ✅ 더보기 버튼(검색 결과 전체보기) 처리
let footer = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: SearchFooterView.reuseIdentifier, for: indexPath) as! SearchFooterView
let section = SearchSection.allCases[indexPath.section]
let canLoadMore: Bool
switch section {
case .movie: canLoadMore = self.viewModel.canLoadMoreMovies
case .tv: canLoadMore = self.viewModel.canLoadMoreTVShows
case .people: canLoadMore = self.viewModel.canLoadMorePeople
}
footer.isHidden = !canLoadMore // ✅ 필요 없으면 숨김
footer.configure(with: "검색 결과 전체보기") {
switch section {
case .movie: self.viewModel.loadMore(for: .movie)
case .tv: self.viewModel.loadMore(for: .tv)
case .people: self.viewModel.loadMore(for: .people)
}
}
return footer
}
return UICollectionReusableView() // 🔴 nil 반환 방지
}
}
https://explorer89.tistory.com/356
🤔 createDataSource() 메서드에서 "검색 결과 전체보기" 버튼 동작
🔹 1. SearchFooterView의 역할📌 SearchFooterView는 UICollectionView의 각 섹션(영화, TV, 인물) 하단에 추가되는 버튼을 포함하는 뷰📌 "검색 결과 전체보기" 버튼을 누르면, 추가 데이터를 로드할 수 있도
explorer89.tistory.com
✅ 📌 최종 구현 순서 요약
1️⃣ UISearchViewController를 설정하여 검색 UI 구성
2️⃣ SearchViewModel에서 검색 API 요청 및 데이터 관리
3️⃣ SearchViewController에서 UISearchResultsUpdating을 구현하여 검색어 입력 시 viewModel.search(query:) 호출
4️⃣ SearchResultViewController에서 viewModel을 구독하여 UI 자동 업데이트
5️⃣ UICollectionViewCompositionalLayout을 사용하여 검색 결과 UI를 구성
6️⃣ UICollectionViewDiffableDataSource를 활용해 검색 결과를 표시
7️⃣ 더보기 기능 추가하여 추가 데이터 로드
'Project > MovieClip' 카테고리의 다른 글
❌ 문제 해결 - 검색 결과의 상세페이지 이동이 안됨.. (0) | 2025.02.24 |
---|---|
🔨 검색결과 내의 영화, 티비의 장르 가져오는 부분 개선 (0) | 2025.02.24 |
🤔 createDataSource() 메서드에서 "검색 결과 전체보기" 버튼 동작 (0) | 2025.02.24 |
🤔 reloadData() 함수의 역할 및 동작 방식 (0) | 2025.02.24 |
🔥 MVVM + Combine을 통한 검색기능 구현 1편 패턴 비교 (0) | 2025.02.24 |