Skip to content

GitHub Search

Choi Geonu edited this page Oct 2, 2016 · 5 revisions

In this page, we will show you how to build an application with MVC. Let's make a simple GitHub repository searching application.

Definitions

First, define data structures we need.

These are events what user can make.

enum GitHubSearchEvent {
    case changeSearchText(String)
    case selectRepository(Repository)
}

These are actions (model commands) what controller can make.

enum GitHubSearchAction {
    case updateQuery(String?)
    case updateRepositories([Repository])
    case repositoriesError(Error)
}

And these are states that model can have.

enum RepositoriesState {
    case some([Repository])
    case error(Error)
}

struct GitHubSearchState {
    let query: String?
    let repositories: RepositoriesState
}

Oh, the definition of Repository is here

struct Repository {
    let name: String
    let fullName: String
    let htmlURL: String
}

And this is an REST client for GitHub.

import UIKit
import RxSwift
import Alamofire
import RxAlamofire

public struct GitHubAPI {
    let manager: SessionManager
    
    func searchRepo(_ key: String) -> Observable<[Repository]> {
        let URL = Foundation.URL(string: "https://api.github.com/search/repositories")!
        return manager.rx.json(.get, URL, parameters: ["q": key]).map { data in
            if let data = data as? Dictionary<String, NSObject> {
                let items = data["items"]
                if let items = items as? [Dictionary<String, NSObject>] {
                    return items.map{ item in
                        let name = item["name"]?.description ?? ""
                        let fullName = item["full_name"]?.description ?? ""
                        let htmlURL = item["html_url"]?.description ?? ""
                        return Repository(name: name, fullName: fullName, htmlURL: htmlURL)
                    }
                }
            }
            return []
        }
    }
}

Controller

And now, let's make a controller.

import Foundation
import RxSwift
import RxCocoa
import Alamofire
import RxMVC


protocol GitHubSearchControllerDelegate: class {
    func openURL(_ url: URL)
}

struct GitHubSearchController: FlatMapController {
    typealias Event = GitHubSearchEvent
    typealias Action = GitHubSearchAction
    
    var flatMapType = FlatMapType.latest
    
    weak var delegate: GitHubSearchControllerDelegate?
    
    init(delegate: GitHubSearchControllerDelegate) {
        self.delegate = delegate
    }
    
    func flatMapEventToAction(_ event: Event) -> Observable<Action> {
        switch event {
        case .changeSearchText(let text):
            let query = text.trimmingCharacters(in: CharacterSet.whitespaces)
            if query == "" {
                return Observable.of(Action.updateQuery(nil), Action.updateRepositories([]))
            } else {
                return Observable.just(Action.updateQuery(query)).concat(
                    GitHubAPI(manager: SessionManager.default).searchRepo(query).map { repositories in
                        return Action.updateRepositories(repositories)
                    }.asDriver(onErrorRecover: { (error) -> Driver<Action> in
                        return Driver.just(Action.repositoriesError(error))
                    }))
            }
        case .selectRepository(let repository):
            if let url = URL(string: repository.htmlURL) {
                self.delegate?.openURL(url)
            }
            return Observable.empty()
        }
    }
}

FlatMapController is a kind of Controller that converts event into Action stream.

GitHubSearchController accepts an event.

func flatMapEventToAction(event: Event) -> Observable<Action> {
switch event {

In case of ChangeSearchText, it trims the text and converts empty string into nil.

If text presents, update query and send search request to GitHub

case .ChangeSearchText(let text):
    let query = text.stringByTrimmingCharactersInSet(NSCharacterSet.whitespaceCharacterSet())
    if query == "" {
        return Observable.of(Action.UpdateQuery(nil), Action.UpdateRepositories([]))
    } else {
        return Observable.just(Action.UpdateQuery(query)).concat(
            GitHubAPI(manager: Manager.sharedInstance).searchRepo(query).map { repositories in
                return Action.UpdateRepositories(repositories)
            }.asDriver(onErrorRecover: { (error) -> Driver<Action> in
                return Driver.just(Action.RepositoriesError(error))
            }))
    }

In case of SelectRepository delegates URL opening to delegate. In iOS, delegate will be UIViewController.

case .SelectRepository(let repository): if let url = NSURL(string: repository.htmlURL) { self.delegate?.openURL(url) } return Observable.empty() }

Controller is just a struct. So It should delegate some actions to special objects.

And set flatMapType to FlatMapType.Latest to ignore the result of previous requests.

var flatMapType = FlatMapType.Latest

Model

Model is pretty simple. It uses ReducerModel that accepts (previousState, action) pair and returns newState. (Like redux's reducer)

import Foundation
import RxMVC

struct GitHubSearchModel: ReducerModel {
    typealias State = GitHubSearchState
    typealias Action = GitHubSearchAction
    
    let initialState = GitHubSearchState(query: nil, repositories: RepositoriesState.Some([]))
    
    func reduce(state: State, with action: Action) -> State {
        switch action {
        case .UpdateQuery(let query):
            return State(query: query, repositories: state.repositories)
        case .UpdateRepositories(let repositories):
            return State(query: state.query, repositories: RepositoriesState.Some(repositories))
        case .RepositoriesError(let error):
            return State(query: state.query, repositories: RepositoriesState.Error(error))
        }
    }
}

View & User Interactable

We will make view and user interactable as single struct.

import Foundation
import RxSwift
import RxCocoa
import RxMVC
import JLToast

struct GitHubSearchView: View, UserInteractable {
    typealias State = GitHubSearchState
    typealias Event = GitHubSearchEvent
    
    let searchTextField: UITextField
    let tableView: UITableView
    
    func interact() -> Observable<Event> {
        return [
            searchTextField.rx_text.asDriver().map{text in Event.ChangeSearchText(text)}.throttle(0.5).asObservable(),
            tableView.rx_modelSelected(Repository).map{repository in Event.SelectRepository(repository)}
            ].toObservable().merge()
    }
    
    func update(stateStream: Observable<State>) -> Disposable {
        return CompositeDisposable(disposables: [
            stateStream.map{state in state.query ?? ""}.distinctUntilChanged().bindTo(searchTextField.rx_text),
            stateStream
                .map {state -> [Repository] in
                    switch(state.repositories) {
                    case .Some(let items):
                        return items as Array<Repository>
                    case .Error:
                        return []
                    }
                }
                .bindTo(tableView.rx_itemsWithCellIdentifier("Cell")) { (row, element, cell) in
                    cell.textLabel?.text = element.name
                    cell.detailTextLabel?.text = element.fullName
            },
            stateStream
                .map {state -> ErrorType? in
                    switch (state.repositories) {
                    case .Error(let error):
                        return error
                    default:
                        return nil
                    }
                }
                .asDriver(onErrorJustReturn: nil)
                .filter { error in error != nil }
                .map { error in error! }
                .throttle(1.0)
                .asObservable()
                .subscribeNext {error in
                    let error = error as NSError
                    JLToast.makeText(error.localizedDescription).show()
            }])
    }
}

Accepts events from views and convert them into event objects

throttling the user interaction, so we will not make useless requests.

func interact() -> Observable<Event> {
    return [
        searchTextField.rx_text.asDriver().map{text in Event.ChangeSearchText(text)}.throttle(0.5).asObservable(),
        tableView.rx_modelSelected(Repository).map{repository in Event.SelectRepository(repository)}
        ].toObservable().merge()
}

and render

func update(stateStream: Observable<State>) -> Disposable {
    return CompositeDisposable(disposables: [
        stateStream.map{state in state.query ?? ""}.distinctUntilChanged().bindTo(searchTextField.rx_text),
        stateStream
            .map {state -> [Repository] in
                switch(state.repositories) {
                case .Some(let items):
                    return items as Array<Repository>
                case .Error:
                    return []
                }
            }
            .bindTo(tableView.rx_itemsWithCellIdentifier("Cell")) { (row, element, cell) in
                cell.textLabel?.text = element.name
                cell.detailTextLabel?.text = element.fullName
        },
        stateStream
            .map {state -> ErrorType? in
                switch (state.repositories) {
                case .Error(let error):
                    return error
                default:
                    return nil
                }
            }
            .asDriver(onErrorJustReturn: nil)
            .filter { error in error != nil }
            .map { error in error! }
            .throttle(1.0)
            .asObservable()
            .subscribeNext {error in
                let error = error as NSError
                JLToast.makeText(error.localizedDescription).show()
        }])
}

ViewController

View controller is just a context for RxMVC. Just makes M, V, C and combine them.

import UIKit
import RxSwift
import RxMVC

class GitHubSearchViewController: UIViewController, GitHubSearchControllerDelegate {
    @IBOutlet weak var searchTextField: UITextField!
    @IBOutlet weak var tableView: UITableView!
    
    var disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        let model = GitHubSearchModel()
        let view = GitHubSearchView(searchTextField: searchTextField, tableView: tableView)
        let controller = GitHubSearchController(delegate: self)
        let userInteractable = view
        combineModel(model,
            withView: view,
            controller: controller,
            andUserInteractable: userInteractable)
            .addDisposableTo(disposeBag)
    }
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
    
    // MARK: - Controller delegate functions
    
    func openURL(url: NSURL) {
        UIApplication.sharedApplication().openURL(url)
    }
}

It's pretty long, But we can predict side-effects!

You can make predictable applications by using RxMVC.

See more examples from source.

Clone this wiki locally