From b71d408d79e419251f90df84f33e5527a141bfee Mon Sep 17 00:00:00 2001 From: KuKaH Date: Thu, 8 Jan 2026 04:08:57 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[feat]=20View,=20Cell=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SearchMovieCollectionViewCell.swift | 64 +++++++++++++++++++ .../Core/HJB/Combine4/SearchView.swift | 62 ++++++++++++++++++ .../{ => HJB}/CombineViewController_HJB.swift | 0 .../Core/{ => HJB}/CombineView_HJB.swift | 0 4 files changed, 126 insertions(+) create mode 100644 Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchMovieCollectionViewCell.swift create mode 100644 Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchView.swift rename Smashing-Assignment/Presentation/Core/{ => HJB}/CombineViewController_HJB.swift (100%) rename Smashing-Assignment/Presentation/Core/{ => HJB}/CombineView_HJB.swift (100%) 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..597d368 --- /dev/null +++ b/Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchMovieCollectionViewCell.swift @@ -0,0 +1,64 @@ +// +// SearchMovieCollectionViewCell.swift +// Smashing-Assignment +// +// Created by 홍준범 on 1/8/26. +// + +import UIKit +import Combine + +import SnapKit +import Then + +final class SearchMovieCollectionViewCell: UICollectionViewCell { + + static let identifier: String = "SearchMovieCollectionViewCell" + + let movieNmLabel = UILabel().then { + $0.font = .systemFont(ofSize: 20, weight: .bold) + } + + let yearLabel = UILabel().then { + $0.textAlignment = .right + $0.font = .systemFont(ofSize: 14, weight: .regular) + } + + override init(frame: CGRect) { + super.init(frame: frame) + self.addSubview(movieNmLabel) + self.addSubview(yearLabel) + + movieNmLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.leading.equalToSuperview().offset(16) + make.trailing.equalTo(yearLabel.snp.leading).offset(-10) + } + + yearLabel.snp.makeConstraints { make in + make.centerY.equalToSuperview() + make.trailing.equalToSuperview().offset(-16) + make.width.equalTo(50) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(data: MovieDTO, index: Int) { + switch (index / 10 ) % 3 { + case 0: + movieNmLabel.textColor = .systemPink + case 1: + movieNmLabel.textColor = .systemCyan + case 2: + movieNmLabel.textColor = .systemGreen + default: + movieNmLabel.textColor = .white + } + movieNmLabel.text = String(index + 1) + ": " + data.movieNm + yearLabel.text = data.prdtYear + } + +} 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..d0e0739 --- /dev/null +++ b/Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchView.swift @@ -0,0 +1,62 @@ +// +// 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(SearchMovieCollectionViewCell.self, + forCellWithReuseIdentifier: SearchMovieCollectionViewCell.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 From 6110b79bd17477821356c6e24958c909ba7184c8 Mon Sep 17 00:00:00 2001 From: KuKaH Date: Sat, 10 Jan 2026 02:41:00 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[feat]=20=EC=98=81=ED=99=94=EC=9D=B8=20View?= =?UTF-8?q?=20=EB=B0=8F=20cell=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SearchMovieCollectionViewCell.swift | 42 ++++++++++--------- .../Core/HJB/Combine4/SearchView.swift | 11 ++--- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchMovieCollectionViewCell.swift b/Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchMovieCollectionViewCell.swift index 597d368..9be9850 100644 --- a/Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchMovieCollectionViewCell.swift +++ b/Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchMovieCollectionViewCell.swift @@ -11,54 +11,56 @@ import Combine import SnapKit import Then -final class SearchMovieCollectionViewCell: UICollectionViewCell { +final class SearchPeopleCollectionViewCell: UICollectionViewCell { - static let identifier: String = "SearchMovieCollectionViewCell" + static let identifier: String = "SearchPeopleCollectionViewCell" - let movieNmLabel = UILabel().then { + private let peopleNameLabel = UILabel().then { $0.font = .systemFont(ofSize: 20, weight: .bold) } - let yearLabel = UILabel().then { + 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) - self.addSubview(movieNmLabel) - self.addSubview(yearLabel) + contentView.addSubview(peopleNameLabel) + contentView.addSubview(roleLabel) - movieNmLabel.snp.makeConstraints { make in + peopleNameLabel.snp.makeConstraints { make in make.centerY.equalToSuperview() make.leading.equalToSuperview().offset(16) - make.trailing.equalTo(yearLabel.snp.leading).offset(-10) + make.trailing.equalTo(roleLabel.snp.leading).offset(-10) } - yearLabel.snp.makeConstraints { make in + roleLabel.snp.makeConstraints { make in make.centerY.equalToSuperview() make.trailing.equalToSuperview().offset(-16) - make.width.equalTo(50) + make.width.equalTo(80) } } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - func configure(data: MovieDTO, index: Int) { - switch (index / 10 ) % 3 { + func configure(data: PeopleDTO, index: Int) { + // 색상 변경 (10명씩 다른 색) + switch (index / 10) % 3 { case 0: - movieNmLabel.textColor = .systemPink + peopleNameLabel.textColor = .systemPink case 1: - movieNmLabel.textColor = .systemCyan + peopleNameLabel.textColor = .systemCyan case 2: - movieNmLabel.textColor = .systemGreen + peopleNameLabel.textColor = .systemGreen default: - movieNmLabel.textColor = .white + peopleNameLabel.textColor = .white } - movieNmLabel.text = String(index + 1) + ": " + data.movieNm - yearLabel.text = data.prdtYear + + peopleNameLabel.text = "\(index + 1): \(data.peopleNm)" + roleLabel.text = data.repRoleNm ?? "-" } } diff --git a/Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchView.swift b/Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchView.swift index d0e0739..42140d5 100644 --- a/Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchView.swift +++ b/Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchView.swift @@ -22,14 +22,15 @@ final class SearchView: UIView { $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(SearchMovieCollectionViewCell.self, - forCellWithReuseIdentifier: SearchMovieCollectionViewCell.identifier) + collection.register(SearchPeopleCollectionViewCell.self, + forCellWithReuseIdentifier: SearchPeopleCollectionViewCell.identifier) + return collection }() - + override init(frame: CGRect) { super.init(frame: frame) self.addSubview(searchBar) @@ -46,7 +47,7 @@ final class SearchView: UIView { make.leading.trailing.bottom.equalToSuperview() } } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } From 4ba97ec47daf826d6220caad5ce49c0d582d2663 Mon Sep 17 00:00:00 2001 From: KuKaH Date: Sat, 10 Jan 2026 02:41:29 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[feat]=20=EC=98=81=ED=99=94=EC=9D=B8=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20VC=20=EB=B0=8F=20VM=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20=EB=B0=8F=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../API/{PersonAPI.swift => PeopleAPI.swift} | 9 +- .../HJB/Combine4/SearchMovieViewModel.swift | 157 ++++++++++++++++++ .../Combine4/SearchPeopleViewController.swift | 147 ++++++++++++++++ 3 files changed, 309 insertions(+), 4 deletions(-) rename Smashing-Assignment/Networks/API/{PersonAPI.swift => PeopleAPI.swift} (77%) create mode 100644 Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchMovieViewModel.swift create mode 100644 Smashing-Assignment/Presentation/Core/HJB/Combine4/SearchPeopleViewController.swift 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/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() + } +} From c833fcda06fac26feb123bae8f62055aba383ab4 Mon Sep 17 00:00:00 2001 From: KuKaH Date: Sat, 10 Jan 2026 02:48:04 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[chore]=20Tabbar=20VC=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Smashing-Assignment/Presentation/Core/TabBarController.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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() } }