SwiftUIの画面遷移をCoordinatorパターンでまとめた話
SwiftUIのアプリの実装について、画面遷移をView実装から切り離す方法について考えていきたいと思います。
何も考えずにViewを実装すると画面遷移ロジックがView実装に入り込むことはよくあります。
例えばNavigationView
とその遷移を決めるNavigationLink
を同じView内に実装するなどはよくやります。
struct ContentView: View {
var body: some View {
NavigationView {
NavigationLink(destination: Text("detail")) { // 画面遷移実装がView実装に入り込む
Text("main")
}
}
}
}
これでももちろん動作します。
しかし、アプリ要件が複雑になったときには、View実装はViewのレイアウトのみに専念し、そのViewがどこに遷移するかは別の型で管理したいです。
どんな方法があるかと調べていくとCoordinatorパターンというものがあるらしく、SwiftUIでうまくいくかを試しました。
結論から言うとSwiftUIのみで画面遷移の実装を引き剥がすことは難しそうでした。View Controllerに都度変換すれば実装できそうでした。
とはいえCoordinatorパターンがどういうものかを学べたのでそれを記事にします。
Coordinatorパターンとは
Coordinatorパターンは2015年にSoroush Khanlou (@khanlou)氏が提唱した画面遷移ロジックを画面実装から引き剥がすデザインパターンです。これによりView Controllerの再利用性を高め、View Controller同士が密結合することを防ぎます。
彼のブログや2016年にNSSpainカンファレンスというスペインのiOS/macOSカンファレンスで発表されました。
彼のブログからコードを引用します。
よくあるUITableView
のセルがタップしたら画面遷移をするコードです。
次の画面であるSKDetailViewController
というView Controllerを生成してUINavigationViewController
にpushをするコードです。
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
id object = [self.dataSource objectAtIndexPath:indexPath];
SKDetailViewController *detailViewController = [[SKDetailViewController alloc] initWithDetailObject:object];
[self.navigationController pushViewController:detailViewController animated:YES];
}
このコードには問題があります。
遷移元となるView Controllerは次のView ControllerがSKDetailViewController
であることを知っています。それを生成するためobject
インスタンスも扱っています。
さらにself.navigationController
と自分のView階層の親も知る必要があります。
View Controller
が必要以上に密結合しているため、アプリ要件が変わった際にはかなり実装を変えなければいけなくなるでしょう。次の画面が他のView Controllerに変わったら? View Controllerの生成方法が変わったら? 親ViewがUINavigationViewController
ではなくなったら?
Coordinatorパターンはこの問題を解決します。
まずView Controller
の上位レイヤーであるCoordinator
を導入します。Coordinator
がアプリケーションにおける画面遷移の責務を負うのです。
Coordinatorパターンの実装例
まずUIViewController
でCoordinatorパターンがどのように実装されるか例をみていきましょう。
まずCoordinator
プロトコルを定義します。これはstart()
メソッドのみ持つプロトコルです。
protocol Coordinator {
func start()
}
続いてアプリケーション全体の画面遷移を扱うApplicationCoordinator
を定義します。
Coordinator
プロトコルに準拠したクラスです。
final class AppCoordinator: Coordinator {
let window: UIWindow
let rootViewController: UINavigationController
let listCoordinator: ListCoordinator
init(window: UIWindow) {
self.window = window
self.rootViewController = UINavigationController()
let model = Model()
listCoordinator = ListCoordinator(navigator: rootViewController, models: [model])
self.window.rootViewController = rootViewController
}
func start() {
window.rootViewController = rootViewController
listCoordinator.start()
window.makeKeyAndVisible()
}
}
イニシャライザにUIWindow
を引数に取ります。rootViewController
とlistViewController
を生成します。
start()
メソッドで最初の画面表示処理を行います。
Model
はListCoordinator
のモデルを表す型です。
struct Model {}
AppCoordinator
をSceneDelegate
のfunc scene(_:willConnectTo:options)
メソッドで生成します。
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let window = UIWindow(frame: UIScreen.main.bounds)
self.window = window
let appCoordinator = AppCoordinator(window: window)
appCoordinator.start()
}
}
続いてListCoordinator
とListViewConroller
を実装します。CoordinatorとView Controllerは一対一の関係で作っていきます。
final class ListCoordinator: Coordinator {
private let navigator: UINavigationController
private let models: [Model]
private let listViewController: ListViewConroller
private var detaileCoordinator: DetailCoordinator?
init(navigator: UINavigationController, models: [Model]) {
self.navigator = navigator
self.models = models
listViewController = ListViewConroller(models: models)
}
func start() {
navigator.setViewControllers([listViewController], animated: false)
listViewController.delegate = self
}
}
ListCoordinator
はイニシャライザにListViewConroller
をスタックするNavigation ControllerとListViewConroller
を生成するのに必要なmodels
を引数にとります。
そしてstart()
メソッドで画面表示を行います。
listViewController.delegate = self
の説明は後でしますが、ListViewConroller
のViewイベントを検知できるようにしています。
ListViewConroller
の実装はこうです。
final class ListViewConroller: UIViewController, UITableViewDelegate {
var delegate: ListViewControllerDelegate?
let models: [Model]
init(models: [Model]) {
self.models = models
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let model = models[indexPath.row]
delegate?.listViewControllerDidSelect(model)
}
}
イニシャライザでmodels
をセットします。UITableView
で表示することを想定し、セルがタップされたらListViewControllerDelegate
のデリゲートメソッドを呼ぶようにしています。
ViewイベントをCoordinatorでハンドルできるようにしています。
ListViewControllerDelegate
の定義を説明します。
protocol ListViewControllerDelegate: AnyObject {
func listViewControllerDidSelect(_ model: Model)
}
extension ListCoordinator: ListViewControllerDelegate {
func listViewControllerDidSelect(_ model: Model) {
let detailCoordinator = DetailCoordinator(navigator: navigator, model: model)
detailCoordinator.start()
self.detaileCoordinator = detailCoordinator
}
}
func listViewControllerDidSelect(_:)
メソッドが呼ばれたら詳細画面のコーディネーターDetailCoordinator
を生成してstart()
メソッドを呼んでいます。
このようにデリゲートメソッドを介して特定のViewイベントが呼ばれたら次の画面遷移をハンドリングしています。
最後に詳細画面のコーディネーターであるDetailCoordinator
とView ControllerDetailViewController
をみていきましょう。
final class DetailCoordinator: Coordinator {
private let navigator: UINavigationController
private let model: Model
private var detailViewController: DetailViewController
init(navigator: UINavigationController, model: Model) {
self.navigator = navigator
self.model = model
detailViewController = DetailViewController(model: model)
}
func start() {
navigator.show(detailViewController, sender: nil)
}
}
DetailCoordinator
はイニシャライザでDetailViewController
を生成します。
start()
メソッドで画面遷移を行います。
最後にDetailViewController
の実装です。
final class DetailViewController: UIViewController {
let model: Model
init(model: Model) {
self.model = model
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
CoordinatorパターンのUIViewController
実装例を示しました。
各画面でCoordinatorとView Controllerが一対一の関係になっており、CoordinatorのイニシャライザでView Controllerの生成やセッティングを行います。start()
メソッドで画面遷移の処理を行います。
アプリ全体の画面遷移の実装はAppCoordinator
を通して行います。
SwiftUIで適応してみる
CoordinatorパターンをSwiftUIで適応してみました。
SwiftUIでListView
を定義します。
struct ListView: View {
var models: [Model]
var didTap: (Model) -> ()
var body: some View {
Button(action: {
self.didTap(self.models[0])
}, label: {
Text("brabrabra")
})
}
}
ListCoordinator
を変更し、ListViewConroller
に替わりListView
を使うようにします。
final class ListCoordinator: Coordinator, ObservableObject {
private let navigator: UINavigationController
let models: [Model]
lazy var listView: ListView = {
let list = ListView(models: models, didTap: { [unowned self] model in
let detailCoordinator = DetailCoordinator(navigator: self.navigator, model: model)
detailCoordinator.start()
self.detaileCoordinator = detailCoordinator
})
return list
}()
private var detaileCoordinator: DetailCoordinator?
init(navigator: UINavigationController, models: [Model]) {
self.navigator = navigator
self.models = models
}
func start() {
navigator.setViewControllers([UIHostingController(rootView: listView)], animated: false)
}
}
CoordinatorとViewとのイベント受け渡しをListViewControllerDelegate
ではなくクロージャーで行うように変更しました。
そしてstart()
メソッドではlistView
をUIHostingController
でUIViewController
に変更しています。
これでSwiftUIでCoordinatorパターンを実装することができました。
残る不満
でもちょっと待ってください。このままではSwiftUIで作ったViewをいちいちUIHostingController
でUIViewController
に変換しなければいけなくなります。
せっかくSwiftUIを使うなら、画面遷移もすべてSwiftUIで実装したいものです。
色々調べたのですが、私ではCoordinatorパターンでSwiftUIのみで画面遷移をViewから切り離す実装をするのは難しいようでした。
やりたいことはViewと画面遷移ロジックを切り離すことです。
なるべくサードパーティに依存しないアーキテクチャだと嬉しいと思っています。
そこに合致するデザインパターンがありました。
VIPERです。
Getting Started with the VIPER Architecture Pattern | raywenderlich.com
次回はVIPERアーキテクチャパターンを実装したいと思います。
このパターンではSwiftUIのみで画面遷移ロジックを切り離すことができました。
まとめ
今回はCoordinatorパターンを解説しました。
View Controllerの上位レイヤーとしてCoordinatorを導入し画面遷移ロジックをCoordinatorに任せます。Coordinatorの初期化で対応するView Controllerの生成をおこない、start()
メソッドで画面遷移ロジックを実装します。こうすることでView Controller同士が疎結合になり、再利用性が高まります。
今回はSwiftUIでCoordinatorパターンを試しましたが、画面遷移ロジックでView Controllerに変換する実装になってしまいました。
SwiftUIのみで実装できるよう、次回はVIPERアーキテクチャに挑戦します。
参考
- Introduction to Coordinator pattern in Swift
- Coordinator Tutorial for iOS: Getting Started | raywenderlich.com
- 漢字を調べるアプリを作る過程でCoordinatorパターンの実装例がステップ・バイ・ステップで学べます。
- iOSアプリ設計パターン入門
- 私が知る限りCoordinatorパターンを解説する唯一の商業技術書です。一章つかって丁寧に解説がされています。
- CutFlame/StarWarsContacts: An example of MVVM-Coordinators pattern with SwiftUI
- MVVMとCoordinatorパターンでSwiftUIアプリを作るサンプル例です。残念ながらXcode 11 beta時代に作られたものでcloneしてもビルドができませんでした。しかし実装方法は参考になります。このサンプルも画面遷移はSwiftUIのViewを
UIHostingController
を通してView Controllerに変換するタイプになっています。
- MVVMとCoordinatorパターンでSwiftUIアプリを作るサンプル例です。残念ながらXcode 11 beta時代に作られたものでcloneしてもビルドができませんでした。しかし実装方法は参考になります。このサンプルも画面遷移はSwiftUIのViewを
宣伝
インプレスR&D社より、「1人でアプリを作る人を支えるSwiftUI開発レシピ」発売中です。
「SwiftUIでアプリを作る!」をコンセプトにSwiftUI自体の解説とそれを組み合わせた豊富なサンプルアプリでどんな風にアプリ実装すればいいかが理解できる本となっています。
MVVMパターンで作成したサンプルアプリ、iOS 14対応、Widgetの作成も一章まるまるハンズオンで解説しています。
SwiftUIを学びたい方、ぜひこちらのリンクをチェックしてください!