設計原則
状態駆動
状態を変更するだけで画面が変わる
// 状態を変更するだけで画面が変わる
path.append(.detail(id)) // push
modal = .likeSend // modal 表示
Path は画面名ではなく意味を表す
// ❌ 画面名
case detailView
// ⭕ 意味
case itemDetail(id: Item.ID)
文脈構造
各タブが独立した NavigationStack を持つ例
TabView {
// 各タブが独自の NavigationStack を持つ
HomeRootView() // 内部に NavigationStack
SettingsRootView() // 内部に NavigationStack
}
// タブ切り替え時、選択されていないタブの
// NavigationStack は「存在するが非アクティブ」
Feature境界
Feature 単位で Path を定義する例
// Feature 単位で Path を定義
enum HomePath: Hashable {
case itemDetail(Item.ID)
}
enum SettingsPath: Hashable {
case detail(String)
}
状態分離
RootView の @State で push と modal の状態を分離する例
struct FeatureRootView: View {
@State private var path: [FeaturePath] = [] // push 用
@State private var modal: FeatureModal? // modal 用
}
責務分離
View はクロージャで意図を表明し、RootView が遷移を決定する
// View はクロージャで意図を表明
struct UserDetailView: View {
var onShowDetail: ((Item.ID) -> Void)?
Button("詳細を見る") {
onShowDetail?(id) // 「詳細を見たい」という意図
}
}
// RootView が遷移方法を決定
Feature 固有 View と再利用可能 View の対比
// Feature 固有 View: クロージャで遷移トリガーを受け取る
struct UserDetailView: View {
var onShowPhotoList: (() -> Void)?
var onShowLikeSend: (() -> Void)?
// RootView からクロージャ経由で遷移を委譲される
}
// 再利用可能 View: コールバックで意図を委譲する
struct UserPhotoListView: View {
var onSelectPhoto: (Photo.ID) -> Void
// Feature に依存しないため、他の Feature でも使える
}
Modal を開いた状態を nil に戻して閉じる
// Modal を開いた state(Modal?)を nil に戻す
func dismissModal() {
modal = nil
}
結果伝達
Event を上位に委譲して Feature 間を連携する例
enum SettingsEvent {
case showProfilePreview
}
func handle(_ event: SettingsEvent) {
switch event {
case .showProfilePreview:
currentModal = .profilePreview
}
}
具体的手段
Feature 単位で Path を定義する例
enum HomePath: Hashable {
case itemDetail(Item.ID)
}
enum SettingsPath: Hashable {
case detail(String)
}
Feature の RootView に NavigationStack を配置する例
struct HomeRootView: View {
@State private var path: [HomePath] = []
var body: some View {
NavigationStack(path: $path) {
HomeView()
}
}
}
Modal を Identifiable な enum で定義する例
enum MainTabModal: Identifiable {
case profilePreview
case web(URL)
var id: String {
switch self {
case .profilePreview: return "profilePreview"
case .web(let url): return "web-\(url.absoluteString)"
}
}
}
item: ベースで Modal を制御する例
.sheet(item: $modal) { modal in
switch modal {
case .likeSend:
LikeSendRootView(
user: viewModel.user,
onEvent: { event in /* ... */ }
)
}
}
Modal 用の Feature RootView の例
struct OnboardingRootView: View {
@State private var path: [OnboardingPath] = []
var body: some View {
NavigationStack(path: $path) {
OnboardingStartView()
}
}
}
Event を上位に委譲する例
enum SettingsEvent {
case showProfilePreview
}
func handle(_ event: SettingsEvent) {
switch event {
case .showProfilePreview:
currentModal = .profilePreview
}
}
Environment + ViewModifier でスワイプ dismiss を宣言的に制御する例
// Coordinator が SwipeDismissalInteractor を生成し Environment で注入
func showUserDetail(user: User) {
let dismissalInteractor = SwipeDismissalInteractor()
let detailRootView = UserDetailRootView(
viewModel: UserDetailViewModel(user: user),
onEvent: { [weak self] event in self?.handle(event) }
)
.environment(\.swipeDismissalInteractor, dismissalInteractor)
// ...
}
// Feature 内では .swipeDismissable で宣言的に制御
NavigationStack(path: $path) {
UserDetailView(/* ... */)
}
.swipeDismissable(isAtRoot: path.isEmpty)
推奨: 型安全な [Path]
// ✅ 推奨: 型安全な [Path]
@State private var path: [HomePath] = []
非推奨: 型消去された NavigationPath
// ❌ 使用しない: 型消去された NavigationPath
@State private var path = NavigationPath()
MainTabCoordinator(UIKit)の例
@MainActor
final class MainTabCoordinator {
private let window: UIWindow
private var tabBarController: MainTabBarController?
var currentModal: MainTabModal?
func start() {
let tabBarController = MainTabBarController(coordinator: self)
self.tabBarController = tabBarController
window.rootViewController = tabBarController
window.makeKeyAndVisible()
}
func handle(_ event: SettingsEvent) {
switch event {
case .showProfilePreview:
presentProfilePreview()
case .openHome:
tabBarController?.selectedIndex = 0
}
}
}
MainTabBarController(UIKit)の例
final class MainTabBarController: UITabBarController {
private weak var coordinator: MainTabCoordinator?
private func setupTabs() {
// Home Tab (UIKit ベースのグリッド)
let homeNavController = UINavigationController()
homeNavController.tabBarItem = UITabBarItem(
title: "ホーム",
image: UIImage(systemName: "heart.circle"),
tag: 0
)
coordinator?.setupUserGridCoordinator(
navigationController: homeNavController
)
// Settings Tab (SwiftUI ベース)
let settingsRootView = SettingsRootView(onEvent: { [weak self] event in
self?.coordinator?.handle(event)
})
let settingsVC = UIHostingController(rootView: settingsRootView)
settingsVC.tabBarItem = UITabBarItem(
title: "設定",
image: UIImage(systemName: "gear"),
tag: 1
)
viewControllers = [homeNavController, settingsVC]
}
}
パターン A: SwiftUI Feature 内で UIKit 画面を modal 表示
// UINavigationController を SwiftUI でラップ
struct LegacyProfileNavigationControllerRepresentable:
UIViewControllerRepresentable
{
let rootViewController: UIViewController
func makeUIViewController(context: Context) -> UINavigationController {
UINavigationController(rootViewController: rootViewController)
}
func updateUIViewController(
_ uiViewController: UINavigationController,
context: Context
) {}
}
// sheet で表示
.sheet(item: $modal) { modal in
switch modal {
case .legacyProfile:
LegacyProfileModalView(
user: viewModel.user,
onDismiss: { self.modal = nil }
)
}
}
パターン B: UIKit 画面から SwiftUI Feature を modal 表示
final class SomeViewController: UIViewController {
private func presentSwiftUIFeature() {
let swiftUIView = SettingsRootView(onEvent: { [weak self] event in
self?.handleSettingsEvent(event)
})
let hostingController = UIHostingController(rootView: swiftUIView)
hostingController.modalPresentationStyle = .fullScreen
present(hostingController, animated: true)
}
}
パターン C: UIKit 画面の一部を SwiftUI で構築
final class HybridViewController: UIViewController {
private var hostingController: UIHostingController<SomeSwiftUIView>?
private func setupSwiftUIView() {
let swiftUIView = SomeSwiftUIView(
onTap: { [weak self] in self?.handleTap() }
)
let hosting = UIHostingController(rootView: swiftUIView)
addChild(hosting)
view.addSubview(hosting.view)
hosting.didMove(toParent: self)
hosting.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hosting.view.topAnchor.constraint(
equalTo: view.safeAreaLayoutGuide.topAnchor
),
hosting.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hosting.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hosting.view.heightAnchor.constraint(equalToConstant: 200),
])
hostingController = hosting
}
}
RootView で @State を管理しクロージャで子 View に渡す例
struct UserDetailRootView: View {
@State private var path: [UserDetailPath] = []
@State private var modal: UserDetailModal?
@State private var viewModel: UserDetailViewModel
var body: some View {
NavigationStack(path: $path) {
UserDetailView(
viewModel: viewModel,
onShowPhotoList: {
path.append(.photoList(viewModel.user.id))
},
onShowLikeSend: {
modal = .likeSend
}
)
}
}
}
子 View がクロージャで遷移トリガーを受け取る例
struct UserDetailView: View {
let viewModel: UserDetailViewModel
var onShowPhotoList: (() -> Void)?
var onShowLikeSend: (() -> Void)?
var body: some View {
Button("写真一覧") { onShowPhotoList?() }
Button("いいね!") { onShowLikeSend?() }
}
}
再利用可能 View: コールバックで意図を委譲する
struct UserPhotoListView: View {
let photos: [Photo]
var onSelectPhoto: (Photo.ID) -> Void
var body: some View {
List(photos) { photo in
Button {
onSelectPhoto(photo.id)
} label: {
PhotoRow(photo: photo)
}
}
}
}
RootView でコールバックと @State を接続する
.navigationDestination(for: UserDetailPath.self) { destination in
switch destination {
case .photoList(let userId):
UserPhotoListView(
photos: viewModel.photos,
onSelectPhoto: { photoId in
// コールバックを受けて @State で遷移
path.append(.photoDetail(photoId))
}
)
case .photoDetail(let photoId):
UserPhotoDetailView(photoId: photoId)
}
}
画面パターン別マトリクス
各画面パターンに適用される設計原則の一覧です。
| パターン | S1 | S2 | C1 | C2 | F1 | F2 | P1 | R1 | R2 | E1 |
|---|---|---|---|---|---|---|---|---|---|---|
| 全画面共通 | ✓ | ✓ | ||||||||
| NavigationStack (push) | ✓ | ✓ | ✓ | ✓ | ✓ | |||||
| Modal 表示 | ✓ | ✓ | ✓ | ✓ | ✓ | |||||
| Feature 内遷移 | ✓ | |||||||||
| Feature 間遷移 | ✓ | ✓ | ||||||||
| View 実装 | ✓ | ✓ | ||||||||
| RootView 設計 | ✓ | ✓ |