Debugging SwiftUI View

Sarathi Kannan
8 min readJan 10, 2024

--

In this article, we delve into dynamic debugging techniques for SwiftUI. Many developers commonly rely on breakpoints as a powerful tool to gain insights into the execution of their Swift code, enabling them to improve code quality and comprehension.

When considering how to effectively debug SwiftUI Views dynamically, my initial approach was to leverage the power of breakpoints. I ventured into debugging SwiftUI Views using breakpoints and found it to be an exceptionally effective strategy.

Now, you might be wondering why you should continue reading this article. The answer lies in the crucial challenge of pinpointing which property triggers a SwiftUI view’s re-render. This knowledge is pivotal for enhancing SwiftUI view code and optimizing its performance, making this article a valuable resource for SwiftUI developers.

Before diving into debugging SwiftUI Views, let’s take a look at the sample code. I’ve developed a user list using an MVVM architecture pattern.

Model and Network: Handle API calls to fetch user data and store it into the User model.

import Combine
import Foundation

typealias UserListPublisher = AnyPublisher<[User], Error>

enum UserNetworkError: Error {
case invalidURL
case fetchUserError(String)
}

public class UserNetwork {
private init() {}
public static let shared = UserNetwork()
let backgroundQueue = DispatchQueue(label: "network")

func fetchAllUsers() -> UserListPublisher {
guard let url = URL(string: "https://jsonplaceholder.typicode.com/users") else {
fatalError("Invalid URL")
}

return URLSession.shared.dataTaskPublisher(for: url)
.map{$0.data}
.subscribe(on: backgroundQueue)
.receive(on: DispatchQueue.main)
.decode(type: [User].self, decoder: JSONDecoder())
.mapError{ error in
return UserNetworkError.fetchUserError(error.localizedDescription)
}
.eraseToAnyPublisher()
}
}

public struct User : Codable, Identifiable {

public var id: Int
var name: String
var userName: String
var email: String
var address: Address
var phone: String
var website: String
var company: Company

enum CodingKeys: String, CodingKey {
case id, name
case userName = "username"
case email, phone, website
case company
case address
}
}

ViewModel: It fetches user data from the network, then stores it in the user array property and serves the data to the view.

public class UserViewModel: ObservableObject {
enum UserState {
case loading
case failure
case success
}
private(set) var users: [User] = []
internal var cancellables = Set<AnyCancellable>()
@Published private(set) var userState: UserState = .loading
private(set) var errorMessage: String = ""

init() {}
func fetchAllUsers() {
userState = .loading
UserNetwork.shared.fetchAllUsers()
.sink { [weak self] completion in
if case let .failure(error) = completion, let errMessage = error as? LocalizedError {
guard let self = self else {
return
}
self.errorMessage = errMessage.localizedDescription
self.userState = .failure
}
} receiveValue: { [weak self] users in
guard let self = self else {
return
}
self.users = users
self.userState = .success
}
.store(in: &cancellables)
}

}

View: Display the User List data in view

struct UserListView: View {
@StateObject public var userViewModel: UserViewModel = UserViewModel()

var body: some View {
VStack() {
UserHeaderView(headerString: "User List", userViewModel: userViewModel)
Spacer()
switch userViewModel.userState {
case .loading:
loadingView
case .failure:
errorView
case .success:
UserList(userViewModel: userViewModel)
}
Spacer()
}
.padding()
.onAppear {
self.userViewModel.fetchAllUsers()
}

}

private var loadingView: some View {
ProgressView()
.padding()
}

private var errorView: some View {
VStack {
Image(systemName: "xmark.circle")
Text(userViewModel.errorMessage)
}
}

}

struct UserHeaderView: View {
private var headerString: String
private var userViewModel: UserViewModel

init(headerString: String, userViewModel: UserViewModel) {
self.headerString = headerString
self.userViewModel = userViewModel
}

var body: some View {
HStack(alignment: .center) {
Text(headerString)
.font(.title)
.fontWeight(.bold)
.multilineTextAlignment(.center)

Spacer()
Button(action: {
self.userViewModel.fetchAllUsers()
}) {
HStack {
Image(systemName: "arrow.clockwise")
Text("Refresh")
}
.padding()
}
}
.padding([.top, .leading], 20)
}
}

struct UserList: View {
@ObservedObject public var userViewModel: UserViewModel

var body: some View {
VStack {
ScrollView {
LazyVStack {
ForEach(userViewModel.users, id: \.id) { user in
VStack(alignment: .leading) {
Text(user.name)
.font(.subheadline)
.fontWeight(.bold)
.padding()
HStack {
Image(systemName: "envelope")
Text(user.email)
}
.padding(.horizontal)
.padding(.vertical, 5.0)
HStack {
Image(systemName: "phone")
Text(user.phone)
}
.padding(.horizontal)
.padding(.vertical, 5.0)
Divider()
}
.cornerRadius(6.0)
}
}
}
.padding()
}
}
}

In the above example, UserListView checks the user state enum. If the state is ‘loading’, it displays the loading view. If the state is ‘success’, it then calls the UserList struct to load user data.

Fig 1: User List view

Now, I want to debug the UserListView and UserList pages, specifically to understand when the view gets changed or created and which properties impact the SwiftUI view. As mentioned earlier, we have two ways to debug the view.

First, let’s explore how the Self._printChanges() statement aids in debugging the SwiftUI view or how to debug the SwiftUI view by using Self._printChanges().

Self._printChanges():

While breakpoints serve as a useful tool for debugging SwiftUI views, they may not offer an exact solution in every scenario. This limitation arises from the fact that the body property of a SwiftUI View is a computed property, capable of returning only an element of type View, making it challenging to include print statements directly.

So, how can we effectively debug SwiftUI views? One solution is to leverage Self._printChanges(). This method provides a viable alternative for debugging and monitoring changes within SwiftUI views.

In the example above, when UserListView appears, the fetchAllUser method is called in the onAppear closure. This method fetches data from the API and stores user data in case of success or error data in case of failure.

In this scenario, I aim to debug when UserListView gets updated. To achieve this, I use Self._printChanges() inside the body property. This approach provides information on when the view gets updated or created and identifies which property is responsible for the update or creation. Ultimately, this helps improve the performance of SwiftUI views by using Self._printChanges().

Now, let’s examine the code below:

struct UserListView: View {
@StateObject public var userViewModel: UserViewModel = UserViewModel()

var body: some View {
let _ = Self._printChanges()
VStack() {
UserHeaderView(headerString: "User List", userViewModel: userViewModel)
Spacer()
switch userViewModel.userState {
case .loading:
loadingView
case .failure:
errorView
case .success:
UserList(userViewModel: userViewModel)
}
Spacer()
}
.padding()
.onAppear {
self.userViewModel.fetchAllUsers()
}

}

private var loadingView: some View {
ProgressView()
.padding()
}

private var errorView: some View {
VStack {
Image(systemName: "xmark.circle")
Text(userViewModel.errorMessage)
}
}

}

struct UserHeaderView: View {
private var headerString: String
private var userViewModel: UserViewModel

init(headerString: String, userViewModel: UserViewModel) {
self.headerString = headerString
self.userViewModel = userViewModel
}

var body: some View {
HStack(alignment: .center) {
Text(headerString)
.font(.title)
.fontWeight(.bold)
.multilineTextAlignment(.center)

Spacer()
Button(action: {
self.userViewModel.fetchAllUsers()
}) {
HStack {
Image(systemName: "arrow.clockwise")
Text("Refresh")
}
.padding()
}
}
.padding([.top, .leading], 20)
}
}


struct UserList: View {
@ObservedObject public var userViewModel: UserViewModel

var body: some View {
let _ = Self._printChanges()
VStack {
ScrollView {
LazyVStack {
ForEach(userViewModel.users, id: \.id) { user in
VStack(alignment: .leading) {
Text(user.name)
.font(.subheadline)
.fontWeight(.bold)
.padding()
HStack {
Image(systemName: "envelope")
Text(user.email)
}
.padding(.horizontal)
.padding(.vertical, 5.0)
HStack {
Image(systemName: "phone")
Text(user.phone)
}
.padding(.horizontal)
.padding(.vertical, 5.0)
Divider()
}
.cornerRadius(6.0)
}
}
}
.padding()
}
}
}

In the example code above, I have included Self._printChanges() in both UserListView and UserList. Take a look at the console output while running the app.

Fig 2: User List Page

When the UserListPage is opened, we make the fetchAllUsers() method call to retrieve user data. Based on the logs, initially, the UserListView was created, signifying the creation of a new identity.

When called within an invocation of body of a view of this type, prints the names of the changed dynamic properties that caused the result of body to need to be refreshed. As well as the physical property names, “@self” is used to mark that the view value itself has changed, and “@identity” to mark that the identity of the view has changed (i.e. that the persistent data associated with the view has been recycled for a new instance of the same type).

@self and @identity are followed by the properties that changed.

To understand more about view identity, please refer to my previous article on How SwiftUI works

  1. In the fetchAllUsers() method, the userState property is initially updated as loading. Upon reviewing the logs, it is observed that the _userViewModel property has changed for the UserListView.
Fig 3: Logs for Loading screen

2. After retrieving the user data, the userState property is updated to indicate success. Upon examining the logs, it is observed that the _userViewModel property has changed, and a new UserList view has been created. This implies the creation of a new identity.

Fig 4: Logs for data screen

3. When the Refresh button is tapped, the fetchAllUsers() method is called once more, resulting in the userState property being updated to ‘loading.’ Upon examining the logs, it is observed that the _userViewModel property has changed for the UserListView.

Fig 5: Logs for Loading screen in Refresh

After fetching user data for the refresh action, the userState property is updated again as a success. Upon reviewing the logs, it’s evident that the _userViewModel property has changed for the UserListView. However, a new UserList view was created, indicating the creation of a new identity. Since we are utilizing the same _userViewModel reference in UserList, declared as @ObservedObject, a new UserList view is generated.

Fig 6: Logs for User data screen in Refresh

By employing Self._printChanges(), we gain insights into which properties are altered or updated and understand how these changes impact the SwiftUI view.

Now that we have identified the issue, which is the UserList page being created every time instead of being updated when we click refresh. The problem arises from using a conditional statement for loading and success using the Switch case in our code. Consequently, whenever the userState changes from loading to success, the UserList page is newly created.

To resolve this issue, remove the conditional statement between the loading and success userState.

Please refer to the updated code below:

struct UserListView: View {
@StateObject public var userViewModel: UserViewModel = UserViewModel()

var body: some View {
let _ = Self._printChanges()
VStack() {
UserHeaderView(headerString: "User List", userViewModel: userViewModel)
Spacer()
ZStack {
switch userViewModel.userState {
case .failure:
errorView
default:
UserList(userViewModel: userViewModel)
.disabled(userViewModel.userState == .success ? false : true)
}
if userViewModel.userState == .loading {
loadingView
}
}
Spacer()
}
.padding()
.onAppear {
self.userViewModel.fetchAllUsers()
}

}

private var loadingView: some View {
ProgressView()
.padding()
}

private var errorView: some View {
VStack {
Image(systemName: "xmark.circle")
Text(userViewModel.errorMessage)
}
}

}

After updating the code, observe the view creation and update again by utilizing the Self._printChanges() statement.

Fig 7: User list page after fix add

After removing the conditional statement between loading and success in the userState, the UserList page is updated when the refresh button is clicked, indicating that it is now created only once. This improvement has enhanced the performance, ensuring that the UserList page is created every time the refresh button is clicked.

Please refer to the screenshots below, illustrating the state before and after implementing the fix.

Fig 8: Comparison between before and after fix

The Self._printChanges() statement is solely intended for debugging purposes, so please refrain from committing it to the repository.

Conclusion:

Debugging SwiftUI views is an important skill, especially when working with dynamic properties on views. Using the Self._printChanges() method enables us to identify the root cause of view re-rendering.

I hope this article helps you how debugging SwiftUI views.

However, if you have any questions, leave me a comment below, and I will answer your questions.

If you like this article, please follow me and provide a clap.

Thank you! Happy Coding!

Follow me on Linked In | Sarathi Kannan

--

--

Sarathi Kannan

Passionate Mobile Application Developer, iOS, Swift, SwiftUI, Objective C, Flutter