[ReactorKit#4] Example : GithubSearch 코드작성 (스토리보드 없이)
새 프로젝트 GithubSearch-Prac을 만들고 Source Control Navigator에서 Remotes를 우클릭해 Create Remote해서 깃허브에 연결해둔다.
저번 Counter과 마찬가지로 Storyboard 없이 작업할 것이기 때문에 #2번 포스트에서도 한 작업을 똑같이 해준다. 스토리보드 두개 지우고, 프로젝트 정보에서도 Main interface 부분 비우고, info.plist에서도 storyboard name 행을 지웠다.
커밋 추가(message : storyboard 삭제)
필요한 pod파일들도 설치해준다.
GithubSearchViewReactor를 만들어주고, 코드 작성을 시작한다.
//
// GithubSearchViewReactor.swift
// GithubSearch-Prac
//
// Created by 정종인 on 2020/06/07.
// Copyright © 2020 swmaestro10th. All rights reserved.
//
import Foundation
import ReactorKit
import RxCocoa
class GithubSearchViewReactor: Reactor {
enum Action {
case updateQuery(String?)
case loadNextPage
}
enum Mutation {
case setQuery(String?)
case setRepos([String], nextPage: Int?)
case appendRepos([String], nextPage: Int?)
case setLoadingNextPage(Bool)
}
struct State {
var query: String?
var repos: [String] = []
var nextPage: Int?
var isLoadingNextPage: Bool = false
}
let initialState: State = State()
func mutate(action: Action) -> Observable<Mutation> {
switch action {
case .updateQuery(let query):
return Observable.concat([
Observable.just(Mutation.setQuery(query)),
self.search(query: query, page: 1)
.takeUntil(self.action.filter(Action.isUpdateQueryAction(_:)))
.map { Mutation.setRepos($0, nextPage: $1) }
])
case .loadNextPage:
guard !self.currentState.isLoadingNextPage else { return Observable.empty() }
guard let page = self.currentState.nextPage else { return Observable.empty() }
return Observable.concat([
Observable.just(Mutation.setLoadingNextPage(true)),
self.search(query: self.currentState.query, page: page)
.takeUntil(self.action.filter(Action.isUpdateQueryAction(_:)))
.map { Mutation.appendRepos($0, nextPage: $1) },
Observable.just(Mutation.setLoadingNextPage(false))
])
}
}
func reduce(state: State, mutation: Mutation) -> State {
var newState = state
switch mutation {
case let .setQuery(query):
newState.query = query
case let .setRepos(repos, nextPage: nextPage):
newState.repos = repos
newState.nextPage = nextPage
case let .appendRepos(repos, nextPage: nextPage):
newState.repos.append(contentsOf: repos)
newState.nextPage = nextPage
case let .setLoadingNextPage(isLoadingNextPage):
newState.isLoadingNextPage = isLoadingNextPage
}
return newState
}
private func url(for query: String?, page: Int) -> URL? {
guard let query = query, !query.isEmpty else { return nil }
return URL(string: "https://api.github.com/search/repositories?q=\(query)&page=\(page)")
}
private func search(query: String?, page: Int) -> Observable<(repos: [String], nextPage: Int?)> {
let emptyResult: ([String], Int?) = ([], nil) // 실패했을 때 내보낼 결과
guard let url = self.url(for: query, page: page) else { return Observable.just(emptyResult) }
return URLSession.shared.rx.json(url: url)
.map { json -> ([String], Int?) in
guard let dictionary = json as? [String: Any] else { return emptyResult }
guard let items = dictionary["items"] as? [[String: Any]] else { return emptyResult }
let repos = items.compactMap { $0["full_name"] as? String }
let nextPage = repos.isEmpty ? nil : page + 1
return (repos, nextPage)
}
.do(onError: { error in
if case let .some(.httpRequestFailed(response, _)) = error as? RxCocoaURLError, response.statusCode == 403 {
print("Github API rate limit exceeded.")
}
})
.catchErrorJustReturn(emptyResult)
}
}
extension GithubSearchViewReactor.Action {
static func isUpdateQueryAction(_ action: GithubSearchViewReactor.Action) -> Bool {
if case .updateQuery = action {
return true
} else {
return false
}
}
}
커밋 추가(message : reactor)
다음엔 뷰를 추가할 것이다. GithubSearchView 파일을 하나 만들어준다. GithubSearchViewController도 만들어서 연결해준다.
GithubSearchView 코드 :
//
// GithubSearchView.swift
// GithubSearch-Prac
//
// Created by 정종인 on 2020/06/07.
// Copyright © 2020 swmaestro10th. All rights reserved.
//
import Foundation
import UIKit
import ReactorKit
import SnapKit
import SafariServices
class GithubSearchView: UIView {
weak var vc: GithubSearchViewController?
init(controlBy viewController: GithubSearchViewController) {
self.vc = viewController
super.init(frame: UIScreen.main.bounds)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private let baseView: UIView = {
let v = UIView()
v.backgroundColor = .white
return v
}()
private let backgroundView: UIView = {
let v = UIView()
v.backgroundColor = .white
return v
}()
let tableView: UITableView = {
let v = UITableView()
v.verticalScrollIndicatorInsets.top = v.contentInset.top
v.backgroundColor = .white
return v
}()
let searchController: UISearchController = {
let v = UISearchController(searchResultsController: nil)
v.obscuresBackgroundDuringPresentation = false
return v
}()
var disposeBag: DisposeBag = DisposeBag()
func setup() {
setupUI()
setBind()
}
private func setupUI() {
addSubviews()
setLayout()
}
private func setBind() {
//delegate, datasource, register
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
}
private func addSubviews() {
self.addSubview(baseView)
baseView.addSubview(backgroundView)
backgroundView.addSubview(tableView)
}
private func setLayout() {
baseView.snp.makeConstraints {
$0.top.left.bottom.right.equalToSuperview()
}
backgroundView.snp.makeConstraints {
$0.top.left.bottom.right.equalTo(safeAreaLayoutGuide)
}
tableView.snp.makeConstraints {
$0.top.left.bottom.right.equalToSuperview()
}
}
}
extension GithubSearchView: View {
func bind(reactor: GithubSearchViewReactor) {
searchController.searchBar.rx.text
.throttle(.milliseconds(300), scheduler: MainScheduler.instance)
.map { Reactor.Action.updateQuery($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.height > 0 else { return false }
return offset.y + self.tableView.frame.height >= self.tableView.contentSize.height - 100
}
.map { _ in Reactor.Action.loadNextPage }
.bind(to: reactor.action)
.disposed(by: disposeBag)
tableView.rx.itemSelected
.subscribe(onNext: { [weak self, weak reactor] indexPath in
guard let `self` = self else { return }
self.endEditing(true)
self.tableView.deselectRow(at: indexPath, animated: false)
guard let repo = reactor?.currentState.repos[indexPath.row] else { return }
guard let url = URL(string: "https://github.com/\(repo)") else { return }
let viewController = SFSafariViewController(url: url)
self.searchController.present(viewController, animated: true, completion: nil)
})
.disposed(by: disposeBag)
reactor.state.map { $0.repos }
.bind(to: tableView.rx.items(cellIdentifier: "cell")) { indexPath, repo, cell in
cell.textLabel?.text = repo
}
.disposed(by: disposeBag)
}
}
GithubSearchViewController 코드 :
//
// GithubSearchViewController.swift
// GithubSearch-Prac
//
// Created by 정종인 on 2020/06/07.
// Copyright © 2020 swmaestro10th. All rights reserved.
//
import Foundation
import UIKit
class GithubSearchViewController: UIViewController {
private lazy var githubSearchView = GithubSearchView(controlBy: self)
override func viewDidLoad() {
super.viewDidLoad()
githubSearchView.setup()
navigationItem.searchController = githubSearchView.searchController
githubSearchView.reactor = GithubSearchViewReactor()
}
override func loadView() {
self.view = githubSearchView
}
}
중간에 시행착오가 있었지만, 주 원인은 GithubSearchViewController에서 override func loadView()을 까먹었던 것이다. 잊지 말고 꼭 추가하도록 하자!
SceneDelegate 파일도 바꾸어 주어야 한다.
//
// SceneDelegate.swift
// GithubSearch-Prac
//
// Created by 정종인 on 2020/06/07.
// Copyright © 2020 swmaestro10th. All rights reserved.
//
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
guard let windowScene = (scene as? UIWindowScene) else { return }
window = UIWindow(frame: UIScreen.main.bounds)
window?.windowScene = windowScene
window?.rootViewController = UINavigationController(rootViewController: GithubSearchViewController())
window?.makeKeyAndVisible()
}
func sceneDidDisconnect(_ scene: UIScene) {
// Called as the scene is being released by the system.
// This occurs shortly after the scene enters the background, or when its session is discarded.
// Release any resources associated with this scene that can be re-created the next time the scene connects.
// The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead).
}
func sceneDidBecomeActive(_ scene: UIScene) {
// Called when the scene has moved from an inactive state to an active state.
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
}
func sceneWillResignActive(_ scene: UIScene) {
// Called when the scene will move from an active state to an inactive state.
// This may occur due to temporary interruptions (ex. an incoming phone call).
}
func sceneWillEnterForeground(_ scene: UIScene) {
// Called as the scene transitions from the background to the foreground.
// Use this method to undo the changes made on entering the background.
}
func sceneDidEnterBackground(_ scene: UIScene) {
// Called as the scene transitions from the foreground to the background.
// Use this method to save data, release shared resources, and store enough scene-specific state information
// to restore the scene back to its current state.
}
}
scene 함수부분을 바꾸어주면 된다.
커밋(message : finish)
자세한 내용은 https://github.com/chongin12/GithubSearch-Prac 에서 확인할 수 있다.
느낀 점 : tableview를 쓸 때 항상 커스텀 셀 클래스를 만들고, UITableViewDelegate, UITableViewDataSource 다 추가해주고, self.tableview.delegate = self, self.tableview.datasource = self 이런 작업을 해왔었는데, 이런 작업 없이 깔끔하게 cell register한번 하고 reactorKit으로 채워주니깐 느낌이 새롭다. 하지만 여전히 reactorKit과 RxSwift는 갈 길이 멀다.