[RxSwift] GithubSearchClone

Junyoung Park·2022년 12월 30일
0

RxSwift

목록 보기
25/25
post-thumbnail

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 또한 반응형으로 감지, 특정 오프셋 이상이 넘길 때 (즉 스크롤을 마지막까지 당긴 뒤 계속해서 로드할 때) 다음 결과를 쿼리하라는 액션으로 바인딩
  • 리액터의 각 상태값이 들고 있는 레포지터리 배열 데이터, 로딩 여부 등을 통해 테이블 뷰의 데이터 소스를 그리거나 푸터 뷰에 로딩 뷰를 잠시 보여줄 것인지 또한 구현
  • 선택된 테이블 뷰 셀이 있다면 해당 셀로부터 사파리 웹뷰를 구성, 연결

구현 화면

profile
JUST DO IT

0개의 댓글