🚥

SwiftUI で Router を介してビューを呼び出す

2022/06/30に公開

はじめに

以下のようなモジュール構成でアプリを作成するときモジュール内で画面遷移を行おうとしてもモジュール間で循環参照になってしまいうまくいきません

そこで Router クラスを導入して DI 的に依存関係を解決します
モジュール構成は以下のようになります

使い方

はじめに利用シーンから
各 views モジュール内では Environment から Router を取り出し、適宜ビューを要求します

import SwiftUI
import Routings

public struct ContentView: View {
    @Environment(\.router) var router

    public init() {}

    public var body: some View {
        List(items) { item in 
            NavigationLink {
                router.route(for: Routings.Detail(id: id))
            } label: {
                Text("\(id)")
            }
        }
    }
}

Routings では以下のように対象のビューに必要なデータを持つバリューオブジェクトを列挙していきます

Routings/Routings.swift
public enum Routings {
    public struct Detail: Routing {
        public let id: Int
        public init(id: Int) { self.id = id }
    }

    // etc...
}

// このような extension を用意しておくと SE-0299 により
// `router.route(for: .detail(id: id))` のように呼び出せる
extension Routing where Self == Routings.Detail {
    public static func detail(id: Int) { self.init(id: id) }
}

実際のビューはアプリ本体側で RoutingProvider を実装したクラスを用意し、各 Routing の実態に対応するビューをマッピングします
それを AppDelegate などで保持した Router に渡し Environment 経由で参照できるようにセットします

App
import SwiftUI
import Routings

@MainActor
struct RouteProvider: RoutingProvider {
    @ViewBuilder
    func route(for target: any Routing) -> some View {
        switch target {
        case let detail as Routings.Detail:
            DetailView(id: detail.id)

        // case ...: 

        default:
            Text("unknown routing: \(String(describing: target))")
        }
    }
}

class AppDelegate: NSObject, UIApplicationDelegate {
    private(set) lazy var router = Router(provider: RouteProvider())
}

struct App: SwiftUI.App {
    @UIApplicationDelegateAdaptor var appDelegate: AppDelegate

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.router, appDelegate.router)
        }
    }
}

使い方に関しては以上です
また、 説明では NavigationLink だけでしたが、 router.route(for:...) は通常のビューを返すだけなので画面遷移だけでなく、ビュー内の要素を表示するモジュールのためにも利用できます

さらに、 iOS16 で登場する NavigationStack に関してもこの実装だとバリューオブジェクトである Routing をシームレスに NavigationPath で利用できると思います

struct ContentView: View {
    @State var navigationStacks = NavigationPath([Routings.List(), Routings.Detail(id: 1)])

    var body {
        NavigationStack(path: $navigationStacks) {
            RootView()
                .navigationDestination(for: Routings.List.self) { list in ... }
                .navigationDestination(for: Routings.Detail.self) { detail in ... }
        }
    }
}

Routings 実装

Routings/Router.swift
import SwiftUI

// ※ Hashable 適合は実装に不要、 NavigationStack 利用の際はここにあると便利
public protocol Routing: Hashable {}

public protocol RoutingProvider {
    associatedtype ResultView: View

    @Sendable
    @MainActor
    @ViewBuilder
    func route(for target: any Routing) -> ResultView
}

public struct Router: Sendable {
    let router: @Sendable @MainActor (any Routing) -> AnyView

    @MainActor
    public func route(for target: some Routing) -> some View {
        router(target)
    }
}

extension Router {
    public init<Provider: RoutingProvider>(provider: Provider) {
        let route = provider.route(for:)
        router = { target in
            AnyView(route(target))
        }
    }
}

extension EnvironmentValues {
    private struct Key: EnvironmentKey {
        static var defaultValue: Router {
            Router { _ in
                AnyView(EmptyView())
            }
        }
    }

    public var router: Router {
        get { self[Key.self] }
        set { self[Key.self] = newValue }
    }
}

Discussion