Back to Home
SwiftUI Fundamentals: How It Works Behind the Scenes

SwiftUI Fundamentals: How It Works Behind the Scenes

SwiftUI, Apple's declarative framework for building user interfaces, has revolutionized the way developers create apps for iOS, macOS, watchOS, and tvOS. By allowing developers to describe the UI using Swift code, SwiftUI offers a more intuitive and less error-prone approach compared to the traditional UIKit. But what exactly happens behind the scenes to make this magic work? In this article, we'll peel back the layers and explore the core principles that underpin SwiftUI.

The Declarative Syntax

At the heart of SwiftUI is its declarative syntax. Unlike imperative programming, where you describe step-by-step how to achieve a result, declarative programming allows you to describe what you want the UI to look like, and SwiftUI handles the rest. For instance, in SwiftUI, you might write:

Text("Hello, World!")
    .font(.largeTitle)
    .padding()

This code describes a text view with larget font and padding, without specifying how to create, configure, or update that view. SwiftUI uses this declarative syntax to build a hierarchy of views, which it then renders to the screen.

So how can it render views?

Most of the time we don't need to care about that. However, behind the scenes, SwiftUI relies on a sophisticated rendering engine using your instructions to render. For iOS, these instructions are translated into UIKit components, which are then rendered to the screen. This translation layer allows SwiftUI to leverage the performance and capabilities of the underlying platform while providing a more modern and expressive API. SwiftUI has something called the attribute graph, which includes more than just the rendered views; it also contains the state and tracks dependencies.

How can SwiftUI understand what to render?

When you describe your UI in SwiftUI, the framework constructs a tree of views. This view tree is then transformed into a series of platform-specific instructions before delivered to the rendering engine. We leverage many things in the View protocol such as View Builder, View Modifier, ... to construct the views that we want to display. When we use declarative syntax to describe that, the framework understands it and constructs a tree of views. This hierarchical structure allows SwiftUI to understand the relationships between different UI elements. One important thing to keep in mind is the tree of view definition is just a struct and will be thrown away after use. Therefore, it can be created over and over again when needed. But this is not enough, SwiftUI also needs to know when and why it should update the views. In order to do so, there are some terms called Identity, Lifetime and Dependency. We will cover them now.

The essential elements of SwiftUI

Identity

SwiftUI uses identity to efficiently manage and render views by recognizing whether elements are the same or distinct across state changes. This is achieved through the Identifiable protocol and the id(_:) modifier, which assign unique identifiers to views. By leveraging these identities, SwiftUI can perform efficient diffing to determine which views have changed, been added, or removed, minimizing unnecessary updates. This mechanism is crucial for dynamic views like lists, where items can be added or removed, ensuring smooth animations, efficient rendering, and a responsive user interface.

view-identity.png

Take an example of animation of the view above. When SwiftUI understand that view is actually the same view, it can smoothly transition the view with animation across multiple positions from top to bottom, instead of remove then insert another view, causing UI flick.

Kinds of identity

In SwiftUI, there are two types of identity: explicit and implicit (also known as structural identity). Explicit identity is assigned using the Identifiable protocol or the id(_:) modifier, providing a unique identifier for each view. Implicit identity, or structural identity, is derived from the view's position in the hierarchy and its type. This allows SwiftUI to efficiently track and update views.

structural-id.png

In contrast, UIKit relies on explicit identity through pointers, where each view is uniquely identified by its memory address

pointer-id.png

As a rule of thumb, we usually want to keep our view identity as stable as we can. Not only does this ensure smooth transitions, but it also helps in preserving states, which is known as Lifetime (a topic we will discuss later)

Moreover, using a type eraser such as AnyView will hide the view structure from the compiler. While type erasers can be useful for certain scenarios, they can potentially impact performance and optimizations, as the compiler loses information about the specific types of views being used. This loss of type information can hinder SwiftUI's ability to efficiently manage and update the view hierarchy.

One of the scenarios when it's tempting to use AnyView is when we need to return different types of views from a single function or property:

anyview-usage.png

With the help of @ViewBuilder, we can simplify the logic and avoid using AnyView:

avoid-anyview.png

Lifetime

Lifetime helps the framework to track the existence of views and data over time. By understanding the lifecycle of a view, SwiftUI can manage resources more effectively, ensuring that views are created, updated, and destroyed at the appropriate times. This tracking is crucial for maintaining state consistency and ensuring smooth transitions. Proper management of view lifetimes helps in preserving the state and optimizing performance, as the framework can make informed decisions about when and how to update the UI.

view-value.png

In other words, the same view with same identity can have many states S1, S2, ..., Sn called view value during the time T1, T2_, ..., Tn. We call this the duration of the identity. When we defining view property wrapper such as @State or @StateObject inside views, we leave SwiftUI to manage them for us. SwiftUI relies on the lifetime to determine whether to preserve that value or destroy it and create a new one, which can lead to data loss if not managed correctly. With that in mind, we can conclude that the State lifetime is the View lifetime

Dependencies

Dependencies help SwiftUI understand when and why to update views. When these dependencies change, they trigger the reevaluation of the body property, resulting in a new view hierarchy. This mechanism ensures that the UI remains in sync with the underlying data, providing a responsive and dynamic user experience. By leveraging dependencies like @State, @Binding, and @ObservedObject, SwiftUI can efficiently manage and update views based on the current state and data changes.

dependencies.png

When an action triggered, the data as a dependency changes, causing the views be updated to reflect that. This can be described as a data flow in SwiftUI.

data-flow.png

The data flow in SwiftUI is designed to be unidirectional, ensuring a clear and predictable path for state changes. At the core of this system are property wrappers like @State, @Binding, and @ObservedObject, which manage the state within views. Data flows from parent views to child views, with state and bindings act as a source of truth ensuring that any changes propagate through the view hierarchy. When a state changes, SwiftUI automatically triggers a re-evaluation of the affected views' body properties, updating the UI accordingly. This unidirectional data flow simplifies state management, reduces the likelihood of bugs, and enhances the maintainability of SwiftUI applications.

On iOS 17, things will get simpler and more straightforward with the help of Observation framework by replacing ObservableObject in your data model type and so on. But we will have a separate article for that.

Conclusion

SwiftUI relies on identity, both explicit and implicit, to recognize and manage views, ensuring smooth transitions and state preservation. The concept of lifetime helps SwiftUI track the existence of views and data, making informed decisions about when to retain or recreate state. Dependencies such as @State, @Binding, and @ObservableObject play a crucial role in triggering view updates and maintaining a synchronized UI. While type erasers like AnyView can simplify code, they can also impact performance by hiding view structures from the compiler. The unidirectional data flow in SwiftUI ensures a clear and predictable path for state changes, reducing bugs and enhancing maintainability.

By understanding how SwiftUI works behind the scenes, developers can leverage its capabilities to build efficient and responsive user interfaces for their SwiftUI applications, ensuring better performance and user experience.