diff --git a/Smashing-Assignment/Networks/API/PersonAPI.swift b/Smashing-Assignment/Networks/API/PeopleAPI.swift similarity index 77% rename from Smashing-Assignment/Networks/API/PersonAPI.swift rename to Smashing-Assignment/Networks/API/PeopleAPI.swift index 4f20349..6481e83 100644 --- a/Smashing-Assignment/Networks/API/PersonAPI.swift +++ b/Smashing-Assignment/Networks/API/PeopleAPI.swift @@ -9,11 +9,11 @@ import Foundation import Moya import Alamofire -enum PersonAPI { - case fetchPeople(page: Int) +enum PeopleAPI { + case fetchPeople(name: String, page: Int) } -extension PersonAPI: BaseTargetType { +extension PeopleAPI: BaseTargetType { var path: String { switch self { @@ -28,9 +28,10 @@ extension PersonAPI: BaseTargetType { var task: Task { switch self { - case .fetchPeople(let page): + case .fetchPeople(let name, let page): return .requestParameters( parameters: ["key": Environment.movie_API_Key, + "peopleNm": name, "curPage": page, "itemPerPage": 10], encoding: URLEncoding.default) diff --git a/Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchMovieCollectionViewCell.swift b/Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchMovieCollectionViewCell.swift new file mode 100644 index 0000000..9be9850 --- /dev/null +++ b/Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchMovieCollectionViewCell.swift @@ -0,0 +1,66 @@ +// +// SearchMovieCollectionViewCell.swift +// Smashing-Assignment +// +// Created by 홍준범 on 1/8/26. +// + +import UIKit +import Combine + +import SnapKit +import Then + +final class SearchPeopleCollectionViewCell: UICollectionViewCell { + + static let identifier: String = "SearchPeopleCollectionViewCell" + + private let peopleNameLabel = UILabel().then { + $0.font = .systemFont(ofSize: 20, weight: .bold) + } + + private let roleLabel = UILabel().then { + $0.textAlignment = .right + $0.font = .systemFont(ofSize: 14, weight: .regular) + $0.textColor = .systemGray + } + override init(frame: CGRect) { + super.init(frame: frame) + contentView.addSubview(peopleNameLabel) + contentView.addSubview(roleLabel) + + peopleNameLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.leading.equalToSuperview().offset(16) + make.trailing.equalTo(roleLabel.snp.leading).offset(-10) + } + + roleLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.trailing.equalToSuperview().offset(-16) + make.width.equalTo(80) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(data: PeopleDTO, index: Int) { + // 색상 변경 (10명씩 다른 색) + switch (index / 10) % 3 { + case 0: + peopleNameLabel.textColor = .systemPink + case 1: + peopleNameLabel.textColor = .systemCyan + case 2: + peopleNameLabel.textColor = .systemGreen + default: + peopleNameLabel.textColor = .white + } + + peopleNameLabel.text = "\(index + 1): \(data.peopleNm)" + roleLabel.text = data.repRoleNm ?? "-" + } + +} diff --git a/Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchMovieViewModel.swift b/Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchMovieViewModel.swift new file mode 100644 index 0000000..a02945c --- /dev/null +++ b/Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchMovieViewModel.swift @@ -0,0 +1,157 @@ +// +// SearchMovieViewModel.swift +// Smashing-Assignment +// +// Created by 홍준범 on 1/8/26. +// + +import Foundation +import Combine + +protocol InputOutputProtocol { + + associatedtype Input + associatedtype Output + + func transform(input: AnyPublisher) -> Output + +} +// +//protocol SearchPeopleViewModelProtocol { +// associatedtype Input +// associatedtype Output +// +// func transform(input: AnyPublisher) -> Output +// +// var people: [PeopleDTO] { get } +// var numberOfPeople: Int { get } +// func person(at index: Int) -> PeopleDTO? +// +//} + +protocol SearchPeopleViewModelProtocol: InputOutputProtocol where Input == SearchPeopleViewModel.Input, Output == SearchPeopleViewModel.Output { + var people: [PeopleDTO] { get } + var numberOfPeople: Int { get } + func person(at index: Int) -> PeopleDTO? +} + +class SearchPeopleViewModel: SearchPeopleViewModelProtocol { + + enum Input { + case searchTextChanged(String) + case scrollReachedBottom + } + + struct Output { + let people = PassthroughSubject<[PeopleDTO], Never>.init() + let error = PassthroughSubject.init() + let isLoading = CurrentValueSubject.init(false) + } + +// struct Output { +// let people: PassthroughSubject<[PeopleDTO], Never> +// let error: PassthroughSubject +// let isLoading: CurrentValueSubject +// } +// +// private let output = Output( +// people: PassthroughSubject(), +// error: PassthroughSubject(), +// isLoading: CurrentValueSubject(false) +// ) +// +// private let outputPublisher = PassthroughSubject() + + var people: [PeopleDTO] { + return peopleList + } + + var numberOfPeople: Int { + return peopleList.count + } + + func person(at index: Int) -> PeopleDTO? { + guard index < peopleList.count else { return nil } + return peopleList[index] + } + + private let output = Output() +// private let outputPublisher = PassthroughSubject() + + private var cancellables = Set() + private var peopleList: [PeopleDTO] = [] + + private var currentSearchText = "" + private var currentPage = 1 + private var isPeopleFetching = false + + func transform(input: AnyPublisher) -> Output { + + input + .filter { if case .searchTextChanged = $0 { return true } + return false } + .compactMap { if case .searchTextChanged(let text) = $0 { return text } + return nil + } + .debounce(for: .seconds(0.3), scheduler: DispatchQueue.main) + .removeDuplicates() + .sink { [weak self] searchText in + self?.handleSearchTextChanged(searchText) + } + .store(in: &cancellables) + + input + .filter { if case .scrollReachedBottom = $0 { return true }; return false } + .throttle(for: .seconds(0.3), scheduler: DispatchQueue.main, latest: true) + .sink { [weak self] _ in + self?.handleScrollReachedBottom() + } + .store(in: &cancellables) + + return output + } + + private func handleSearchTextChanged(_ text: String) { + currentSearchText = text + currentPage = 1 + peopleList.removeAll() + + guard !text.isEmpty else { + output.people.send([]) + return + } + + fetchPeople() + } + + private func handleScrollReachedBottom() { + print("서버 호출") + fetchPeople() + } + + private func fetchPeople() { + guard !isPeopleFetching else { return } + guard !currentSearchText.isEmpty else { return } + + isPeopleFetching = true + output.isLoading.send(true) + + NetworkProvider + .request(.fetchPeople(name: currentSearchText, page: currentPage), type: PeopleListResponse.self) { [weak self] result in + guard let self = self else { return } + + self.isPeopleFetching = false + output.isLoading.send(false) + + switch result { + case .success(let response): + self.peopleList.append(contentsOf: response.peopleListResult.peopleList) + output.people.send(self.peopleList) + self.currentPage += 1 + + case .failure(let error): + output.error.send(error) + } + } + } +} diff --git a/Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchPeopleViewController.swift b/Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchPeopleViewController.swift new file mode 100644 index 0000000..ffebed7 --- /dev/null +++ b/Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchPeopleViewController.swift @@ -0,0 +1,147 @@ +// +// SearcMovieViewController.swift +// Smashing-Assignment +// +// Created by 홍준범 on 1/8/26. +// + +import Foundation +import UIKit +import Combine + +final class SearchPeopleViewController: UIViewController { + + private let viewModel: SearchPeopleViewModelProtocol + private var cancellables = Set() + + private let searchView = SearchView() + + private let input = PassthroughSubject() + + init(viewModel: SearchPeopleViewModelProtocol) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + view = searchView + } + + override func viewDidLoad() { + super.viewDidLoad() + + setupCollectionView() + bind() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + searchView.setCollectionViewLayout() + } + + private func setupCollectionView() { + searchView.collectionView.dataSource = self +// searchView.collectionView.delegate = self + } + + private func bind() { + let output = viewModel.transform(input: input.eraseToAnyPublisher()) + + bindOutput(output) + bindInput() + } + + private func bindOutput(_ output: SearchPeopleViewModel.Output ) { + output.people + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.searchView.collectionView.reloadData() + } + .store(in: &cancellables) + + output.error + .receive(on: DispatchQueue.main) + .sink { [weak self] error in + print("") + } + .store(in: &cancellables) + + output.isLoading + .receive(on: DispatchQueue.main) + .sink { [weak self] isLoading in + print("") + } + .store(in: &cancellables) + } + + private func bindInput() { + searchView.searchBar.textDidChangePublisher() + .sink { [weak self] text in + self?.input.send(.searchTextChanged(text)) + } + .store(in: &cancellables) + + searchView.collectionView.reachedBottomPublisher + .sink { [weak self] _ in + self?.input.send(.scrollReachedBottom) + } + .store(in: &cancellables) + } + +} + +extension SearchPeopleViewController: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return viewModel.numberOfPeople + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: SearchPeopleCollectionViewCell.identifier, for: indexPath) as? SearchPeopleCollectionViewCell else { + return UICollectionViewCell() + } + + guard let person = viewModel.person(at: indexPath.item) else { + return cell + } + + cell.configure(data: person, index: indexPath.item) + return cell + } +} + +//extension SearchPeopleViewController: UICollectionViewDelegate { +// func scrollViewDidScroll(_ scrollView: UIScrollView) { +// +// let offsetY = scrollView.contentOffset.y +// let contentHeight = scrollView.contentSize.height +// let height = scrollView.frame.size.height +// +// if offsetY > contentHeight - height - 100 { +// print("스크롤 하단") +// input.send(.scrollReachedBottom) +// } +// } +//} + +extension UIScrollView { + var reachedBottomPublisher: AnyPublisher { + return publisher(for: \.contentOffset) + .map { [weak self] contentOffset -> Bool in + guard let self = self else { return false } + + let offsetY = contentOffset.y + let contentHeight = self.contentSize.height + let height = self.frame.size.height + + return offsetY > contentHeight - height - 100 + } + .removeDuplicates() + .filter { $0 } + .map { _ in () } + .eraseToAnyPublisher() + } +} diff --git a/Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchView.swift b/Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchView.swift new file mode 100644 index 0000000..42140d5 --- /dev/null +++ b/Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchView.swift @@ -0,0 +1,63 @@ +// +// SearchView.swift +// Smashing-Assignment +// +// Created by 홍준범 on 1/8/26. +// + +import UIKit +import Combine + +import Then +import SnapKit + +final class SearchView: UIView { + + let searchBar = UITextField().then { + $0.isUserInteractionEnabled = true + $0.placeholder = "검색어를 입력하세요" + $0.clipsToBounds = true + $0.layer.cornerRadius = 10 + $0.layer.borderWidth = 2 + $0.layer.borderColor = UIColor.white.cgColor + $0.font = .systemFont(ofSize: 20, weight: .bold) + } + + let collectionView: UICollectionView = { + let collection = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + collection.register(SearchPeopleCollectionViewCell.self, + forCellWithReuseIdentifier: SearchPeopleCollectionViewCell.identifier) + + return collection + }() + + override init(frame: CGRect) { + super.init(frame: frame) + self.addSubview(searchBar) + self.addSubview(collectionView) + + searchBar.snp.makeConstraints { make in + make.height.equalTo(60) + make.leading.trailing.equalToSuperview().inset(20) + make.top.equalToSuperview().offset(100) + } + + collectionView.snp.makeConstraints { make in + make.top.equalTo(searchBar.snp.bottom) + make.leading.trailing.bottom.equalToSuperview() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setCollectionViewLayout() { + let flowLayout = UICollectionViewFlowLayout() + let cellWidth: CGFloat = self.bounds.width + flowLayout.itemSize = CGSize(width: cellWidth, height: 100) + flowLayout.minimumLineSpacing = 10 + flowLayout.minimumInteritemSpacing = 0 + self.collectionView.setCollectionViewLayout(flowLayout, animated: false) + } +} diff --git a/Smashing-Assignment/Presentation/Core/CombineViewController_HJB.swift b/Smashing-Assignment/Presentation/Core/HJB/CombineViewController_HJB.swift similarity index 100% rename from Smashing-Assignment/Presentation/Core/CombineViewController_HJB.swift rename to Smashing-Assignment/Presentation/Core/HJB/CombineViewController_HJB.swift diff --git a/Smashing-Assignment/Presentation/Core/CombineView_HJB.swift b/Smashing-Assignment/Presentation/Core/HJB/CombineView_HJB.swift similarity index 100% rename from Smashing-Assignment/Presentation/Core/CombineView_HJB.swift rename to Smashing-Assignment/Presentation/Core/HJB/CombineView_HJB.swift diff --git a/Smashing-Assignment/Presentation/Core/TabBarController.swift b/Smashing-Assignment/Presentation/Core/TabBarController.swift index 4aaa52d..de742bf 100644 --- a/Smashing-Assignment/Presentation/Core/TabBarController.swift +++ b/Smashing-Assignment/Presentation/Core/TabBarController.swift @@ -125,7 +125,10 @@ final class DefaultTabBarSceneFactory: TabBarSceneFactory { func makeViewController(for tab: TabBarController.Tab) -> UIViewController { switch tab { case .jinjae: return JinJaeViewController() - case .junbeom: return CombineViewController_HJB() +// case .junbeom: return CombineViewController_HJB() + case .junbeom: + let viewModel = SearchPeopleViewModel() + return SearchPeopleViewController(viewModel: viewModel) case .seungjun: return ViewController_LSJ() } }