はじめに
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を用いることで下図のように、アプリ内のデータフローの単方向化をすることができるようになります。
画面遷移②
では、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)
- LoginNavigationController (ログイン周りの画面のNavigationController)
アクセストークンの有無により、LoginNavigationController
とSearchNavigationController
を出し分けます。
何かしらの状態によって大本の画面を切り替えたりする処理を実装することは、該当の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を実行するだけで任意の画面に遷移ができるようになります。
QiitaWithFluxSample
MVVMとFluxを組合させたサンプルをつくっているので、詳しく見てみたい方はこちらをご覧いただけると幸いです。
また、MVVM + Fluxについては、こちらの資料をご覧いただけると幸いです。
最後に
このルーティング処理を用いることで、Remote Noticationから起動をして該当の画面を表示させようとする処理も、容易に実装することができるようになります。