はじめに
記事を読んでほしい対象の方
SwiftUIでライブラリを使わずに...
- 画面遷移のコードをViewから分離 させたい方
-
UIKitのように画面遷移したい けど, せっかくなので
UIKit
は使いたくない方 - アーキテクチャ に悩んでいる方
結論
ちょっと残念ですが現時点では UIKit
を使わないままで理想に近づけることはほぼ不可能だと思われます。
いくつか海外の記事も見て回りましたが、同様の質問をAppleに投げた人が見つかったものの、 Appleからの正式な回答 を持ってしても要件としてはまだ不十分でした。
参考 Stack Overflow
そこで本記事では、 UIKit
のクラスを使うコードを1ファイルのみに限定し、その中身は編集せずに固定、それ以外は全て純粋な SwiftUI
で書く方法を紹介します。
固定ファイル
まずはこの固定コードをそのまま丸々コピーして、プロジェクトのどこかにおいてください。 Coordinator.swift
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
ここをクリックしてCoordinator.swift をみる
// MARK: - Coordinator
import SwiftUI
typealias Coordinator = Navigator & ViewProvider
class Navigator {
var navigation: Navigation = UINavigation()
}
protocol ViewProvider {
associatedtype ViewType: View
func view() -> ViewType
}
protocol Navigation {
func push<C: Coordinator>(_ coordinator: C, animated: Bool)
func set(_ coordinators: [AnyCoordinator], animated: Bool)
func pop(animated: Bool)
func popToRoot(animated: Bool)
}
class DefaultCoordinator: Coordinator {
let content: ViewType
init(@ViewBuilder content: () -> ViewType) {
self.content = content()
}
func view() -> AnyView {
self.content
}
}
extension ViewProvider {
func anyCoordinator<V: View>(_ view: V) -> AnyCoordinator {
return AnyCoordinator(DefaultCoordinator {AnyView(view)} )
}
}
class AnyCoordinator : Coordinator {
init<C: Coordinator>(_ base: C) {
_view = { AnyView(base.view()) }
}
init<V: View>(_ view: V) {
_view = { AnyView(view) }
}
func view() -> AnyView {
_view()
}
private let _view: () -> ViewType
}
// MARK: - Wrapped UIKit
import UIKit
struct Window<C: Coordinator>: UIViewControllerRepresentable {
var coordinator: C
init(coordinator: C) {
self.coordinator = coordinator
let hosting = UIHostingController(rootView: self.coordinator.view())
(self.coordinator.navigation as! UINavigation).controller = UINavigationController(rootViewController: hosting)
}
// UIViewControllerRepresentable
typealias UIViewControllerType = UINavigationController
func makeUIViewController(context: UIViewControllerRepresentableContext<Window>) -> Window.UIViewControllerType {
return (self.coordinator.navigation as! UINavigation).controller!
}
func updateUIViewController(_ uiViewController: Window.UIViewControllerType, context: UIViewControllerRepresentableContext<Window>) {
}
}
private class UINavigation: Navigation {
var controller: UINavigationController?
func push<C: Coordinator>(_ coordinator: C, animated: Bool) {
let hosting = UIHostingController(rootView: coordinator.view())
(coordinator.navigation as! UINavigation).controller = self.controller
self.controller?.pushViewController(hosting, animated: animated)
}
func set(_ coordinators: [AnyCoordinator], animated: Bool) {
for c in coordinators {
(c.navigation as! UINavigation).controller = self.controller
}
self.controller?.setViewControllers(coordinators.map {UIHostingController(rootView: $0.view())}, animated: animated)
}
func pop(animated: Bool) {
self.controller?.popViewController(animated: animated)
}
func popToRoot(animated: Bool) {
self.controller?.popToRootViewController(animated: animated)
}
}
>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
使用コード例
iOS界で有名な Coordinator(コーディネーター) と言うパターンで遷移を書きます。
今回は簡単なサンプルなので、 ビューモデル に Coordinator の役割もさせちゃいます。
ビューA
Up ボタンでカウント数を増やす。 Go ボタンを押すと、カウント数を渡しながら ビューB
へ進む。
import SwiftUI
/// ビュー A
struct ViewA: View {
/// ビューモデル は @StateObject を付ける
@StateObject var vm: ViewModelA
var body: some View {
// テキスト
Text("A counts: \(vm.count)")
// ボタン
Button(action: vm.up) { Text("Up") }
// ボタン
Button(action: vm.go) { Text("Go") }
}
}
/// ビューモデル A
class ViewModelA: Coordinator, ObservableObject {
// @Published この値と一緒に ビュー A の表示が変わる
@Published var count: Int = 0
// カウントを 1 増やす
func up() {
count += 1
}
// カウントを渡して B へ遷移 (Push)
func go() {
self.navigation.push(
ViewModelB(count),
animated: true
)
}
// Coordinator に必要なメソッド
func view() -> ViewA {
return ViewA(vm: self)
}
}
ビューB
ビューA
からカウント数を受け取って表示する。
import SwiftUI
/// ビュー B
struct ViewB: View {
/// ビューモデル は @StateObject を付ける
@StateObject var vm: ViewModelB
var body: some View {
// テキスト
Text("B counts: \(vm.count)")
}
}
/// ビューモデル B
class ViewModelB: Coordinator, ObservableObject {
let count: Int
init(_ count: Int) { self.count = count }
// Coordinator に必要なメソッド
func view() -> ViewB {
return ViewB(vm: self)
}
}
ビューC
ビューモデル
を持たないミニマムな ビュー
あとのサンプルで使います。
import SwiftUI
/// ビュー C
struct ViewC: View {
var body: some View {
Text("C")
}
}
実行
xxxApp.swift(アプリによってxxxは異なる)
WindowGroup の中に
Window(coordinator: ViewModelA())
を書いておきましょう。
import SwiftUI
@main
struct xxxApp: App {
var body: some Scene {
WindowGroup {
Window(coordinator: ViewModelA()) // ここを書き換えた。
}
}
}
無事にやりたかったことができました。
今回のコードでは他にも以下のような遷移メソッドを提供しています。
サンプルは ViewA
の go()
の中を変えてみてください。
サンプル
-
push
: 今の画面へ戻って来られるように状態を保ったまま次の画面へ進む
ここをクリックして サンプル1 をみる
...
self.navigation.push(
ViewModelB(count),
animated: true
)
...
-
set
: いくつかの画面を繋がった状態で用意して、その一番最後の画面を表示する
ここをクリックして サンプル2 をみる
...
// set を使うときは AnyCoordinator で囲んで型消去をする
self.navigation.set(
[
AnyCoordinator(ViewModelB(1)),
AnyCoordinator(ViewC()),
AnyCoordinator(ViewModelB(2)),
AnyCoordinator(ViewC()),
AnyCoordinator(ViewModelB(3)),
],
animated: true)
...
-
pop
: 今いる画面を削除しながら、前の画面に戻る
ここをクリックして サンプル3 をみる
...
self.navigation.pop(animated: true)
...
-
popToRoot
: 一番最初の画面(ルート)まで戻る。
ここをクリックして サンプル4 をみる
...
self.navigation.popToRoot(animated: true)
...
さいごに
長い記事になってしまいました。最後まで読んでいただきありがとうございます。
また機能の追加とか修正ができれば、記事を更新しようかと思います。
それでは、良いSwiftUIライフを!