Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 ?? "-"
}

}
Original file line number Diff line number Diff line change
@@ -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<Input, Never>) -> Output

}
//
//protocol SearchPeopleViewModelProtocol {
// associatedtype Input
// associatedtype Output
//
// func transform(input: AnyPublisher<Input, Never>) -> 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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

POP 마스터 드립니다~

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<Error, Never>.init()
let isLoading = CurrentValueSubject<Bool, Never>.init(false)
}

// struct Output {
// let people: PassthroughSubject<[PeopleDTO], Never>
// let error: PassthroughSubject<Error, Never>
// let isLoading: CurrentValueSubject<Bool, Never>
// }
//
// private let output = Output(
// people: PassthroughSubject(),
// error: PassthroughSubject(),
// isLoading: CurrentValueSubject(false)
// )
//
// private let outputPublisher = PassthroughSubject<Output, Never>()

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<Output, Never>()

private var cancellables = Set<AnyCancellable>()
private var peopleList: [PeopleDTO] = []

private var currentSearchText = ""
private var currentPage = 1
private var isPeopleFetching = false

func transform(input: AnyPublisher<Input, Never>) -> 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<PeopleAPI>
.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)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<AnyCancellable>()

private let searchView = SearchView()

private let input = PassthroughSubject<SearchPeopleViewModel.Input, Never>()

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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

진짜 너무 좋은 꿀코드~~

var reachedBottomPublisher: AnyPublisher<Void, Never> {
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()
}
}
Loading