Skip to content
ViewGraph Alpha
← All articles
SwiftUI Architecture

Why every SwiftUI app is structured differently — and how to visualize it

In UIKit there was a shape you could almost assume. SwiftUI hands you composition and navigation primitives, then gets out of the way. The result is that every team invents its own architecture — which is exactly why a structural view is useful.

SwiftUI ships primitives, not structure

Ask ten iOS teams to diagram their app and you'll get ten different diagrams. Some navigate directly from each screen. Some route everything through a coordinator object. Some organise by feature folder and never introduce a router at all. SwiftUI doesn't pick for you, and it doesn't record your choice in a way a new teammate can read.

The four patterns below cover most SwiftUI apps I've opened. They aren't mutually exclusive — a real app often mixes two or three — but naming them makes the differences visible.

Pattern 1 — Direct navigation

The screen owns its own destinations. It's the shortest path from "user taps" to "new screen appears," and it's where most projects start.

struct LibraryScreen: View {
    @State private var selectedBook: Book?

    var body: some View {
        NavigationStack {
            List(books) { book in
                Button(book.title) { selectedBook = book }
            }
            .navigationTitle("Library")
            .navigationDestination(item: $selectedBook) { book in
                BookDetailScreen(book: book)
            }
        }
    }
}

The connection LibraryScreen → BookDetailScreen is written in the view, in code, with a literal destination. That's the easy case for a tool: it can be read statically.

Pattern 2 — Router / coordinator

Once more than a couple of screens need to trigger navigation, teams often move it behind an object. The screen no longer knows what comes next; it asks the router.

@Observable
final class AppRouter {
    var path = NavigationPath()
    func show(_ route: Route) { path.append(route) }
}

struct RootView: View {
    @State private var router = AppRouter()

    var body: some View {
        NavigationStack(path: $router.path) {
            HomeScreen()
                .navigationDestination(for: Route.self) { route in
                    switch route {
                    case .profile(let id): ProfileScreen(id: id)
                    case .settings:        SettingsScreen()
                    }
                }
        }
        .environment(router)
    }
}

This is better engineering in many ways, and harder to read from the outside. The destination isn't next to the trigger; it lives in one switch, and the trigger is a call to router.show(.settings) somewhere else entirely. The structure exists, but it's spread across the router, the route enum, and every call site.

Pattern 3 — Feature modules

As an app grows, the strongest signal is often the folder tree:

App/
├── Features/
│   ├── Library/
│   │   ├── LibraryScreen.swift
│   │   └── LibraryViewModel.swift
│   ├── Reader/
│   │   ├── ReaderScreen.swift
│   │   └── ReaderToolbar.swift
│   └── Settings/
│       └── SettingsScreen.swift
└── Shared/
    ├── Components/
    └── DesignSystem/

Folders communicate intent to humans, but they're invisible to the type system: a LibraryScreen and a ReaderScreen don't "know" they're in different features. Nothing stops Reader from importing Library's internals — and over time, something will. When a component moves between sections, the folder tree updates but the reuse relationships don't.

Pattern 4 — Reusable composition

Underneath navigation, every SwiftUI app is a composition tree. This is where the real structure of a screen lives:

struct DashboardScreen: View {
    @State private var viewModel = DashboardViewModel()

    var body: some View {
        ScrollView {
            VStack(spacing: 16) {
                HeaderView(title: viewModel.title)
                MetricGrid(metrics: viewModel.metrics)
                RecentActivityList(items: viewModel.recent)
            }
            .padding()
        }
        .task { await viewModel.load() }
    }
}

HeaderView, MetricGrid and RecentActivityList are the interesting parts of this screen. Replace one and you change every screen that uses it. Which is exactly the question source browsing is bad at answering: where else is HeaderView used?

The shape underneath all four

Regardless of which pattern a project chose, there is always the same underlying shape: screens reachable from one another through navigation, and components composed inside screens, some of them reused across many screens. Widgets are their own entry points. ViewGraph draws that shape directly.

It classifies nodes as screens, components or widgets; lays screens out by navigation level; draws connections for pushes, sheets and full-screen covers; and draws reuse edges between a component and the screens that use it. Sections are grouped so a feature folder still has a visible boundary even though the type system doesn't enforce one.

ViewGraph — Overview
ViewGraph's Overview showing feature sections such as Dashboard Screen, Editor and Widget Screen, each with a component count, plus a preview-coverage summary.
Real ViewGraph output for a project organised into feature sections. Each section shows its component count, and preview coverage is reported per category — including what is missing.

The useful part is that this view is the same whether the app used direct navigation, a router, or a mix. You stop needing to know the pattern before you can read the app.

Static analysis vs. runtime navigation

This is the part I want to be scrupulous about, because it's where a structural tool can quietly mislead you. ViewGraph reads Swift source with swift-syntax. It sees what it can prove from the code, and it reports the rest as unresolved instead of inventing a connection.

What it can reliably infer

  • Literal navigation: a NavigationLink, a .sheet or .fullScreenCover whose content is a concrete view.
  • Typed destinations: a navigationDestination(for:) with a route type whose cases resolve to concrete screens.
  • Composition: a view's body referencing other view types, including across files.
  • The app root and widget entry points, from the app and widget declarations.

What it cannot reliably infer

  • Data-driven destinations. If the path is built from a decoded deep link or a server response, the destination isn't in the source to read.
  • String-based routing. A switch over a route string, or a registry keyed by name, hides the real destination from static analysis.
  • Abstractions that erase types. A coordinator that hands navigation to a protocol, or returns AnyView chosen at runtime, loses the concrete edge.
  • Navigation outside SwiftUI. A UIHostingController pushed by a UIKit navigation controller isn't part of the SwiftUI graph.
  • Conditional branches. Destinations behind a feature flag or a runtime check may resolve to several outputs, and the analyzer treats divergent branches conservatively.
Read the unresolved count. When the canvas says "analysis complete — N unresolved relationships," that N is the honest size of what it couldn't prove. Treat it as a to-do list for manual exploration, not an error.

The practical consequence is a division of labour: ViewGraph gives you the parts of the structure that are written down, and you spend your runtime exploration on the parts that aren't. It doesn't claim to know how your app behaves — only how it's built.

Draw your own app

Point ViewGraph at a SwiftUI project and it classifies screens, components and widgets, then lays out the connections it can prove — locally, on your Mac.