iOS Dev/ReactorKit

[ReactorKit#4] Example : GithubSearch 코드작성 (스토리보드 없이)

Mosu(정종인) 2020. 6. 7. 16:22
반응형

새 프로젝트 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는 갈 길이 멀다.

반응형