Optimize SwiftUI List View Updates
In this article we are going to take a quick look at how SwiftUI updates a list view when the State
changes and a way to improve it.
Set up
To demostrate the topic we are going to create a very simple sample app. A view will display a list of users and when we tap on a cell, the tap count increases.
Our user is a simple struct
struct User: Identifiable {
let id = UUID()
let name: String
var taps = 0
}
The UsersView
holds the list of users and displays a UserCell
for each user
struct UsersView: View {
@State private var users = [
User(name: "Ben"),
User(name: "David"),
User(name: "Tom")
]
var body: some View {
let _ = Self._printChanges()
NavigationView {
List {
ForEach($users) { $user in
UserCell(user: $user)
}
}
.listStyle(.plain)
.navigationTitle("Users")
}
}
}
struct UserCell: View {
@Binding var user: User
var body: some View {
let _ = Self._printChanges()
Button {
user.taps += 1
} label: {
HStack {
Text(user.name)
Spacer()
Text("\(user.taps)")
.foregroundStyle(.secondary)
}
.font(.subheadline)
}
}
}
Note: We add the Self._printChanges() call to see when the views body is called and recomputed.
When we run the sample app, we notice that each time we tap on a cell. The entire view and all the cells are recomputed
UsersView: @self, @identity, _users changed.
UserCell: @self, @identity, _user changed.
UserCell: @self, @identity, _user changed.
UserCell: @self, @identity, _user changed.
This is because everytime we update a value of a user
in the users
array, the entire users
array is marked as updated. This is obviously not ideal, as only one cell was changed.
The trick to improve this, is to make the User
model an ObservableObject
, mark the values we want to get notified with the @Published
keyword and to observe the changes in the cell.
final class User: Identifiable, ObservableObject {
let id = UUID()
let name: String
@Published var taps = 0
init(name: String) {
self.name = name
}
}
The UsersView
stays almost the same
struct UsersView: View {
@State private var users = [
User(name: "Ben"),
User(name: "David"),
User(name: "Tom")
]
var body: some View {
let _ = Self._printChanges()
NavigationView {
List {
ForEach(users) { user in
UserCell(user: user)
}
}
.listStyle(.plain)
.navigationTitle("Users")
}
}
}
And in our UserCell we observe the User changes
struct UserCell: View {
@ObservedObject var user: User
var body: some View {
let _ = Self._printChanges()
Button {
user.taps += 1
} label: {
HStack {
Text(user.name)
Spacer()
Text("\(user.taps)")
.foregroundStyle(.secondary)
}
.font(.subheadline)
}
}
}
With this change in place, we run the app and see that we only get one single log trace when we update a cell. Nice.
UserCell: _user changed.
Observable (iOS17+)
Adapting this to the Observable
macro is straight forward.
@Observable
class User: Identifiable {
let id = UUID()
let name: String
var taps = 0
init(name: String) {
self.name = name
}
}
struct UserCell: View {
@Bindable var user: User
var body: some View {
let _ = Self._printChanges()
Button {
user.taps += 1
} label: {
HStack {
Text(user.name)
Spacer()
Text("\(user.taps)")
.foregroundStyle(.secondary)
}
.font(.subheadline)
}
}
}
When you run the app, you'll see the same result: only one cell is recomputed.
UserCell: @self changed.
Note: The UsersView stays exactly how it was.
As you see, the solution is quite simple and it helps to understand how the SwiftUI diffing and updates works.
If you want to get a more fine grain control on what subviews are recomputed, you need to mark the model containers as ObservableObject
or @Observable
and observe the idividual changes. Moreover this also enforces the use of classes over structs to hold the model data.
Does this also happen if we don't use a ForEach loop?
Let's take the first approach where we use a simple User struct
and show three cells in a VStack
struct User {
let name: String
var taps = 0
}
struct UsersView: View {
@State private var users = [
User(name: "Ben"),
User(name: "David"),
User(name: "Tom")
]
var body: some View {
let _ = Self._printChanges()
NavigationView {
VStack {
UserCell(user: $users[0])
UserCell(user: $users[1])
UserCell(user: $users[2])
Spacer()
}
.buttonStyle(.bordered)
.foregroundStyle(.primary)
.padding()
.navigationTitle("Users")
}
}
}
If we run the app, we see that the outcome is exactly the same as before. Each time we tap on a cell, the main view and each cell is recomputed.
UsersView: @self, @identity, _users changed.
UserCell: @self, @identity, _user changed.
UserCell: @self, @identity, _user changed.
UserCell: @self, @identity, _user changed.
And if don't use an array?
Now let's add a small twist to the example: instead of using an array lets create a Users
struct that holds three users and use this instead.
struct Users {
var user0: User
var user1: User
var user2: User
}
struct UsersView: View {
@State private var users = Users(
user0: User(name: "Ben"),
user1: User(name: "David"),
user2: User(name: "Tom")
)
var body: some View {
let _ = Self._printChanges()
NavigationView {
VStack {
UserCell(user: $users.user0)
UserCell(user: $users.user1)
UserCell(user: $users.user2)
Spacer()
}
.buttonStyle(.bordered)
.foregroundStyle(.primary)
.padding()
.navigationTitle("Users")
}
}
}
The output is interesting: when we tap on a cell, each cell is recomputed, but the main view is not
UserCell: _user changed.
UserCell: _user changed.
UserCell: _user changed.
Let's take it one step further: instead of having the users model in a State
variable in the view, let's move it to an intermediate ObservableObject
.
class UsersViewModel: ObservableObject {
@Published var users = Users(
user0: User(name: "Ben"),
user1: User(name: "David"),
user2: User(name: "Tom")
)
}
struct UsersView: View {
@StateObject private var model = UsersViewModel()
var body: some View {
let _ = Self._printChanges()
NavigationView {
VStack {
UserCell(user: $model.users.user0)
UserCell(user: $model.users.user1)
UserCell(user: $model.users.user2)
Spacer()
}
.buttonStyle(.bordered)
.foregroundStyle(.primary)
.padding()
.navigationTitle("Users")
}
}
}
Running the app you will notice an intersting result: when we tap on one cell, the container view and only one cell is recomputed.
UsersView: _model changed.
UserCell: @self, _user changed.
How can we optimize this?
Now that we have seen all this different options, let's have a look at a few possible solutions to optimize it. Our goal is that only the cell that is updated gets recomputed.
- Have three separated
State
variables, each with one user
struct User: Equatable {
let name: String
var taps = 0
}
struct UsersView: View {
@State var user0 = User(name: "Ben")
@State var user1 = User(name: "David")
@State var user2 = User(name: "Tom")
var body: some View {
let _ = Self._printChanges()
NavigationView {
VStack {
UserCell(user: $user0)
UserCell(user: $user1)
UserCell(user: $user2)
Spacer()
}
.buttonStyle(.bordered)
.foregroundStyle(.primary)
.padding()
.navigationTitle("Users")
}
}
}
- A second possible option, is the one we mentioned at the begininning: make the User model
Observable
and observe the changes in the cell.
@Observable
class User {
let name: String
var taps = 0
init(name: String) {
self.name = name
}
}
struct UserCell: View {
@Bindable var user: User
var body: some View {
let _ = Self._printChanges()
Button {
user.taps += 1
} label: {
HStack {
Text(user.name)
Spacer()
Text("\(user.taps)")
.foregroundStyle(.secondary)
}
.font(.subheadline)
}
}
}
struct UsersView: View {
@State private var users = Users(
user0: User(name: "Ben"),
user1: User(name: "David"),
user2: User(name: "Tom")
)
var body: some View {
let _ = Self._printChanges()
NavigationView {
VStack {
UserCell(user: users.user0)
UserCell(user: users.user1)
UserCell(user: users.user2)
Spacer()
}
.buttonStyle(.bordered)
.foregroundStyle(.primary)
.padding()
.navigationTitle("Users")
}
}
}
What happens if we introduce a view model?
Well, as you have seen in the previous section, when we introduced an intermediate ObservableObject
, only one cell was recomputed, but also the main container view.
The solution is the same mentioned before: we make the User model Observable
and observe the individual changes.
@Observable
class User {
let name: String
var taps = 0
init(name: String) {
self.name = name
}
}
@Observable
class UsersViewModel {
var users = Users(
user0: User(name: "Ben"),
user1: User(name: "David"),
user2: User(name: "Tom")
)
}
struct UsersView: View {
@State private var model = UsersViewModel()
var body: some View {
let _ = Self._printChanges()
NavigationView {
VStack {
UserCell(user: model.users.user0)
UserCell(user: model.users.user1)
UserCell(user: model.users.user2)
Spacer()
}
.buttonStyle(.bordered)
.foregroundStyle(.primary)
.padding()
.navigationTitle("Users")
}
}
}
With this in place, you get the expected result where only one cell is recomputed.
UserCell: @dependencies changed.
Learnings
As you can see, it's important to take some time to evaluate your apps architecture, model types and what has to be Observable
and what not. For very simple views it might not matter, but for more complex composed views it can make a huge difference.