一般社団法人 全国個人事業主支援協会

COLUMN コラム

  • SwiftUIのナビゲーション設計:NavigationStackの実践パターン

NavigationStackが解決する課題

iOS 16で導入されたNavigationStackは、従来のNavigationViewが抱えていた問題を根本から解決するために設計されたコンポーネントです。NavigationViewでは画面遷移の状態管理がビューの階層に暗黙的に紐づいており、プログラマティックなナビゲーション制御が困難でした。ディープリンクへの対応や、特定の画面へのジャンプ、ナビゲーション履歴のリセットといった操作が煩雑になりがちだったのです。

NavigationStackは、ナビゲーションの状態を明示的なデータ(パス)として管理することで、これらの課題を解決します。筆者が実プロジェクトでNavigationViewからの移行を行った経験をもとに、実践的なパターンを紹介します。

基本的なNavigationStackの構築

NavigationStackの基本は、NavigationPathとnavigationDestinationの組み合わせです。

struct ContentView: View {
@State private var path = NavigationPath()

var body: some View {
NavigationStack(path: $path) {
List {
NavigationLink("ユーザー一覧", value: Route.userList)
NavigationLink("設定", value: Route.settings)
}
.navigationTitle("ホーム")
.navigationDestination(for: Route.self) { route in
switch route {
case .userList:
UserListView(path: $path)
case .settings:
SettingsView()
case .userDetail(let userId):
UserDetailView(userId: userId, path: $path)
case .editProfile(let userId):
EditProfileView(userId: userId)
}
}
}
}
}

enum Route: Hashable {
case userList
case settings
case userDetail(userId: String)
case editProfile(userId: String)
}

Route enumでアプリ内のすべての遷移先を型安全に定義できます。これにより、存在しない画面への遷移をコンパイル時に防止できます。

Routerパターンによる責務分離

画面数が増えると、navigationDestinationの分岐が肥大化します。Routerクラスを導入してナビゲーションロジックを集約するのが効果的です。

@Observable
class Router {
var path = NavigationPath()

func navigate(to route: Route) {
path.append(route)
}

func navigateBack() {
guard !path.isEmpty else { return }
path.removeLast()
}

func navigateToRoot() {
path.removeLast(path.count)
}

func navigateToUserDetail(userId: String) {
path.append(Route.userDetail(userId: userId))
}

func replaceCurrentWith(_ route: Route) {
if !path.isEmpty {
path.removeLast()
}
path.append(route)
}
}

// アプリのルートで注入
struct AppView: View {
@State private var router = Router()

var body: some View {
NavigationStack(path: $router.path) {
HomeView()
.navigationDestination(for: Route.self) { route in
RouteView(route: route)
}
}
.environment(router)
}
}

RouterをEnvironmentに注入することで、どの階層のビューからでもナビゲーション操作が可能になります。

ディープリンクへの対応

NavigationStackの最大の利点のひとつが、ディープリンクの実装が格段に簡単になることです。URLをRouteに変換し、パスに追加するだけで任意の画面に遷移できます。

extension Router {
func handleDeepLink(_ url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let host = components.host else { return }

// ルートにリセットしてから遷移
navigateToRoot()

switch host {
case "user":
if let userId = components.queryItems?.first(where: { $0.name == "id" })?.value {
navigate(to: .userList)
navigate(to: .userDetail(userId: userId))
}
case "settings":
navigate(to: .settings)
default:
break
}
}
}

パスにRoute.userListとRoute.userDetailを順番に追加することで、ユーザー一覧→ユーザー詳細という正しいスタック構造を構築できます。これにより、戻るボタンを押したときに自然なナビゲーション体験が提供されます。

タブとの組み合わせ

実際のアプリケーションでは、TabViewとNavigationStackを組み合わせるケースが大半です。各タブが独立したナビゲーションスタックを持つ構成が一般的です。

struct MainTabView: View {
@State private var selectedTab = Tab.home
@State private var homeRouter = Router()
@State private var searchRouter = Router()
@State private var profileRouter = Router()

var body: some View {
TabView(selection: $selectedTab) {
Tab("ホーム", systemImage: "house", value: .home) {
NavigationStack(path: $homeRouter.path) {
HomeView()
.navigationDestination(for: Route.self) { route in
RouteView(route: route)
}
}
.environment(homeRouter)
}
Tab("検索", systemImage: "magnifyingglass", value: .search) {
NavigationStack(path: $searchRouter.path) {
SearchView()
.navigationDestination(for: Route.self) { route in
RouteView(route: route)
}
}
.environment(searchRouter)
}
Tab("プロフィール", systemImage: "person", value: .profile) {
NavigationStack(path: $profileRouter.path) {
ProfileView()
.navigationDestination(for: Route.self) { route in
RouteView(route: route)
}
}
.environment(profileRouter)
}
}
}
}

enum Tab {
case home, search, profile
}

タブごとにRouterを分けることで、タブを切り替えてもナビゲーション状態が保持されます。また、同じタブアイコンを再タップした際にルートに戻る動作も、対応するRouterのnavigateToRootを呼ぶだけで実現できます。

状態の保存と復元

NavigationPathはCodableに準拠しているため、アプリの終了時にナビゲーション状態を保存し、次回起動時に復元することが可能です。ただし、パスに含まれるRouteもCodableに準拠させる必要があります。

enum Route: Hashable, Codable {
case userList
case settings
case userDetail(userId: String)
case editProfile(userId: String)
}

extension Router {
func savePath() {
guard let data = try? JSONEncoder().encode(path.codable) else { return }
UserDefaults.standard.set(data, forKey: "savedNavigationPath")
}

func restorePath() {
guard let data = UserDefaults.standard.data(forKey: "savedNavigationPath"),
let decoded = try? JSONDecoder().decode(
NavigationPath.CodableRepresentation.self, from: data
) else { return }
path = NavigationPath(decoded)
}
}

移行のポイントと注意事項

NavigationViewからNavigationStackへの移行にあたって、いくつかの注意点があります。まず、NavigationStackはiOS 16以降でのみ利用可能です。iOS 15以前のサポートが必要な場合は、条件分岐での対応が必要になります。

また、NavigationPathは型消去されたコンテナであるため、デバッグ時にスタックの中身を確認しにくいという点があります。開発中はNavigationPath代わりに具体的な型の配列を使い、リリースビルドでNavigationPathに切り替えるという手法も有効です。

NavigationStackへの移行は、アプリのナビゲーション設計を見直す良い機会です。最初に画面遷移の全体像をRoute enumで整理し、Routerパターンで責務を分離することで、保守性の高いナビゲーション基盤を構築できます。

この記事をシェアする

  • Twitterでシェア
  • Facebookでシェア
  • LINEでシェア