【iOS】Fluxを利用して画面遷移を制御する

# はじめに

iOSアプリで画面遷移を行う際は、該当のViewControllerから

```swift
func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil)
```
だったり

```swift
func pushViewController(_ viewController: UIViewController, animated: Bool)
```

などを呼んで次の画面へ遷移するかと思います。

## 通常の画面遷移の例

```swift:画面遷移①
extension SearchTopViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let item = items.value[indexPath.row]
        guard let url = URL(string: item.url) else { return }
        let vc = SFSafariViewController(url: url)
        navigationController?.pushViewController(vc, animated: true)
    }
}
```

SearchTopViewController上のtableViewがタップされた際に、itemのurlをもとにWebViewを表示するとします。
この場合、itemから取得したurlをSFSafariViewControllerに渡し、pushViewControllerで画面遷移させるかと思います。

## Fluxを利用した画面遷移の例

```swift:画面遷移②
extension SearchTopViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let item = items.value[indexPath.row]
        guard let url = URL(string: item.url) else { return }
        RouteAction.shared.show(searchDisplayType: .webView(url))
    }
}
```

`画面遷移①`と`画面遷移②`を比較すると、ViewControllerの生成部分と画面遷移のコードがなくなって、代わりに`RouteAction.shared.show(searchDisplayType: .webView(url))`が使われています。
このように、FluxのActionとして分離されたオブジェクトの1つのメソッドで画面の生成から遷移までを行うことによって、他の画面でも同じ画面遷移を再利用することができます。
それでは、上記のコードがどのように動作するのかを解説していきます。

# Flux

Fluxを用いることで下図のように、アプリ内のデータフローの単方向化をすることができるようになります。

![flux.png](https://qiita-image-store.s3.amazonaws.com/0/60325/6d41b79e-55d4-24c8-0482-f2406323d8a4.png)

`画面遷移②`では、View (SearchTopViewController)からAction (RouteAction)のイベントを発生させ、Dispatcher (RouteDispatcher)を介してStore (RouteStore)にイベントが渡されるという流れになっています。

## RouteDispatcher

searchのイベントをActionから受け取り、Storeに渡すためにDispatcherを実装します。

```swift
final class RouteDispatcher: DispatcherType {
    static let shared = RouteDispatcher()
    
    fileprivate let search = PublishSubject<SearchDisplayType>()
    
    private init() {}
}

extension AnyObserverDispatcher where Dispatcher: RouteDispatcher {
    var search: AnyObserver<SearchDisplayType> {
        return dispatcher.search.asObserver()
    }
}

extension AnyObservableDispatcher where Dispatcher: RouteDispatcher {
    var search: Observable<SearchDisplayType> {
        return dispatcher.search
    }
}
```

Any○○Dispatcherの説明については、[【iOS】FluxのDispatcherのデータフローを単一方向に保つ案③](http://qiita.com/marty-suzuki/items/4b70dc28786340ae15a5)をご覧ください。

## RouteStore

Disaptcherからsearchのイベントを受け取り、Storeのsearchにbindします。
StoreのsearchはViewで利用します。

```swift
enum SearchDisplayType {
    case root
    case webView(URL)
}

final class RouteStore {
    static let shared = RouteStore()

    let search: Observable<SearchDisplayType?>
    private let _search = BehaviorSubject<SearchDisplayType?>(value: nil)
    
    private let disposeBag = DisposeBag()
    
    init(dispatcher: AnyObservableDispatcher<RouteDispatcher> = .init(.shared)) {
        self.search = _search
        
        dispatcher.search
            .bind(to: _search)
            .addDisposableTo(disposeBag)
    }
}
```

## RouteAction

画面の遷移の処理が実行されたら、Dispatcherにイベントを渡す処理を実装します。

```swift
final class RouteAction {
    static let shared = RouteAction()
    
    private let dispatcher: AnyObserverDispatcher<RouteDispatcher>
    
    init(dispatcher: AnyObserverDispatcher<RouteDispatcher> = .init(.shared)) {
        self.dispatcher = dispatcher
    }

    func show(searchDisplayType: SearchDisplayType) {
        dispatcher.search.onNext(searchDisplayType)
    }
}
```

# 画面遷移を制御するViewContorller

RouteStoreまで渡ってきたイベントを、画面遷移を制御するViewContorllerでsubscribeします。
渡ってきたdisplayTypeによって、`currentViewController`を出し分けたり、適切なViewControllerをPushまたはPresentします。

```swift
class RootViewController: UIViewController {
    private (set) var currentViewController: UIViewController?
    private let disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        observeStore()
    }

    private func observeStore() {
        RouteStore.shared.search
            .observeOn(ConcurrentMainScheduler.instance)
            .filter { $0 != nil }
            .map { $0! }
            .subscribe(onNext: { [weak self] displayType in
                guard let me = self else { return }
                let searchNC: SearchNavigationController
                if let nc = me.currentViewController as? SearchNavigationController {
                    searchNC = nc
                } else {
                    searchNC = SearchNavigationController()
                    me.currentViewController = searchNC
                }
                switch displayType {
                case .root:
                    if searchNC.topViewController is SearchTopViewController {
                        return
                    }
                    searchNC.popToRootViewController(animated: true)
                case .webView(let url):
                    if searchNC.topViewController is SFSafariViewController {
                        return
                    }
                    searchNC.pushViewController(SFSafariViewController(url: url), animated:  true)
                }
            })
            .addDisposableTo(disposeBag)
    }
}
```

ここまでの実装で、`RouteAction`を呼び出すことによって画面遷移を制御できるようになります。
少々使いみちがわかりにくいかもしれないので、更に具体的な例でこれらの実装の利用方法を解説していきます。

# 実用例

今までの処理を用いて、下記のQiitaベースの画面遷移を実現して行きます。

![](https://raw.githubusercontent.com/marty-suzuki/QiitaWithFluxSample/master/Images/structure.jpeg)

- RootViewController (画面を出し分けるViewController)
    - LoginNavigationController (ログイン周りの画面のNavigationController)
        - LoginTopViewController (初回起動時の最初のViewController)
        - LoginViewController (ログインのViewController)
    - SearchNavigationController (検索周りの画面のNavigationController)
        - SearchTopViewController (投稿を検索するViewController)
        - SFSafariViewController (投稿を表示するViewController)

アクセストークンの有無により、`LoginNavigationController`と`SearchNavigationController`を出し分けます。
何かしらの状態によって大本の画面を切り替えたりする処理を実装することは、該当のView側で画面遷移を考慮して出し分けをしなければいけなくなるかと思います。
その部分にFluxを利用することで、該当の処理のActionを呼ぶだけで任意の画面が表示されるようにしていこうと思います。

## Application

アクセストークン周りに関する実装をしていきます。

### ApplicationDispatcher

ActionからaccessTokenを受け取り、StoreにaccessTokenを渡す実装をしてます。

```swift
final class ApplicationDispatcher: DispatcherType {
    static let shared = ApplicationDispatcher()
    
    fileprivate let accessToken = PublishSubject<String?>()
    
    private init() {}
}

//　省略
```

### ApplicationStore

Dispatcherから受け取ったaccessTokenを保持します。
UserDefaultsにaccessTokenが保存済みの場合は、初期化時に値を代入しておきます。
DispatcherからaccessTokenを受け取ったら、UserDefaultsに保存しStoreのaccessTokenにbindします。

```swift
final class ApplicationStore {
    static let shared = ApplicationStore()
    
    let accessToken: Property<String?>
    private let _accessToken = Variable<String?>(nil)
    
    private let disposeBag = DisposeBag()
    
    init(dispatcher: AnyObservableDispatcher<ApplicationDispatcher> = .init(.shared)) {
        if let token = Defaults[.accessToken] {
            _accessToken.value = token
        }
        
        self.accessToken = Property(_accessToken)

        dispatcher.accessToken
            .do(onNext: { Defaults[.accessToken] = $0 })
            .bind(to: _accessToken)
            .addDisposableTo(disposeBag)
    }
}
```

### ApplicationAction

アクセストークンを取得する処理を実装します。
APIを叩いてアクセストークンを取得したら、Dispatcherを介してStoreにイベントを渡します。

```swift
final class ApplicationAction {
    static let shared = ApplicationAction()
    
    private let dispatcher: AnyObserverDispatcher<ApplicationDispatcher>
    private let session: SessionType
    private let config: Config
    
    private let disposeBag = DisposeBag()
    
    init(dispatcher: AnyObserverDispatcher<ApplicationDispatcher> = .init(.shared),
         session: SessionType = QiitaSession.shared,
         config: Config = .shared) {
        self.dispatcher = dispatcher
        self.session = session
        self.config = config
    }
    
    func requestAccessToken(withCode code: String) {
        let request = AccessTokensRequest(clientId: config.clientId,
                                          clientSecret: config.clientSecret,
                                          code: code)
        session.send(request)
            .map { Optional.some($0.token) }
            .subscribe(onNext: dispatcher.accessToken.onNext)
            .addDisposableTo(disposeBag)
    }
}
```

## Route

先程のRoute周りのFluxのオブジェクトに対して、ログイン周りのメソッド、Observableやobserverを追加していきます。

### RouteDispatcher

```swift
final class RouteDispatcher: DispatcherType {
    static let shared = RouteDispatcher()
    
    fileprivate let login = PublishSubject<LoginDisplayType>()
    fileprivate let search = PublishSubject<SearchDisplayType>()
    
    private init() {}
}

//　省略
```

### RouteStore

```swift
enum LoginDisplayType {
    case root
    case webView
}

enum SearchDisplayType {
    case root
    case webView(URL)
}

final class RouteStore {
    static let shared = RouteStore()
    
    let login: Observable<LoginDisplayType?>
    private let _login = BehaviorSubject<LoginDisplayType?>(value: nil)
    
    let search: Observable<SearchDisplayType?>
    private let _search = BehaviorSubject<SearchDisplayType?>(value: nil)
    
    private let disposeBag = DisposeBag()
    
    init(dispatcher: AnyObservableDispatcher<RouteDispatcher> = .init(.shared)) {
        self.login = _login
        self.search = _search
        
        dispatcher.login
            .bind(to: _login)
            .addDisposableTo(disposeBag)
        
        dispatcher.search
            .bind(to: _search)
            .addDisposableTo(disposeBag)
    }
}
```

### RouteAction

```swift
final class RouteAction {
    static let shared = RouteAction()
    
    private let dispatcher: AnyObserverDispatcher<RouteDispatcher>
    
    init(dispatcher: AnyObserverDispatcher<RouteDispatcher> = .init(.shared)) {
        self.dispatcher = dispatcher
    }
    
    func show(loginDisplayType: LoginDisplayType) {
        dispatcher.login.onNext(loginDisplayType)
    }
    
    func show(searchDisplayType: SearchDisplayType) {
        dispatcher.search.onNext(searchDisplayType)
    }
}
```

## RootViewController

`currentViewController`に新しくViewControllerが代入された場合は、アニメーションをしてから切り替わる処理になっています。

```swift
class RootViewController: UIViewController {
    private (set) var currentViewController: UIViewController? {
        didSet {
            guard let currentViewController = currentViewController else { return }
            addChildViewController(currentViewController)
            currentViewController.view.frame = view.bounds
            view.addSubview(currentViewController.view)
            currentViewController.didMove(toParentViewController: self)
            
            guard let oldViewController = oldValue else { return }
            view.sendSubview(toBack: currentViewController.view)
            UIView.transition(from: oldViewController.view,
                              to: currentViewController.view,
                              duration: 0.3,
                              options: .transitionCrossDissolve) { [weak oldViewController] _ in
                guard let oldViewController = oldViewController else { return }
                oldViewController.willMove(toParentViewController: nil)
                oldViewController.view.removeFromSuperview()
                oldViewController.removeFromParentViewController()
            }
        }
    }
    
    private let disposeBag = DisposeBag()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        observeApplicationStore()
        observeRouteStore()
    }
}
```

`func observeApplicationStore()`では`ApplicationStore.shared.accessToken`の状態によって、`RouteAction.shared`で呼び出すメソッドを出し分けています。

```swift
extension RootViewController {
    fileprivate func observeApplicationStore() {
        let accessTokenObservable = ApplicationStore.shared.accessToken.asObservable()
        accessTokenObservable
            .filter { $0 != nil }
            .map { _ in SearchDisplayType.root }
            .bind(onNext: RouteAction.shared.show)
            .addDisposableTo(disposeBag)
        
        accessTokenObservable
            .filter { $0 == nil }
            .map { _ in LoginDisplayType.root }
            .bind(onNext: RouteAction.shared.show)
            .addDisposableTo(disposeBag)
    }
```

`func observeRouteStore()`では、`RouteAction.shared.login`または`RouteAction.shared.search`の状態によって、`currentViewController`を切り替えたり、適切な画面をPushまたはPresentしたりしています。

```swift
    fileprivate func observeRouteStore() {
        RouteAction.shared.login
            .observeOn(ConcurrentMainScheduler.instance)
            .filterNil()
            .subscribe(onNext: { [weak self] displayType in
                // ログイン画面周りの表示処理
            })
            .addDisposableTo(disposeBag)
        
        RouteAction.shared.search
            .observeOn(ConcurrentMainScheduler.instance)
            .filterNil()
            .subscribe(onNext: { [weak self] displayType in
                // 検索画面周りの表示処理
            })
            .addDisposableTo(disposeBag)
    }
}
```

## LoginViewController

Login画面でQiitaページからcodeを取得し、そのcodeを用いてアクセストークンを取得します。
`ApplicationAction.shared.requestAccessToken(withCode: code)`でアクセストークンの取得を行うと、RootViewControllerでsubscribeしている`AppliationStore.shared.accessToken`にイベントが渡ります。
accessTokenがnilの場合は`RouteAction.shared.show(loginDisplayType: .root)`、accessTokenが存在する場合は`RouteAction.shared.show(searchDisplayType: .root)`のイベントが発生します。
そのイベントは、RootViewControllerでsubscribeしてる`RouteStore.shared.login`または`RouteStore.shared.search`にイベントが渡り、画面の出し分けが行われます。

```swift
class LoginViewController: UIViewController, WKNavigationDelegate {
    let webView: WKWebView = WKWebView(frame: .zero)

    override func viewDidLoad() {
        super.viewDidLoad()
        webView.navigationDelegate = self
    }

    func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Swift.Void) {
        guard let url = navigationAction.request.url else {
            decisionHandler(.cancel)
            return
        }
        if url.absoluteString.hasPrefix(Config.shared.redirectUrl) {
            guard
                let URLComponents = URLComponents(string: url.absoluteString),
                // URLからcodeを取得
                let code = codeItem.value
            else {
                fatalError("can not find \"code\" from URL query")
            }
            
            ApplicationAction.shared.requestAccessToken(withCode: code)
            decisionHandler(.cancel)
            return
        }
        decisionHandler(.allow)
    }
}
```

このように実装することで、ログインしアクセストークンを取得後の遷移を意識することなく、Actionを実行するだけで任意の画面に遷移ができるようになります。

![ezgif.com-optimize.gif](https://qiita-image-store.s3.amazonaws.com/0/60325/0550f5b0-428a-b0e9-bf6a-1ee325e33210.gif)


# QiitaWithFluxSample

MVVMとFluxを組合させたサンプルをつくっているので、詳しく見てみたい方はこちらをご覧いただけると幸いです。

https://github.com/marty-suzuki/QiitaWithFluxSample

また、MVVM + Fluxについては、こちらの資料をご覧いただけると幸いです。

https://speakerdeck.com/martysuzuki/mvvm-plus-flux

# 最後に

このルーティング処理を用いることで、Remote Noticationから起動をして該当の画面を表示させようとする処理も、容易に実装することができるようになります。