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.
To be slightly more precise, to update a swiftUI view, a three-step process happens very quickly:
-
Evaluation
-
When will evaluation happen
When a state dependency changes, SwiftUI evaluates the view.
-
View's body is called
This means it calls the
bodyproperty of your view to get a new, lightweight description of the UI. -
Equatablecan prevent thisThis is the step that performance optimizations like
Equatablecan prevent.
-
-
Diffing
After evaluating the view, SwiftUI compares the new description with the old one.
It then calculates the most efficient way to change the actual user interface on screen.
-
Rendering
The final step of creating, modifying, and drawing the pixels is what is most accurately called rendering.
-
Rendering process cannot be explicitly controlled
In a pure SwiftUI approach, the final rendering process—the act of drawing pixels on the screen—is a private implementation detail of the framework.
SwiftUI's rendering engine decides the most efficient way to update the pixels on screen.
It cannot be explicitly controlled by the programmer. You cannot force a standard
TextorVStackto "redraw itself now."
-
-
ObservableObject(The Blueprint)ObservableObjectis a protocol that you apply to aclass. By conforming to this protocol, you are telling SwiftUI, "This object contains data that might change, and views should be able to subscribe to those changes."It's the blueprint for any custom data model that you want to share across multiple views.
import Combine // This class is now a blueprint for an observable data model. class UserSettings: ObservableObject { // ... properties will go here ... }
-
@Published(The Announcer) 📣@Publishedis a property wrapper you use inside anObservableObjectclass. You place it before any property that you want to trigger a view update when its value changes.When a
@Publishedproperty is about to change, it automatically "publishes" or "announces" this change to any views that are observing the object.class UserSettings: ObservableObject { // When 'username' changes, it will announce the change. @Published var username: String = "Anonymous" }
-
@StateObject(The Owner) 👑@StateObjectis a property wrapper you use in a view to create and own an instance of anObservableObject.The key word here is own. The view that declares a property with
@StateObjectis responsible for keeping that object alive. SwiftUI ensures that the object is created only once for the lifetime of that view and is not destroyed if the view re-renders.Use
@StateObjectwhen a view is the "source of truth" for an object.struct MainView: View { // This view creates and OWNS the UserSettings object. // It will stay alive as long as MainView is on screen. @StateObject private var settings = UserSettings() var body: some View { TextField("Username", text: $settings.username) } }
-
@ObservedObject(The Follower) 👀@ObservedObjectis a property wrapper you use in a subview to watch or subscribe to anObservableObjectthat it receives from a parent view.A view with an
@ObservedObjectdoes not own the object. It's just observing an object that was created and is owned by someone else (usually a parent view with@StateObject). If the parent view is destroyed, this object will be too.Use
@ObservedObjectwhen a view needs to access and react to changes in a shared object that it doesn't own. -
A Complete Example
This example shows the full data flow:
import SwiftUI // 1. THE BLUEPRINT: A class that can be observed. class UserProfile: ObservableObject { // 2. THE ANNOUNCER: This property will trigger updates. @Published var name = "Taylor" } // 3. THE OWNER: This view creates and owns the UserProfile. struct RootView: View { // It's the source of truth for the 'profile' object. @StateObject private var profile = UserProfile() var body: some View { VStack { Text("In RootView, name is: \(profile.name)") // It passes the object down to the subview. DetailView(profile: profile) } } } // 4. THE FOLLOWER: This view watches the object it was given. struct DetailView: View { // It does NOT own the 'profile' object, it just observes it. @ObservedObject var profile: UserProfile var body: some View { // When 'profile.name' changes, this view will re-render. TextField("Enter name", text: $profile.name) .textFieldStyle(.roundedBorder) .padding() } }
-
Providing the Object (
.environmentObject)You call this modifier on a parent view.
The object you pass in (here,
appState) must conform to theObservableObjectprotocol. This is crucial because it allows SwiftUI to watch the object for changes and automatically update any views that depend on it.This makes the object available to the view it's attached to and any view contained within it, no matter how deeply nested.
-
Receiving the Object (
@EnvironmentObject)In any child view that needs access to the data, you declare a property using the
@EnvironmentObjectproperty wrapper. -
Example
class AppSettings: ObservableObject { @Published var fontSize = 14.0 } // Ancestor View creates the object and injects it into the environment struct AncestorView: View { @StateObject private var settings = AppSettings() var body: some View { IntermediateView() .environmentObject(settings) // Inject 'settings' for all children } } // An intermediate view that DOES NOT need to know about the object struct IntermediateView: View { var body: some View { DeeplyNestedChildView() // No need to pass 'settings' here } } // The deeply nested child can now access the object directly struct DeeplyNestedChildView: View { @EnvironmentObject var settings: AppSettings // Swift finds it in the environment var body: some View { Text("Font size from the environment is: \(settings.fontSize)") } }
-
Method 1: Explicit Passing (using
@ObservedObject) 🔗A parent view creates the object with
@StateObjectand passes it directly into the child view's initializer. The child view then catches it with@ObservedObject.This is best when: You have a simple parent-to-child relationship and want the dependency to be very clear.
The downside to this is "prop drilling," where you have to pass the object through many intermediate views that don't even use it, just to get it to a deeply nested child.
-
Method 2: Using the Environment (with
@EnvironmentObject) 📡This is the more scalable solution. An ancestor view can place an object into the environment, and any descendant view in that hierarchy can access it directly without it being passed down through initializers.
This is best when: An object needs to be accessed by many different views at various levels of the view hierarchy (e.g., theme settings, user authentication status).
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.
}
}We have two solutions. Both solutions achieve property-level dependency tracking.
- Only explicitly pass in specific properties
- Use the Observation framework
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.
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()
}
}Here's a common scenario: a ParentView holds the state for a toggle, and a reusable ChildView contains the actual Toggle control.
-
The Parent View (with
@State)The
ContentViewowns the actual state and passes a binding down to the child.import SwiftUI // This parent view owns the source of truth. struct ContentView: View { // The @State property is the single source of truth. @State private var isEnabled = false var body: some View { VStack { if isEnabled { Text("The feature is ON") .foregroundColor(.green) } else { Text("The feature is OFF") .foregroundColor(.red) } // The parent passes a binding to the child using the '$' sign. ToggleView(isFeatureEnabled: $isEnabled) } .padding() } }
When you tap the toggle inside
ToggleView, it uses its binding ($isFeatureEnabled) to change theisEnabledproperty inContentView. This causesContentViewto re-evaluate, updating theTextview. -
The Child View (with
@Binding)The
ToggleViewdoesn't know or care where the boolean comes from; it just knows it has a two-way binding to one.import SwiftUI // This child view receives a binding. It does not own the state. struct ToggleView: View { // This creates a two-way connection to a Bool from a parent view. @Binding var isFeatureEnabled: Bool var body: some View { // The Toggle control uses the binding directly. // When the user taps this, it changes the parent's @State. Toggle("Enable Feature", isOn: $isFeatureEnabled) } }
-
@Bindingshould be used together with$. -
Binding is two-way
-
Parent to Child (Child Read): The child view reads the value from the parent's source of truth (the @State or @StateObject property). If the value changes in the parent, the child's view updates automatically to reflect that change.
-
Child to Parent (Child Write): The child view can modify the value (e.g., through a Toggle or TextField). That change is immediately sent back up to the parent's source of truth, updating the original property.
-
-
Why Use
@Binding?- Single Source of Truth: It prevents you from having duplicate, out-of-sync copies of the same data.
- Component Reusability: It allows you to create small, reusable child views (like
ToggleView) that can operate on data from any parent view. - Decoupling: The child view doesn't need to know anything about the parent view, only about the data type it's binding to.
-
Pros
-
Maximum Reusability
This is the biggest advantage. The view is completely decoupled from your app's specific data models. You can drop a view into any project and it will work.
-
Clear API & Dependencies
It's immediately obvious what data the view needs to function. The contract is explicit.
-
Predictable Performance
The view only re-evaluates when its direct inputs change.
-
-
Cons
-
Prop Drilling
If a deeply nested child view needs a piece of data, that data must be passed down through every intermediate view in the hierarchy, even if they don't use it. This can become cumbersome.
-
Boilerplate
It can lead to more code, as you need to explicitly pass every required property.
-
The observation framework uses @Environment, so we introduce environment values first.
-
Define a
EnvironmentKeyFirst, you create a new struct that conforms to the
EnvironmentKeyprotocol. This struct has one requirement: you must define adefaultValue. This is the value that will be used if no other value is provided for this key.import SwiftUI // Define a new key for a custom accent color. struct CustomAccentColorKey: EnvironmentKey { // Provide a default value that will be used if none is set. static let defaultValue: Color = .blue }
-
Create a Convenience Extension on
EnvironmentValues(Optional)This step is technically optional, but it's a strongly recommended convention that makes your code much cleaner. You extend the
EnvironmentValuesstructure to create a computed property for your new key. This allows you to access it with a simple key path like\.customAccentColor.extension EnvironmentValues { // Create a computed property to get and set the value. var customAccentColor: Color { get { self[CustomAccentColorKey.self] } set { self[CustomAccentColorKey.self] = newValue } } }
Without this, you'd have to use the clunky syntax
environment(\.self[CustomAccentColorKey.self])every time. -
Provide a Value in the View Hierarchy
Now you can use the
.environment()view modifier to inject your custom value into a view and all of its children. Any descendant view can now read this value.struct ParentView: View { var body: some View { VStack { Text("Parent View") ChildView() } // Provide the value for our custom key to this view and its children. .environment(\.customAccentColor, .purple) } }
-
Read the Value in a Child View
Finally, any child view within that hierarchy can use the
@Environmentproperty wrapper to read the value. It uses the same key path you defined in your extension.struct ChildView: View { // Read the value from the environment using its key path. @Environment(\.customAccentColor) var accentColor var body: some View { Text("This is the Child View") // The accentColor will be .purple because the parent provided it. .foregroundColor(accentColor) } } // If a view is created outside that hierarchy, it will get the default value. struct AnotherView: View { @Environment(\.customAccentColor) var accentColor var body: some View { Text("Another View") // The accentColor here will be .blue (the default value). .foregroundColor(accentColor) } }
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.
// =============================================================================
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 Source of Truth Changes
Your
AppStateobject, which is anObservableObject, has a property marked with@Published. When you change this property's value, it "announces" or "publishes" that a change has occurred. -
.environmentRe-evaluationThe view
ContentViewwhere you used the.environment(\.themeColor, appState.brandColor)modifier is subscribed to yourAppStateobject.When it receives the announcement of
appStateproperty change, whether it'sbrandColorchange or other property change, it executes all the code inside itsbody. As itsbodycode runs, SwiftUI creates new instances ofText,Button,Divider,ChildView.As part of
body, the.environment(\.themeColor, appState.brandColor)modifier is also executed. -
SwiftUI Re-rendering for children views
SwiftUI asks ""Is the value for
\.themeColoryou're providing now different from the one I had before?".If no, it does nothing. Child views that depend on
\.themeColorare not re-rendered (which includes re-running children view'sbody).If yes, it updates the environment value and re-renders any child views that depend on it.
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
}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)
}
}
}If you have a data model object, like Library, that you want to share throughout your app, you can either:
- pass the data model object to each view in the view hierarchy; or
- add the data model object to the view’s environment
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()
}
}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 {
// ...
}
}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.
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()
}
}
}
}
}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
-
Scalability & Convenience
It completely eliminates "prop drilling." Any view in the hierarchy can access the shared state without it being passed down manually.
-
Less Boilerplate
You pass one object instead of many individual properties.
-
Centralized Logic
It encourages centralizing related state and logic within a single model object.
-
-
Cons
-
Lower Reusability
The view is now tightly coupled to your specific data model (
AppSettings). You can't easily reuseProfileHeaderViewin another app that doesn't have anAppSettingsobject. -
Implicit Dependency ("Magic")
The dependency is hidden. It's not immediately obvious from the view's call site that it relies on
AppSettings. Forgetting to inject it with.environment()will cause a runtime crash.
-
-
Only views'
bodycan be evaluatedNote that only views'
bodycan be evaluated.The body of
@mainstruct is for defining app's scenes (likeWindowGroup) and is not re-evaluated on state changes in the same way a view is. -
Under what circumstances will a view be updated (evaluated)
-
When a view's direct state dependencies changes, its body will 100% be re-evaluated.
A view can have
@StateObject@EnvironmentObject,@ObservedObject,@Binding@State@Environmentproperties as dependencies.@StateObjectdefines an instance ofObservableObjectas dependency. If any@Publishedproperty of anObservableObjectinstance changes, the current view will be re-evaluated.@EnvironmentObjectand@ObservedObjectdeclare an entire instance ofObservableObjectas dependency. If any@Publishedproperty of anObservableObjectinstance changes, views which subscribe to this instance with@EnvironmentObjector@ObservedObjectwill be re-evaluated.@Bindingis a two-way connection to a state owned by other views. If this state changes, our current view will be re-evaluated.@Stateis owned and managed privately by a view. If this state changes, our current view will be re-evaluated.If a environment value denoted by
@Environmentchanges, our current view will be re-evaluated. -
When a parent view is re-evaluated, its child views might be re-evaluated.
-
-
What happens when a parent view is re-evaluated
Here is the sequence of events:
-
Parent View Updates
A state change causes a parent view's
bodyto be re-evaluated. -
A New View Description is Created
As the parent's
bodycode runs, it creates a new instance of your child view struct. Let's call thisnewChild. -
SwiftUI Intervenes
Before SwiftUI calls
newChild.body, it looks at the child view from the previous update cycle. Let's call thatoldChild. -
Diffing Process
SwiftUI now asks a critical question: "Does this view's type conform to
Equatable?"-
If YES, SwiftUI does not immediately evaluate the
body. Instead, it performs one more step.Your Custom Logic is Called: SwiftUI calls your custom
==function, effectively asking: isoldChild == newChild?-
If your
==function returnstrue(meaning, according to your logic, nothing important has changed), SwiftUI says, "Great, no changes here."It stops the update process for this view and all of its children. The
bodyis never called. -
If your
==function returnsfalse, SwiftUI says, "Okay, something meaningful has changed," and only then does it proceed to evaluate thebodyofnewChild.
-
-
If NO, SwiftUI falls back to a default member-wise comparison of its properties.
-
If view only contain
EquatablepropertiesIf your view only contains properties whose types already conform to
Equatable(likeString,Int,Bool,UUID, or an Array of anEquatabletype), then the default behavior works perfectly.struct MyView: View { let name: String let score: Int }
In this case, SwiftUI will compare
lhs.name == rhs.nameandlhs.score == rhs.score. The view will only update if one of these values actually changes. You get the performance optimization for free.@Bindingproperties show the same behavior.This is why you generally don't need to write a custom
Equatableconformance for views that only contain simpleletproperties and@Bindings. SwiftUI's default behavior is already optimal. -
If view contain non-
EquatablepropertiesIf your view contains closure properies, SwiftUI will also compare them.
Since closures are not equatable, and the parent view creates a new closure instance on every update, SwiftUI has no way to know if they are "the same." It conservatively assumes they are different.
Because one of its properties is considered different, the entire view is considered to have changed, and its body is re-evaluated.
-
@State@Environmentand@EnvironmentObjectproperties are ignoredNote that the purpose of the comparison is to answer the question: "Have the explicit inputs from the parent changed?" So
@Stateproperties,@Environmentproperties, and@EnvironmentObjectproperties are completely ignored in comparison.SwiftUI does technically compare the new empty struct with the old empty struct. This comparison is instantaneous and effectively does nothing.
-
@ObservedObjectproperties are also comparedWhen the parent view re-evaluates, it creates a new ChildView, passing in the same instance of
ObservableObject.SwiftUI compares the old and new ChildView. Since the instance hasn't changed (it's pointing to the same object in memory), the member-wise comparison will return
true.
-
-
-
-
What happens if multiple views have the same changed dependencies
When a state dependency changes, SwiftUI identifies every view that subscribes to it and schedules them all for an update.
However, the exact order in which their body properties are called—whether it's parent-first, child-first, or even in parallel for independent views—is an internal implementation detail of the framework.
While it is not guaranteed, it is reasonable to assume that SwiftUI performs a top-down traversal of the view hierarchy.
This means a parent view (like
ContentView) is typically evaluated before its child views (likeMarkdownTabBar).However, you must treat this as an observation, not a guarantee.
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.
- It calls the PaneView initializer.
- 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. - 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.
- PDFViewerView conforms to the Equatable protocol. SwiftUI compares the new PDFViewerView instance with the one from the previous render using your custom == function.
- Since its parameters (like the document) have changed due to the pdfFiles update, the == function returns false.
- This false result signals to SwiftUI that the underlying platform view (NSView) that this struct represents must be updated.
- SwiftUI calls the updateNSView(_:context:) method on your PDFViewerView.
- 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.
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