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を引数に取ります。rootViewControllerlistViewControllerを生成します。
start()メソッドで最初の画面表示処理を行います。
ModelListCoordinatorのモデルを表す型です。

struct Model {}

AppCoordinatorSceneDelegatefunc 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()
    }
}

続いてListCoordinatorListViewConrollerを実装します。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()メソッドではlistViewUIHostingControllerUIViewControllerに変更しています。
これでSwiftUIでCoordinatorパターンを実装することができました。

残る不満

でもちょっと待ってください。このままではSwiftUIで作ったViewをいちいちUIHostingControllerUIViewControllerに変換しなければいけなくなります。
せっかく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アーキテクチャに挑戦します。

参考

宣伝

インプレスR&D社より、「1人でアプリを作る人を支えるSwiftUI開発レシピ」発売中です。
「SwiftUIでアプリを作る!」をコンセプトにSwiftUI自体の解説とそれを組み合わせた豊富なサンプルアプリでどんな風にアプリ実装すればいいかが理解できる本となっています。
MVVMパターンで作成したサンプルアプリ、iOS 14対応、Widgetの作成も一章まるまるハンズオンで解説しています。
SwiftUIを学びたい方、ぜひこちらのリンクをチェックしてください!




https://nextpublishing.jp/book/12491.html