SwiftUI Evaluation and Rendering

Some Concepts

Description of UI

When you write this code:

VStack {
    Text("Hello, World!")
        .font(.title)
    Image(systemName: "globe")
}

You are not calling drawing functions. You are creating instances of VStack, Text, and Image structs.

These structs are extremely lightweight value types that hold the data for your UI (the text to display, the font, the name of the image, etc.).

This tree of structs is the "description".

Because these descriptions are so cheap to create, SwiftUI can simply throw away the old one and create a brand new one from scratch whenever the state changes (by re-evaluating the body).

It then compares the new blueprint to the old one (diffing) and efficiently figures out the minimum amount of work needed to update the actual pixels on the screen.

Evaluation Diffing Rendering

To be slightly more precise, to update a swiftUI view, a three-step process happens very quickly:

Pass Whole Instance of ObservableObject

ObservableObject @Published @StateObject @ObservedObject

ObservableObject @Published @StateObject .environmentObject @EnvironmentObject

Comparison

Problems

A view using @ObservedObject or @EnvironmentObject will re-evaluate its body whenever any @Published property on the observed object changes, even if the view doesn't use that specific property.

A minimum working example for @EnvironmentObject:

import SwiftUI
import Combine

// 1. THE SHARED STATE: Two independent properties.
class AppState: ObservableObject {
    @Published var username: String = "Alex"
    @Published var score: Int = 100
}

// 2. THE CHILD VIEW: Only depends on 'username'.
//    We will watch the print statement in this view.
struct UsernameDisplayView: View {
    @EnvironmentObject var appState: AppState

    var body: some View {
        // This print statement is the key to our experiment.
        // It will only run if this view's body is re-evaluated.
        let _ = Self._printChanges()

        return Text("User: \(appState.username)")
            .font(.title)
            .padding()
            .background(Color.blue)
            .foregroundColor(.white)
            .cornerRadius(10)
    }
}


struct ContentView: View {

    @StateObject private var appState = AppState()

    var body: some View {
        let _ = Self._printChanges()

        return VStack(spacing: 30) {
            Text("This button changes a property that the child view does NOT use.")
                .multilineTextAlignment(.center)
                .padding(.horizontal)

            Button("Increase Score to \(appState.score + 10)") {
                // This changes 'score', which UsernameDisplayView does not read.
                appState.score += 10
            }
            .font(.title2)

            Divider()

            UsernameDisplayView()
        }
        .padding()
        .environmentObject(appState) // Inject the state for the child to use.
    }
}

Solutions

We have two solutions. Both solutions achieve property-level dependency tracking.

Pass in Properties

There are two ways to explicitly pass in properties from parents to children.

If your view needs to read the data and also modify it, pass it as a @Binding. This is a two-way data flow.

If your view only needs to read a piece of data to display itself, pass it as a let constant. This is a one-way data flow, from parent to child.

Pass Properties of App State with $ @Binding

Instead of passing the whole object, the parent view can pass bindings ($) to the individual properties the child view needs. The child view then declares these properties with @Binding.

This makes the child view dependent only on the value of those specific properties, not on the object as a whole. It will only re-evaluate when the bound properties change.

class UserProfile: ObservableObject {
    @Published var username = "Alex"
    @Published var score = 0
    @Published var isOnline = false
}

// The Parent owns the object but only passes what's needed.
struct ProfileView: View {
    @StateObject private var profile = UserProfile()

    var body: some View {
        VStack {
            Text("Welcome, \(profile.username)")
            // This button changes 'score', which will NOT cause
            // UsernameEditorView to re-render.
            Button("Increase Score: \(profile.score)") {
                profile.score += 1
            }

            // Pass a binding ONLY to the username.
            UsernameEditorView(username: $profile.username)
        }
    }
}

// The Child only knows about the 'username' binding.
// It will not re-render when 'score' or 'isOnline' changes.
struct UsernameEditorView: View {
    @Binding var username: String

    var body: some View {
        // This view only depends on the username string.
        TextField("Username", text: $username)
            .textFieldStyle(.roundedBorder)
            .padding()
    }
}

Pass @State Properties with $ @Binding

Here's a common scenario: a ParentView holds the state for a toggle, and a reusable ChildView contains the actual Toggle control.

Characteristics of Binding

Pass let Constants

Pros and Cons

Environment Values

The observation framework uses @Environment, so we introduce environment values first.

Example (Older)

Example (Modern)

The @Entry macro replaces a significant amount of boilerplate code.

The Old Way Before @Entry:

// 1. Define the key
private struct ThemeColorKey: EnvironmentKey {
    static let defaultValue: Color = .blue
}

// 2. Extend EnvironmentValues
extension EnvironmentValues {
    var themeColor: Color {
        get { self[ThemeColorKey.self] }
        set { self[ThemeColorKey.self] = newValue }
    }
}

The New Way With @Entry:

extension EnvironmentValues {
    @Entry var themeColor: Color = .blue
}

The macro condenses all of that into a single, declarative line, which is much cleaner and less error-prone. This is the new, recommended way to create custom environment values.

Re-evaluation of Views (Example)

// =============================================================================
import SwiftUI

// Define the custom environment key
extension EnvironmentValues {
    @Entry var themeColor: Color = .gray // A default value
}

// Define the AppState as the source of truth
class AppState: ObservableObject {
    @Published var brandColor: Color = .purple
}

// =============================================================================
struct ContentView: View {
    // 1. Create and own the AppState instance.
    @StateObject private var appState = AppState()

    var body: some View {
        VStack(spacing: 30) {
            Text("Parent View")
                .font(.headline)

            // 4. This button changes the source of truth.
            Button("Change Theme to Orange") {
                appState.brandColor = .orange
            }

            Divider()

            // This child will inherit the environment value.
            ChildView()
        }
        // 2. Inject the brandColor property into the environment.
        //    SwiftUI creates a dependency here.
        .environment(\.themeColor, appState.brandColor)
        .padding()
    }
}

// =============================================================================
struct ChildView: View {
    // 3. Read the themeColor from the environment.
    //    This creates a subscription to that value.
    @Environment(\.themeColor) var color

    var body: some View {
        Text("Hello from the Child View!")
            .font(.largeTitle)
            .padding()
            .background(color) // Use the color from the environment
            .foregroundColor(.white)
            .cornerRadius(10)
    }
}

Here's the step-by-step process of what happens when the property in your AppState changes:

The Observation Framework

Make Model Data Observable

To make data changes visible to SwiftUI, apply the Observable() macro to your data model.

This macro generates code that adds observation support to your data model at compile time, keeping your data model code focused on the properties that store data.

For example, the following code defines a data model for books:

@Observable class Book: Identifiable {
    var title = "Sample Book Title"
    var author = Author()
    var isAvailable = true
}

Create Source of Truth

To create and store the source of truth for model data, declare a private variable and initialize it with a instance of an observable data model type.

Then wrap it with a State property wrapper.

For example, the following code stores an instance of the data model type Book in the state variable book:

struct BookView: View {
    @State private var book = Book()

    var body: some View {
        Text(book.title)
    }
}

By wrapping the book with State, you’re telling SwiftUI to manage the storage of the instance. Each time SwiftUI re-creates BookView, it connects the book variable to the managed instance, providing the view a single source of truth for the model data.


You can also create a state object in your top-level App instance or in one of your app’s Scene instances. For example, the following code creates an instance of Library in the app’s top-level structure:

@main
struct BookReaderApp: App {
    @State private var library = Library()

    var body: some Scene {
        WindowGroup {
            LibraryView()
                .environment(library)
        }
    }
}

Share Model Data throughout Hierarchy

If you have a data model object, like Library, that you want to share throughout your app, you can either:

Pass Object

Passing model data to each view is convenient when you have a shallow view hierarchy; for example, when a view doesn’t share the object with its subviews.

However, you usually don’t know if a view needs to pass the object to subviews, and you may not know if a subview deep inside the layers of the hierarchy needs the model data.

A minimum example:

import SwiftUI

// --- The Model ---
// This is the source of truth.
@Observable
class UserSettings {
    var username: String = "Guest"
}

// --- The Child View ---
// It receives the model as a simple 'let' constant.
// It has no ability to change the model.
struct ChildView: View {
    let settings: UserSettings

    var body: some View {
        // It just reads the property and displays it.
        Text("Child View: Hello, \(settings.username)")
            .font(.caption)
            .foregroundColor(.secondary)
    }
}

// --- The Parent View ---
struct ContentView: View {
    // The parent view owns the model using @State.
    @State private var settings = UserSettings()

    var body: some View {
        VStack(spacing: 20) {
            // The parent reads the property.
            Text("Parent View: Hello, \(settings.username)")
                .font(.largeTitle)

            // We pass the entire model object to the child.
            ChildView(settings: settings)

            // This button in the parent changes the state.
            Button("Log In as 'Alex'") {
                settings.username = "Alex"
            }
        }
        .padding()
    }
}

Add Object to Environment

To share model data throughout a view hierarchy without needing to pass it to each view, add the model data to the view’s environment.

You can add the data to the environment using either environment(_:_:) or the environment(_:) modifier, passing in the model data.

You can also store model data directly in the environment without defining a custom environment value by using the environment(_:) modifier.

For instance, the following code adds a Library instance to the environment using this modifier:

@main
struct BookReaderApp: App {
    @State private var library = Library()

    var body: some Scene {
        WindowGroup {
            LibraryView()
                .environment(library)
        }
    }
}

To retrieve the instance from the environment, another view defines a local variable to store the instance and wraps it with the Environment property wrapper.

But instead of providing a key path to the environment value, you can provide the model data type, as shown in the following code:

struct LibraryView: View {
    @Environment(Library.self) private var library

    var body: some View {
        // ...
    }
}

Change Model Data in a View

In most apps, people can change data that the app presents. When data changes, any views that display the data should update to reflect the changed data.

Change Data without Bindings

With Observation in SwiftUI, a view can support data changes without using property wrappers or bindings.

For example, the following toggles the isAvailable property of a book in the action closure of a button:

struct BookView: View {
    var book: Book

    var body: some View {
        List {
            Text(book.title)
            HStack {
                Text(book.isAvailable ? "Available for checkout" : "Waiting for return")
                Spacer()
                Button(book.isAvailable ? "Check out" : "Return") {
                    book.isAvailable.toggle()
                }
            }
        }
    }
}

Change Data with Bindable

However, there may be times when a view expects a binding before it can change the value of a mutable property.

To provide a binding, wrap the model data with the Bindable property wrapper.

For example, the following code wraps the book variable with @Bindable. Then it uses a TextField to change the title property of a book, and a Toggle to change the isAvailable property, using the $ syntax to pass a binding to each property.

In this example, we need @Bindable to create bindings because TextField and Toggle expect bindings as arguments.

struct BookEditView: View {
    @Bindable var book: Book
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        VStack() {
            HStack {
                Text("Title")
                TextField("Title", text: $book.title)
                    .textFieldStyle(.roundedBorder)
                    .onSubmit {
                        dismiss()
                    }
            }

            Toggle(isOn: $book.isAvailable) {
                Text("Book is available")
            }

            Button("Close") {
                dismiss()
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}

Pros and Cons

Evaluation and Diffing Key Points

Of course. Here is the complete, combined, and detailed step-by-step explanation of the re-evaluation process when pdfFiles changes, assuming the refactor to the generic PaneView is complete.

Step 1: The Data Source (AppState) It all begins with your data source. AppState is an ObservableObject, and its pdfFiles property is marked with @Published. When you open or close a PDF, the pdfFiles array is modified. The @Published property wrapper automatically detects this change and sends out a notification to any views that are observing AppState.

Step 2: The Top-Level Listener (ContentView) Your ContentView is listening for these notifications because it declares @EnvironmentObject var appState: AppState. When it receives the notification that AppState has changed, SwiftUI knows that ContentView's UI might be out of date. It "invalidates" ContentView and calls its body property to get the new, updated version of the view hierarchy.

Step 3: Rebuilding AppLayout and its Content The execution of ContentView.body begins. The top-level view inside the body is AppLayout. SwiftUI starts to create a new instance of AppLayout and executes its @ViewBuilder content closure to generate its four child panes. We are interested in the second pane, the one for displaying PDFs.

Step 4: Constructing the New PaneView Inside the AppLayout content closure, ContentView now directly constructs the PaneView for the PDF section.

  1. It calls the PaneView initializer.
  2. It executes the @ViewBuilder closure for the topContent parameter. Inside this closure, it checks if !appState.pdfFiles.isEmpty and creates a new instance of PDFTabBar, passing it the updated appState.pdfFiles array.
  3. It executes the @ViewBuilder closure for the mainContent parameter. Inside this closure, it checks if let currentFile = appState.currentPDFFile and creates a new instance of PDFViewerView (or a placeholder view if no file is selected).

Crucially, ContentView is now directly responsible for creating and configuring these specific views using the latest data from AppState.

Step 5: AppLayout's Body Re-evaluation AppLayout is a generic view that holds its four child panes as properties (e.g., primaryPane). SwiftUI compares the new set of panes being passed in from ContentView with the set from the previous render. It detects that the view in the second slot is a new and different PaneView instance.

Because its content has changed, SwiftUI calls the body of AppLayout. The AppLayout.body re-runs its layout logic (the HStack and GeometryReader) to correctly arrange all its children, including the new PaneView.

Step 6: PaneView's Body Evaluation When AppLayout.body renders its primaryPane property, it is now rendering the new PaneView instance. SwiftUI then calls the body of this PaneView. Since PaneView is a simple layout container, its body is just a VStack that renders the topContent (PDFTabBar) and mainContent (PDFViewerView) that it was given.

Step 7: The Final UI Update (PDFViewerView) The new PDFViewerView instance is now part of the view hierarchy that will be rendered to the screen.

  1. PDFViewerView conforms to the Equatable protocol. SwiftUI compares the new PDFViewerView instance with the one from the previous render using your custom == function.
  2. Since its parameters (like the document) have changed due to the pdfFiles update, the == function returns false.
  3. This false result signals to SwiftUI that the underlying platform view (NSView) that this struct represents must be updated.
  4. SwiftUI calls the updateNSView(_:context:) method on your PDFViewerView.
  5. Finally, the code inside updateNSView (e.g., pdfView.document = self.document) executes. This is the command that actually updates the PDFKit view, causing the new PDF to be rendered on screen for the user to see.

This entire cascade, from the data change to the final view update, happens automatically as part of the SwiftUI data flow mechanism.

References

https://medium.com/airbnb-engineering/understanding-and-improving-swiftui-performance-37b77ac61896

https://medium.com/@tungvt.it.01/avoiding-unnecessary-view-re-renders-in-swiftui-47c2ecdd1fb1

https://developer.apple.com/documentation/SwiftUI/Managing-model-data-in-your-app