【画面遷移はUIKitに】SwiftUIにおけるFlowControllerを解説!

Swift

こんにちは、新人エンジニアの岩本です。

今回は「FlowController」パターンについて解説していきます。

SwiftUIは何かと画面遷移周りが不安定ですが、FlowControllerを使うことでそれらの悩みを解消することができます。

ぜひ最後までご覧ください。

この記事は新卒エンジニアが執筆しています。
そのため内容に間違いや不備がある場合があります。
もし間違いを発見しましたら、どんどん指摘していただけると幸いです。

FlowControllerとは

SwiftUIにおけるFlowControllerは、「画面遷移の処理」をまとめたクラスです。

そもそもSwiftUIは画面遷移周りの処理が結構不安定だったりします。

  • NavigationLinkをつけると、矢印が表示される
    • 矢印を消すためには特殊な書き方をしなければならない
  • NavigationViewとTabViewを同時に使うと、挙動がおかしくなる
  • 画面遷移の処理をUIと一緒のところに書く必要がある

など、たくさんの問題点があります。

そこでSwiftUIはViewの処理だけをして、画面遷移の処理はUIKitに任せればいいじゃん、という発想になります。これがFlowControllerパターンの考え方です。

FlowController内ではUIKitで画面遷移を行います。

こうすることで、全てとは言いませんがある程度問題をクリアすることができます。

実装例

今回はMVVM + FlowControllerの実現方法について解説していきます。

登場人物はこんな感じです。

ファイル名役割
Screen画面のレイアウトを記述。SwiftUIで実装。
ViewModelScreenとFlowControllerの橋渡し。
Scrennで発生したイベントをFlowControllerに渡す。
FlowController画面遷移の処理を記述。 UIKitで実装。

FlowControllerは UIHostingView を使って実装します。
UIHostingView はSwiftUIをUIKitで表示できるようにするものです。

UIHostingController | Apple Developer Documentation
A UIKit view controller that manages a SwiftUI view hierarchy.

ここからは簡単なアプリを作って、FlowControllerについて解説します。

全てのコードをここに載せることはできないので、詳しく見たい方はGitHubから見てください。

GitHub - Yuzuki0709/SampleFlowController
Contribute to Yuzuki0709/SampleFlowController development by creating an account on GitHub.

ボタンを押下して画面遷移する簡単なアプリを作っていきます。

まずは主要なコードを載せます。

FirstScreen.swift
struct FirstScreen: View {
    @ObservedObject var viewModel: FirstViewModel
    
    init(viewModel: FirstViewModel) {
        self.viewModel = viewModel
    }
    
    var body: some View {
        Button {
            viewModel.navigate(.second)
        } label: {
            Text("画面遷移")
                .frame(width: 100, height: 50)
                .background(Color.blue)
                .foregroundColor(.white)
        }
    }
}
FirstViewModel.swift
final class FirstViewModel: ObservableObject {
    private let _navigationSubject = PassthroughSubject<Navigation, Never>()
    var navigationSignal: AnyPublisher<Navigation, Never> {
        _navigationSubject.eraseToAnyPublisher()
    }
    
    func navigate(_ navigation: Navigation) {
        _navigationSubject.send(navigation)
    }
}

extension FirstViewModel {
    enum Navigation {
        case second
    }
}
FlowController.swift
public protocol FlowController: UIViewController {
    func start()
}
FirstFlowController.swift
final class FirstFlowController: HostingController<FirstScreen>, FlowController {
    private var cancellable = Set<AnyCancellable>()
    private var current: UIViewController?
    
    private var viewModel: FirstViewModel {
        host.rootView.viewModel
    }
    
    func start() {
         cancellable = Set()
        
        viewModel.navigationSignal
            .sink(receiveValue: { [weak self] navigation in
                guard let self else { return }
                switch navigation {
                case .second:
                    self.startSecond()
                }
            })
            .store(in: &cancellable)
    }
    
    private func startSecond() {
        let second = SecondFlowController(
            rootView: SecondScreen(
                viewModel: SecondViewModel()
            )
        )
        self.present(second, animated: true)
        second.start()
    }
}
HostingController.swift
class HostingController<Content: View>: UIViewController {
    public var host: UIHostingController<Content>
    
    open override var navigationItem: UINavigationItem {
        host.navigationItem
    }
    
    public init(rootView: Content) {
        self.host = .init(rootView: rootView)
        super.init(nibName: nil, bundle: nil)
        addContent(host)
    }
    
    @available(*, unavailable)
    public required init?(coder: NSCoder) {
        fatalError("Error")
    }
}

ポイントを解説

ポイントをかいつまんで解説します。

まずViewModelではNavigationを定義し、そのViewから遷移する画面の記述します。

FirstViewModel.swift
extension FirstViewModel {
    enum Navigation {
        case second
    }
}

FlowControllerではこのNavigationの値を受け取って、どこの画面に遷移するかを判別します。

FirstFlowController.swift
viewModel.navigationSignal
    .sink(receiveValue: { [weak self] navigation in
        guard let self else { return }
        switch navigation {
            case .second:
                self.startSecond()
        }
     })
     .store(in: &cancellable)

実際に画面遷移をする際は、対象画面のFlowControllerのインスタンスを作成します。

このFlowControllerはUIViewControllerを継承しているので、画面遷移処理で直接指定することができます。

FirstFlowController.swift
private func startSecond() {
    let second = SecondFlowController(
        rootView: SecondScreen(
            viewModel: SecondViewModel()
        )
    )
    self.present(second, animated: true) // モーダル遷移
    second.start()
}

【ステップアップ】NavigationViewを使ったFlowControllerパターン

ではここからはNavigationViewを使ったFlowControllerパターンを紹介します。

NavigationControllerを使って画面遷移するには、UINavigationControllerを継承したクラスを用意する必要があります。

こちらも詳しいコードはGitHubをご覧ください。

GitHub - Yuzuki0709/SampleFlowController
Contribute to Yuzuki0709/SampleFlowController development by creating an account on GitHub.
RootContainerFlowController.swift
final class RootContainerFlowController: UINavigationController, FlowController {
    func start() {
        let first = FirstFlowController(
            rootView: FirstScreen(
                viewModel: FirstViewModel()
            )
        )
        
        setViewControllers([first], animated: true)
        first.start()
    }
}
FirstFlowController.swift
final class FirstFlowController: HostingController<FirstScreen>, FlowController {
    private var cancellable = Set<AnyCancellable>()
    private var current: UIViewController?
    
    private var viewModel: FirstViewModel {
        host.rootView.viewModel
    }
    
    func start() {
         cancellable = Set()
        
        viewModel.navigationSignal
            .sink(receiveValue: { [weak self] navigation in
                guard let self else { return }
                switch navigation {
                case .second:
                    self.startSecond()
                }
            })
            .store(in: &cancellable)
        
    }
    
    private func startSecond() {
        let second = SecondFlowController(
            rootView: SecondScreen(
                viewModel: SecondViewModel()
            )
        )
        // Navigationで遷移
        self.navigationController?.pushViewController(second, animated: true)
        second.start()
    }
}

ポイントを解説

コードのポイントをかいつまんで解説します。

まずはUINavigationControllerを継承したクラスを用意します。

RootContainerFlowController.swift
final class RootContainerFlowController: UINavigationController, FlowController {
    func start() {
        let first = FirstFlowController(
            rootView: FirstScreen(
                viewModel: FirstViewModel()
            )
        )
        
        setViewControllers([first], animated: true)
        first.start()
    }
}

このクラス自体は画面遷移の処理というよりは、Navigation下の最初の画面を定義します。こうすることでその画面からの遷移はNavigationで遷移することができます。

参考にした記事

SwiftUI + FlowController パターンの提案 - Qiita
コンセプトモチベーションアプリを SwiftUI ベースで作りたい!……けど、危険な香りもするので UIKit に逃げられる余地を残しておきたいSwiftUISwiftUI が発表されたの…
【Swift】FlowControllerを使ったサンプルを作ってCoordinatorとの違いを検討する - Qiita
経緯参考にした記事: 開発の中でCoordinatorパターンを使っていたこととCoordinator…

コメント

タイトルとURLをコピーしました