본문 바로가기

Project/MovieClip

🔥 MVVM + Combine을 통한 검색기능 구현 2편 구현 순서?

✅ 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️⃣ 더보기 기능 추가하여 추가 데이터 로드

 

\