こんにちは、新人エンジニアの岩本です。
今回は「FlowController」パターンについて解説していきます。
SwiftUIは何かと画面遷移周りが不安定ですが、FlowControllerを使うことでそれらの悩みを解消することができます。
ぜひ最後までご覧ください。
FlowControllerとは
SwiftUIにおけるFlowControllerは、「画面遷移の処理」をまとめたクラスです。
そもそもSwiftUIは画面遷移周りの処理が結構不安定だったりします。
など、たくさんの問題点があります。
そこでSwiftUIはViewの処理だけをして、画面遷移の処理はUIKitに任せればいいじゃん、という発想になります。これがFlowControllerパターンの考え方です。
FlowController内ではUIKitで画面遷移を行います。
こうすることで、全てとは言いませんがある程度問題をクリアすることができます。
実装例
今回はMVVM + FlowControllerの実現方法について解説していきます。
登場人物はこんな感じです。
ファイル名 | 役割 |
---|---|
Screen | 画面のレイアウトを記述。SwiftUIで実装。 |
ViewModel | ScreenとFlowControllerの橋渡し。 Scrennで発生したイベントをFlowControllerに渡す。 |
FlowController | 画面遷移の処理を記述。 UIKitで実装。 |
FlowControllerは UIHostingView
を使って実装します。UIHostingView
はSwiftUIをUIKitで表示できるようにするものです。
ここからは簡単なアプリを作って、FlowControllerについて解説します。
全てのコードをここに載せることはできないので、詳しく見たい方はGitHubから見てください。
ボタンを押下して画面遷移する簡単なアプリを作っていきます。
まずは主要なコードを載せます。
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)
}
}
}
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
}
}
public protocol FlowController: UIViewController {
func start()
}
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()
}
}
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から遷移する画面の記述します。
extension FirstViewModel {
enum Navigation {
case second
}
}
FlowControllerではこのNavigationの値を受け取って、どこの画面に遷移するかを判別します。
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を継承しているので、画面遷移処理で直接指定することができます。
private func startSecond() {
let second = SecondFlowController(
rootView: SecondScreen(
viewModel: SecondViewModel()
)
)
self.present(second, animated: true) // モーダル遷移
second.start()
}
【ステップアップ】NavigationViewを使ったFlowControllerパターン
ではここからはNavigationViewを使ったFlowControllerパターンを紹介します。
NavigationControllerを使って画面遷移するには、UINavigationController
を継承したクラスを用意する必要があります。
こちらも詳しいコードはGitHubをご覧ください。
final class RootContainerFlowController: UINavigationController, FlowController {
func start() {
let first = FirstFlowController(
rootView: FirstScreen(
viewModel: FirstViewModel()
)
)
setViewControllers([first], animated: true)
first.start()
}
}
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
を継承したクラスを用意します。
final class RootContainerFlowController: UINavigationController, FlowController {
func start() {
let first = FirstFlowController(
rootView: FirstScreen(
viewModel: FirstViewModel()
)
)
setViewControllers([first], animated: true)
first.start()
}
}
このクラス自体は画面遷移の処理というよりは、Navigation下の最初の画面を定義します。こうすることでその画面からの遷移はNavigationで遷移することができます。
コメント