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

はじめに

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

func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)? = nil)

だったり

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

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

通常の画面遷移の例

画面遷移①
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を利用した画面遷移の例

画面遷移②
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

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

RouteDispatcher

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

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のデータフローを単一方向に保つ案③をご覧ください。

RouteStore

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

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にイベントを渡す処理を実装します。

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します。

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ベースの画面遷移を実現して行きます。

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

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

Application

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

ApplicationDispatcher

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

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します。

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にイベントを渡します。

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

final class RouteDispatcher: DispatcherType {
    static let shared = RouteDispatcher()

    fileprivate let login = PublishSubject<LoginDisplayType>()
    fileprivate let search = PublishSubject<SearchDisplayType>()

    private init() {}
}

// 省略

RouteStore

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

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が代入された場合は、アニメーションをしてから切り替わる処理になっています。

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で呼び出すメソッドを出し分けています。

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したりしています。

    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にイベントが渡り、画面の出し分けが行われます。

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

QiitaWithFluxSample

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

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

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

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

最後に

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


『 Swift 』Article List