Mastering SwiftUI: Are You Really as Good as You Think?

Vladyslav Shkodych
16 min readMay 11, 2024

--

How deep do you think you know what SwiftUI is and how it works? Have you ever wondered why each native SwitUI View struct conforms to the Equatable protocol? Why are they structs? How often is the initializer or body variable called during the building process? Are both called every time? Why is your View lagging? And much more…

So many questions and so few answers. Let’s dive into it together and learn how SwiftUI makes its magic.

First things first, the Basics:

Views are structs, why? Well, that’s an easy one. During a lifetime, SwiftUI reconstructs views many times (that’s how it is designed), and it’s “easy” to do so with structs because of the benefits they provide, such as:

  • Being lightweight
  • Having a fixed memory size
  • Being placed in the stack
  • Having static/direct dispatch (no inheritance overhead)

Of course, all of this depends on how exactly you build the structure, but in an ideal world or case, structs have these advantages.

When and Why is SwiftUI reconstructing views? Reconstruction starts when the State changes — something in the view hierarchy or dependent/observed parameters have changed. Real quick example:

import SwiftUI

struct ContentView: View {

@State private var counter: Int = 99

var body: some View {
VStack {
Text("Counter: \(counter)")
Button {
counter += 1
} label: {
Text("Counter +1")
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}

@available(iOS 17, *)
#Preview {
ContentView()
}

In this example, when you tap on the Counter +1 button, the counter value changes and SwiftUI starts a reconstruction process because this property is marked as @State. Without using it here, you can’t trigger the SwiftUI update mechanism. You will encounter errors from Xcode such as: “Left side of mutating operator isn’t mutable: ‘self’ is immutable” and similar. This occurs because you’re trying to mutate a property counter within a non-mutating context, the body property of a view is computed and cannot mutate properties directly, and so on. But the key is:

“SwiftUI Views are a function of state. When we modify a state property, the view reconfigures with the updated state. We don’t modify our views directly. This is the essence of declarative programming.” (WWDC 2019)

What else can trigger SwiftUI to update a view? Well:

  • @Binding
  • @StateObject
  • @ObservedObject
  • @Environment
  • @EnvironmentObject
  • Models with the @Observable macro

Yeah, there are a bunch of them, each serving its purpose. They all work differently, but the key here is that they all trigger the SwiftUI update mechanism. Let me know in the comments if I missed anything.

Visualization

In the above example, we update the counter value and SwiftUI recomputes the body of the ContentView. We can see changes with the updated text on the screen, but what if that text won't be on the screen? Let's remove Text(“Counter: \(counter)”) from the body:

import SwiftUI

struct ContentView: View {

@State private var counter: Int = 99

var body: some View {
VStack {
// Text("Counter: \(counter)")
Button {
counter += 1
} label: {
Text("Counter +1")
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}

@available(iOS 17, *)
#Preview {
ContentView()
}

Is the View updated 🧐 ?! The short answer is — no! But how can we prove it?
There are 2 possible ways. I use both when creating Views in a regular job and for my pet projects.

  1. Printing changes
  2. Canvas visualization

1. Printing changes

Printing allows you to see what updates and what was called. Let’s add some more lines of code:

import SwiftUI

struct ContentView: View {

@State private var counter: Int = 99

init() {
print(Self.self, #function)
}

var body: some View {
let _ = Self._printChanges()
VStack {
// Text("Counter: \(counter)")
Button {
counter += 1
} label: {
Text("Counter +1")
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}

@available(iOS 17, *)
#Preview {
ContentView()
}

First, we add an initializer with a simple print of the type and function name. You can see the result in the console:

Next, we add let _ = Self._printChanges() in the body. This function is not a documented feature! One day, someone on WWDC, showed us this function by accident and many developers use it nowadays 😁. It prints all changes in a state for this view. (not the property @State but the view state)

Beware to use it, because, once again, it’s not official, it’s for Apple dev purpose and it can be removed from public in the future.

The result in the console:

2. Canvas visualization

There can be any visual changes to the view. I use my own library, you can use whatever you want.

More about my lib you can read in my article here or download from the direct link on github here. It can be injected into your project by Swift Package Manager as well as by CocoaPods. It can be used with iOS 14.0 and Mac OS 11.0 and higher.

When the .debugBackground() modifier from the lib is added — a randomly colored background will be shown for the view and each time when SwiftUI recomputes body — the color will change, here is what it looks like:

import SwiftUI

struct ContentView: View {

@State private var counter: Int = 99

init() {
print(Self.self, #function)
}

var body: some View {
let _ = Self._printChanges()
VStack {
Text("Counter: \(counter)")
Button {
counter += 1
} label: {
Text("Counter +1")
}
.buttonStyle(.borderedProminent)
}
.padding()
.debugBackground()
}
}

@available(iOS 17, *)
#Preview {
ContentView()
}

So, for view with Text the visual representation you can see above and the console logs here:

But for view without Text:

As I said previously the answer is — no, View is not updated, but why?!

“The framework compares the view and renders again only what is changed.” (WWDC 2019)

In our case counter definitely changes the state of the view, but let’s say for now that nothing is watching for updates, which is why SwiftUI does not recompute the body and does not re-render anything.

The thing is, that we often use ObservableObject models or even @Observable models instead of @State. And with them, SwiftUI reacts a bit differently.
Let’s add an ObservableObject model first:

import SwiftUI

final class ContentViewModel: ObservableObject {

@Published var counter: Int = 99
}

struct ContentView: View {

@StateObject private var model: ContentViewModel = ContentViewModel()

init() {
print(Self.self, #function)
}

var body: some View {
let _ = Self._printChanges()
VStack {
// Text("Counter: \(counter)")
Button {
model.counter += 1
} label: {
Text("Counter +1")
}
.buttonStyle(.borderedProminent)
}
.padding()
.debugBackground()
}
}

@available(iOS 17, *)
#Preview {
ContentView()
}

As you can see on updating the ObservableObject model parameter — SwiftUI recomputes the body and re-renders the view even without any observers for that property.

What about @Observable?! Well, take a look:

import SwiftUI

@Observable
final class ContentViewModel {

var counter: Int = 99 {
didSet {
print(counter)
}
}
}

struct ContentView: View {

var model: ContentViewModel

init(_ model: ContentViewModel) {
self.model = model
print(Self.self, #function)
}

var body: some View {
let _ = Self._printChanges()
VStack {
// Text("Counter: \(counter)")
Button {
model.counter += 1
} label: {
Text("Counter +1")
}
.buttonStyle(.borderedProminent)
}
.padding()
.debugBackground()
}
}

@available(iOS 17, *)
#Preview {
ContentView(ContentViewModel())
}

I also added a didSet closure for the counter to visualize changes in the console.

As you can see @Observable works similar to the @State and differently from the ObservableObject.

More about the difference between ObservableObject and the @Observable you can read from my article here.

So, what exactly happens when the SwiftUI is triggered to update a view, and when does this triggering happen? Before answering this tough question let’s look at a test example of the programming problem.

Task

Now, when we have “things” that help us observe changes how can we solve this:

Let’s imagine that we need to add a List to our view. The List with 100_000 items (or any other big amount of values). And that’s easy to build, but the view is lagging. Take a look:

import SwiftUI

@Observable
final class ContentViewModel {

var counter: Int = 99 {
didSet {
print(counter)
}
}

var values: [Int] = [Int](1...100_000)
}

struct ContentView: View {

var model: ContentViewModel

init(_ model: ContentViewModel) {
self.model = model
print(Self.self, #function)
}

var body: some View {
let _ = Self._printChanges()
VStack {
List {
ForEach(model.values, id: \.self) {
Text(String($0))
.padding()
.debugBackground()
}
}
.listStyle(.plain)
.frame(height: 200.0)
Text("Counter: \(model.counter)")
Button {
model.counter += 1
} label: {
Text("Counter +1")
}
.buttonStyle(.borderedProminent)
}
.padding()
.debugBackground()
}
}

@available(iOS 17, *)
#Preview {
ContentView(ContentViewModel())
}

As you can see by adding .debugBackground() to every Text cell we can observe updating each cell whenever we click on theCounter +1 button. And as you remember we have 100_000 values🤯. That causes lagging! To be more precise — lagging is caused by the List cell creation behavior. Whenever List is created, it has to create all its inside items first because it needs to know how many items are on the screen. By clicking on the button we change the view state, and SwiftUI detects the state change and starts rebuilding the view, and because of the large number of values — this view rebuilds long. And I do have some measurements:

10s! Really?! That’s a lot! We definitely need to switch List to the LazyVStack inside of the ScrollView. Yeah, that’s a possible way to solve this, but let’s say we have to use List here because of some other extra stuff. How else can we solve this? Now we can discuss what exactly happens when the SwiftUI is triggered to update.

SwiftUI under the hood

In the SwiftUI “world” we have some views and some states associated with those views. And you can see I’m saying — associated, not owned by those views like in UIKit “world”, because views don’t own the stateSwiftUI does!

In a moment when some state changes — we have new dependencies for the view, a new state. The first thing that we do or the SwiftUI does for us, is that it creates a new view with a new state and compares that to the existing state. I will show you later exactly how SwiftUI compares between states of views. For now, let’s say we compare them somehow. On Step 1 SwiftUI checks whether they are equal.

If they are, that’s it. SwiftUI throws the new view into the trash, and no more jobs will be done. It will send nothing to render.

If they’re not, SwiftUI calls the body of those views. This is Step 2. And checks whether the bodies are equal.

Note: The body is just creating the views that we put inside the body of the current view. So SwiftUI is creating more views, checking if those changed, and then comparing them, all the way down through the hierarchy deepest level.

Now, if they are the same, again — to the trash and no more actions. SwiftUI will keep the existing view on the screen.

Only if they’re different, SwiftUI grabs the new view, puts it on the view tree, sends it to render changes, and throws an old view to the trash.

Now you have an understanding of how the mechanism works, but what about comparing what I mentioned previously? First, let’s talk about performance, and what can impact performance in SwiftUI. This will help you understand why and where SwiftUI compares views.

Performance

Rule number 1: Keep your View body simple!

Avoid doing any complicated work inside your view body. For example, pass in the ForEach not a ready-to-go sequence, with filtering, mapping, etc.

// Avoid such things ...
var body: some View {
List {
ForEach(model.values.filter { $0 > 0 }, id: \.self) {
Text(String($0))
.padding()
.debugBackground()
}
}
}

The reason this is so important is that the system can actually call your body multiple times in the single layout phase. For example some combinations of modifiers and GeometryReader.

Rule number 2: Preferring no effect modifiers over conditional views.

Example:

// Avoid if possible ...
var body: some View {
VStack {
if isHighlighted {
CustomView()
.opacity(0.8)
} else {
CustomView()
}
// ...
}
}

It affects performance because of the view tree and the diffing between them. In this case, you have both CustomViews in the view tree (not in the UI hierarchy).

(lldb) po print(type(of: body))

VStack<_ConditionalContent<ModifiedContent<CustomView, _OpacityEffect>, CustomView>>

SwiftUI can’t animate between them, because it doesn’t have an idea that it’s actually the same thing, in your mind. It can only remove one and add the other.

So what you need to do, if you can due to the context, is something like this:

// Prefer ...
var body: some View {
VStack {
CustomView()
.opacity(isHighlighted ? 0.8 : 1.0)
// ...
}
}
(lldb) po print(type(of: body))

VStack<ModifiedContent<CustomView, _OpacityEffect>>

This is what’s called no effect modifiers.

“When you introduce a branch, pause for a second and consider whether you’re representing multiple views or two states of the same view.” (WWDC 2021)

Rule number 3: Split “states” parts into custom view types.

Here we have some big and complicated view and some very long body code. The body is using different pieces of the state of this view, but because it’s all in one body, it’s all in one view, whenever each of those variables changes, we will re-render at least this entire body, and it might be time-consuming. All this stuff inside the body is going to be diffed when anything changes in this view.

(lldb) po print(type(of: body))

VStack<_ConditionalContent<ModifiedContent<ModifiedContent<CustomView, ... >

And if you think that the next setup will save you, well sorry for ruining your dreams 😔, not going to help.

This is exactly the same as what you saw before, the compiler might even inline this.

The solution here is to move them into their own subviews.

It looks nearly the same, but if here you pause for a second and remember how the SwiftUI mechanism works you will notice a huge performance boost. That’s right when you call the body of the big view, now with the three subviews, you’re not calling the bodies of those views, you’re only creating them. As I explained above — in Step 1, when we create them, the first thing we do is compare the state! If the state is the same we won’t call the bodies of those views. With this, we have a diff which is much faster, for example, if we change the blue variable we don’t even bother with yellow and white.

Now, the body of the view is much smaller and simpler, much easier for SwiftUI to diff.

(lldb) po print(type(of: body))

TupleView<TopView, MiddleView, BottomView>

Comparing

Yes, finally we reached the Comparing section 💪.
Before we start, I have to warn you:

The information down below is a kind of mosaic of knowledge glued all together from some tweets from random Apple developers on Twitter, some forums, threads, articals, etc, there is no official documentation about it.

I will show you an underscore function which you don’t want to use in production, Apple will not like this 🙂.

And any of this may change in the future, so be careful.

When we compare views, there are three methods that SwiftUI is using to compare between different views.

  1. memcmp(memory compare) — is a C function that compares the actual memory (bytes) between the two structures, a new and old state, whether they changed or not. This is the fastest way to compare the states of views.
  2. Equality is a little bit slower, but it’s still okay. And it only happens when your view conforms to the Equatable protocol.
  3. Reflection is the slowest and fallback default way to compare.

So, if you have performance problems, try to update your view so that SwiftUI can use quicker comparing.

Now I want to bring a new term here: Plain Old Data (POD). It’s a structure that is an aggregate type that contains only POD members. You can think about it like a container for simple variables.

This is the POD structure:

struct ContentView: View {

var counter: Int
let isHighlighted: Bool

// ...

And this is Non-POD:

struct ContentView: View {

@StateObject private var counter: ContentViewModel
@State var isHighlighted: Bool

// ...

It’s a bit unclear if your view is POD, so to know this, you can use an underscore runtime function:

print(_isPOD(ContentView.self))

This function will return you a Bool. Generally, a structure with only value types inside can be POD, but beware, the String type in Swift is called a value type but only has a value semantic. Under the hood, the String type uses a copy-on-write mechanism. So like closures and other ref. type, Strings are also ref. types under the hood. According to this — structs with Strings parameters, that pass from outside or stored directly — are Non-POD. To check this you can call _isPOD(T.self) for such structs, and the return value will be — false, while the structs with only POD parameters, such as Int, Float, etc. will return — true. But to be 100% sure you can call _isPOD(T.self) and check if your view is POD.

Why is that so important? The thing is:

  • POD View always uses “memcmp” unless it’s Equatable and never uses “Reflection”.
  • Non-POD View always uses “Reflection” unless it’s Equatable and never uses “memcmp”.

So, try to do your best to achieve the best time in comparing views you can produce, because that is where your view lagging comes from.

Back to the Task

Now, when we figure out how SwiftUI works under the hood, we can give a +1 solution to the Task we have started solving earlier. Take a look:

import SwiftUI

@Observable
final class ContentViewModel {

var counter: Int = 99 {
didSet {
print(counter)
}
}

var values: [Int] = [Int](1...100_000)
}

struct ContentView: View {

var model: ContentViewModel

init(_ model: ContentViewModel) {
self.model = model
print(Self.self, #function)
}

var body: some View {
let _ = Self._printChanges()
VStack {
ContentListView(values: model.values)
Text("Counter: \(model.counter)")
Button {
model.counter += 1
} label: {
Text("Counter +1")
}
.buttonStyle(.borderedProminent)
}
.padding()
.debugBackground()
}
}

// MARK: - Inside types

private extension ContentView {

struct ContentListView: View, Equatable {

let values: [Int]

init(values: [Int]) {
self.values = values
print(Self.self, #function)
}

var body: some View {
let _ = Self._printChanges()
List {
ForEach(values, id: \.self) {
Text(String($0))
.padding()
.debugBackground()
}
}
.listStyle(.plain)
.frame(height: 200.0)
.padding()
.debugBackground()
}
}
}

@available(iOS 17, *)
#Preview {
ContentView(ContentViewModel())
}

Let’s break through this code snippet. I:

  1. used Performance Rule number 3 and split List into its custom view type ContentListView, to have the advantage of using the SwiftUI comparing mechanism.
  2. encapsulated this type, made Inside types private. This custom type is only a part of this specific ContentView — no need to “open it to the world”.
  3. add Equatable conformance to the ContentListView. This inside view is Non-POD because of a values array dependence. Array in Swift is not a clear value type it uses only value semantics, which is why this type is
    Non-POD and SwiftUI uses Reflection by default to compare. Adding Equatable makes comparing faster. (Of course with these straightforward structures — comparing will be fast enough, but with an Equatable it will be a bit faster, so why not 😁)

Here is the visualization:

As you can see, there is no more lagging even with 100_000 values List. I can even scroll the List while pressing the button. When the Counter +1 button is pressed state for the ContentView changes and SwiftUI starts rebuilding. .debugBackground() for the entire view changes color, but a background for theContentListView is constant, and it is constant not because I made it, but because SwiftUI does not re-render this inside view. (remember that .debugBackground() picks a random color each time when the view — re-renders). Moreover, SwiftUI does not even call the body for ContentListView. SwiftUI uses only Step 1 from the SwiftUI under the hood section. Look into console logs:

First initialization and render for all views -> Button tap (console log with a line “100") -> Change state for the ContentView-> ContentView body call -> Init call for the ContentListView -> Comparing for theContentListViewstate (Step 1) -> The rest part of the body call for the ContentView -> Comparing bodies for theContentView(Step 2) -> re-rendering for changed parts -> button tap (console log with a line “101”) -> …

As I said the body for the ContentListView is not even called on counter updating.

And measurements now look like this:

99 ms — which is way faster than 10 seconds, Task solved! Of course, you can combine LazyVStack with this method and you will be right! But my task here was to show you how to work with SwiftUI and not against it.

Conclusion

  • Don’t fight with SwiftUI, work with it!
  • Use visualization to watch when and where your view re-renders.
  • Try to use @Observable over theObservableObject if you can.
  • Don’t forget Performance rules about body, modifiers, and splitting.
  • Remember comparing types: memcmp(memory compare), Equality, and Reflection.
  • Use _isPOD(T.self) when you are not 100% sure if a view is POD or Non-POD.

This is it, if you read this line — thank you! I was doing my best to show you this material as simple, visual, and clear as possible.
Have fun coding :)

--

--