SwiftUIの画面遷移をCoordinatorパターンでまとめた話
SwiftUIのアプリの実装について、画面遷移をView実装から切り離す方法について考えていきたいと思います。
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を学びたい方、ぜひこちらのリンクをチェックしてください!
