ReactorKit
GithubSearchClone
구현 목표
ReactorKit
깃허브 예시 파일 중 깃허브 서치 프로젝트 클론
구현 태스크
RxSwift
, RxCocoa
, RxDataSources
, ReactorKit
사용
- 깃허브 API를 통한 검색 쿼리 서비스 제공
- 검색 결과를 통한 테이블 뷰
RxDataSources
로 구성
- 테이블 뷰 셀 선택 시 사파리 뷰 연결
핵심 코드
final class SearchReactor: Reactor {
enum Action {
case updateQuery(query: String)
case loadNextPage
}
enum Mutation {
case setQuery(query: String)
case setRepos(repos: [String], nextPage: Int?)
case appendRepos(repos: [String], nextPage: Int?)
case setLoadingNextPage(isLoading: Bool)
}
struct State {
var query: String = ""
var repos: [String] = []
var nextPage: Int?
var isLoadingNextPage: Bool = false
}
...
}
- 리액터 프로토콜을 따르는 파이널 클래스
SearchReactor
는 기좀 MVVM 스타일의 뷰 모델을 대리, 액션과 상태로 각 다룰 영역을 구분한 클래스
Action
을 통해 어떤 선택이 가능한지, Mutation
을 통해 어떤 변화가 일어날지, State
를 통해 어떤 종류의 변화가 적용이 되었는지 확인할 수 있음
func mutate(action: Action) -> Observable<Mutation> {
switch action {
case .updateQuery(query: let query):
return Observable.concat([
Observable.just(Mutation.setQuery(query: query)),
search(query: query, page: 1)
.take(until: self.action.filter({ action in
switch action {
case .updateQuery(query: _): return true
default: return false
}
}))
.map({Mutation.setRepos(repos: $0, nextPage: $1)})
])
case .loadNextPage:
guard
!currentState.isLoadingNextPage,
let page = currentState.nextPage else { return .empty() }
return Observable.concat([
Observable.just(Mutation.setLoadingNextPage(isLoading: true)),
search(query: currentState.query, page: page)
.take(until: self.action.filter({ action in
switch action {
case .updateQuery(query: _): return true
default: return false
}
}))
.map({Mutation.appendRepos(repos: $0, nextPage: $1)}),
Observable.just(Mutation.setLoadingNextPage(isLoading: false))
])
}
}
- 특정한 액션이 들어왔을 때 어떤 변화가 일어나는지 함수를 통해 표현하는
mutate
- 액션의 종류에 따라 어떤 변화가 일어나는지
Observable.concat
을 통해 Observable
의 변화를 연속적으로 붙여서 리턴하는 게 관습적인 코드인 것 같음
private func getURL(query: String, page: Int) -> URL? {
guard !query.isEmpty else { return nil }
var components = URLComponents(string: baseURLString)
let urlQueryItems: [URLQueryItem] = [
.init(name: "q", value: query.lowercased()),
.init(name: "page", value: page.description),
.init(name: "client_id", value: "token [YOUR_GITHUB_ACCESS_TOKEN]")
]
components?.queryItems = urlQueryItems
return components?.url
}
private func search(query: String, page: Int) -> Observable<(repos: [String], nextPage: Int?)> {
guard let url = getURL(query: query, page: page) else { return .just((repos: [], nextPage: nil))}
return URLSession.shared.rx.json(url: url)
.map { json -> ([String], Int?) in
guard
let dict = json as? [String: Any],
let items = dict["items"] as? [[String: Any]] else {
return ([], nil)
}
let repos = items.compactMap({ $0["full_name"] as? String })
let nextPage = repos.isEmpty ? nil : page + 1
return (repos, nextPage)
}
}
- 쿼리 검색 액션이 들어왔을 때 실행되는 검색 함수
- 깃허브 API를 사용하기 때문에 쿼리를 구성한 뒤 URL를 리턴, 해당 URL을 통해
URLSession
을 사용한 검색 결과를 Observable
로 감싸 리턴하는 게 검색 함수의 결과
func reduce(state: State, mutation: Mutation) -> State {
var state = state
switch mutation {
case .setQuery(query: let query):
state.query = query
case .setRepos(repos: let repos, nextPage: let nextPage):
state.repos = repos
state.nextPage = nextPage
case .appendRepos(repos: let repos, nextPage: let nextPage):
state.repos.append(contentsOf: repos)
state.nextPage = nextPage
case .setLoadingNextPage(isLoading: let isLoading):
state.isLoadingNextPage = isLoading
}
return state
}
- 현재 상태 및 변경 사항이 주어진다면 현재 상태가 어떻게 변화되어야 하는지 확인하는 함수
- 즉
reduce
되는 결과값이 상태에 반영된다는 뜻
- 상태 구조체를 구성하는 쿼리, 레포지터리 배열, 페이지 정수, 로딩 중 여부 등 경우의 수에 따라 현재 상태 변경
typealias ReactorView = ReactorKit.View
final class SearchViewController: UIViewController, ReactorView {
private let searchController: UISearchController = {
let controller = UISearchController(searchResultsController: nil)
controller.searchBar.placeholder = "Search Github Input..."
return controller
}()
private let tableView: UITableView = {
let tableView = UITableView()
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "tableViewCell")
return tableView
}()
var disposeBag = DisposeBag()
private let reactor = SearchReactor()
override func viewDidLoad() {
super.viewDidLoad()
setUI()
bind(reactor: reactor)
}
...
}
Reactor
프레임워크의 View
프로토콜을 따르는 뷰 컨트롤러
- 뷰 컨트롤러, 셀 등은 모두
View
로 간주하는 게 리액터 킷의 관점
bind(reacotr:)
함수는 외부에서 리액터가 주입이 될 때 곧바로 실행이 되는 데, 현 시점에서는 뷰 컨트롤러 내부에서 private
하게 리액터를 가지고 있고 로드가 될 때 자동으로 바인드 함수를 실행하는 방식으로 구현
func bind(reactor: SearchReactor) {
searchController
.searchBar
.rx
.text
.orEmpty
.throttle(.milliseconds(500), scheduler: MainScheduler.instance)
.map({ SearchReactor.Action.updateQuery(query: $0)})
.bind(to: reactor.action)
.disposed(by: disposeBag)
tableView
.rx
.contentOffset
.filter { [weak self] offset in
guard let self = self else { return false}
guard self.tableView.frame.size.height > 0 else { return false }
return offset.y + self.tableView.frame.size.height >= self.tableView.contentSize.height - 100
}
.map { _ in
SearchReactor.Action.loadNextPage
}
.bind(to: reactor.action)
.disposed(by: disposeBag)
typealias SearchSection = SectionModel<Int, String>
let dataSource: RxTableViewSectionedReloadDataSource<SearchSection> = .init { _, tableView, indexPath, item in
let cell = tableView.dequeueReusableCell(withIdentifier: "tableViewCell", for: indexPath)
cell.textLabel?.text = item
return cell
}
reactor
.state
.map({ $0.repos })
.map({ [SearchSection(model: 0, items: $0)]})
.asDriver(onErrorJustReturn: [])
.drive(tableView.rx.items(dataSource: dataSource))
.disposed(by: disposeBag)
reactor
.state
.map({ $0.isLoadingNextPage })
.distinctUntilChanged()
.observe(on: MainScheduler.instance)
.subscribe { [weak self] isLoading in
guard let self = self else { return }
self.tableView.tableFooterView = isLoading ? self.createSpinnerView() : nil
}
.disposed(by: disposeBag)
tableView
.rx
.itemSelected
.subscribe { [weak self, weak reactor] indexPath in
guard let self = self else { return }
self.view.endEditing(true)
self.tableView.deselectRow(at: indexPath, animated: true)
guard
let repo = reactor?.currentState.repos[indexPath.row],
let url = URL(string: "https://www.github.com/\(repo)") else { return }
let vc = SFSafariViewController(url: url)
self.searchController.present(vc, animated: true)
}
.disposed(by: disposeBag)
}
ReactorKit
과 별개로 rx
를 통해 뷰를 그리는 파트
- 서치 바의 텍스트를
rx
화하여 반응형으로 리액터의 쿼리를 업데이트하는 액션과 연동
- 테이블 뷰의
contentOffset
또한 반응형으로 감지, 특정 오프셋 이상이 넘길 때 (즉 스크롤을 마지막까지 당긴 뒤 계속해서 로드할 때) 다음 결과를 쿼리하라는 액션으로 바인딩
- 리액터의 각 상태값이 들고 있는 레포지터리 배열 데이터, 로딩 여부 등을 통해 테이블 뷰의 데이터 소스를 그리거나 푸터 뷰에 로딩 뷰를 잠시 보여줄 것인지 또한 구현
- 선택된 테이블 뷰 셀이 있다면 해당 셀로부터 사파리 웹뷰를 구성, 연결
구현 화면