Modeling view state in SwiftUI

Edoardo 🇮🇹 Aug 15, 2023 5 min read

Introduction #

In this post, we will explore a way to reduce boilerplate code when modeling view state in SwiftUI views by using a generic enum to handle common scenarios such as loading, success, and error states.

Introducing the ViewState enum #

Let’s start by creating the generic view state enum. We’ll call it ViewState and it will have three cases: success, failed, and loading. The success case will have an associated value of type T which will be the type of the data we want to display in the view. The failed case will have an associated value of type Error which will be the error we want to display in the view. The loading case will not have an associated value.

1enum ViewState<T>: Equatable where T: Equatable {
2 case success(T)
3 case failed(Error)
4 case loading
5
6 static func ==(lhs: ViewState, rhs: ViewState) -> Bool {
7 switch (lhs, rhs) {
8 case (.loading, .loading):
9 return true
10 case let (.success(lhsValue), .success(rhsValue)):
11 return lhsValue == rhsValue
12 case let (.failed(lhsError), .failed(rhsError)):
13 return lhsError.localizedDescription == rhsError.localizedDescription
14 default:
15 return false
16 }
17 }
18}

Note that we are conforming to the Equatable protocol. This is because we want to be able to compare two ViewState instances to see if they are equal. To do so, we are also constraining the generic type T to conform to the Equatable protocol, so that we easily can compare associated values of type T in the success case using the == operator.

Example usage #

Let’s now see how we can use the ViewState enum in a SwiftUI view. We’ll create a simple view that displays a list of news:

1struct NewsView: View {
2
3 @StateObject var viewModel = ViewModel()
4
5 var body: some View {
6 ZStack {
7 switch viewModel.state {
8
9 case .failed(let error):
10 ErrorView(error: error)
11
12 case .loading:
13 ProgressView()
14
15 case .success(let news):
16 List {
17 ForEach(news) { newsEntry in
18 Text(newsEntry.title)
19 }
20 }
21 .refreshable { await viewModel.loadNews() }
22 }
23 }
24 .task { await viewModel.loadNews() }
25 .navigationTitle("News")
26 }
27}

We use a ZStack to display the correct view depending on the state, which are mutually exclusive. The state lives in a ViewModel class, which we will look at next.

If the state is .failed, we display a generic ErrorView. If the state is .loading, we display a ProgressView. If the state is .success, we display a refreshable List of news entries. We also add a .task modifier to the ZStack to load the news when the view appears, and a .navigationTitle modifier to set the title of the view.

Example view model #

The View Model class is responsible for loading the news entries and updating the state accordingly. This is how it could look like:

1extension NewsView {
2 @MainActor final class ViewModel: ObservableObject {
3 @Dependency(\.apiService) private var apiService
4
5 @Published private(set) var state: ViewState<[NewsEntry]> = .loading
6
7 func loadNews() async {
8 do {
9 let response: [NewsEntry] = try await apiService.fetchNews()
10 state = .success(response)
11 } catch {
12 state = .failed(error)
13 }
14 }
15 }
16}

It has a state property of type ViewState<[NewsEntry]> which is the type of the state we want to display in the view (a list of news entries), and defaults to .loading so that the progress view is shown immediately. It also has a loadNews method which loads the news entries from an external service and updates the state accordingly.

The news are loaded using the apiService dependency, which is injected using the @Dependency property wrapper. This is just an example inspired by the Factory library I recently explored (actual dependency injection techniques are out of scope for this post).

Since the state is marked as @Published, the view will automatically be updated when the state changes.

Animations #

We can also add animations to the view by using the .animation modifier. For example, if we want to animate the transition between the different states, we can do so by adding the .animation modifier to the ZStack:

1ZStack {
2 switch viewModel.state {
3 /* ... */
4 }
5}
6.animation(.default, value: viewModel.state)

Previews #

One big advantage of using a generic enum to model view state is that we can easily create previews for all the different states by mocking the service (again, this is just an example inspired by Factory):

1struct NewsView_Previews: PreviewProvider {
2 static var previews: some View {
3 Group {
4 // Preview with mocked news
5 let _ = Dependencies.apiService.register {
6 .mock(.data([
7 NewsEntry(id: 1, title: "One"),
8 NewsEntry(id: 2, title: "Two")
9 ]))
10 }
11 let viewModel = NewsView.ViewModel()
12 NewsView(viewModel: viewModel)
13 .previewDisplayName("Mocked")
14
15 // Preview with error
16 let _ = Dependencies.apiService.register { .mock(.error(.example)) }
17 let viewModel2 = NewsView.ViewModel()
18 NewsView(viewModel: viewModel2)
19 .previewDisplayName("Error")
20
21 // Preview while loading
22 let _ = Dependencies.apiService.register { .mock(.loading()) }
23 let viewModel3 = NewsView.ViewModel()
24 NewsView(viewModel: viewModel3)
25 .previewDisplayName("Loading")
26 }
27 }
28}

Testing #

In the same way, we can easily test that the view model sets the correct state when loading the news entries:

1@MainActor final class NewsViewModelTests: XCTestCase {
2 
3 func testNewsLoadingInitialState() {
4 let viewModel = NewsView.ViewModel()
5 XCTAssertEqual(viewModel.state, .loading)
6 }
7 
8 func testNewsLoadingSuccess() async throws {
9 // Given
10 let newsEntry: NewsEntry = .mock
11 Dependencies.apiService.register {
12 .mock(.data([newsEntry]))
13 }
14 let viewModel = NewsView.ViewModel()
15 XCTAssertEqual(viewModel.state, .loading)
16
17 // When
18 await viewModel.loadNews()
19
20 // Then
21 XCTAssertEqual(viewModel.state, .success([newsEntry]))
22 }
23
24 func testNewsLoadingFail() async throws {
25 // Given
26 Dependencies.apiService.register {
27 .mock(.error(.example))
28 }
29 let viewModel = NewsView.ViewModel()
30 XCTAssertEqual(viewModel.state, .loading)
31
32 // When
33 await viewModel.loadNews()
34
35 // Then
36 XCTAssertEqual(viewModel.state, .failed(.example))
37 }
38 
39}

Notes #

  • This is not a silver bullet and may not be suitable for all use cases, but I found it quite useful in some of my projects for simple views that just need to display data from an external service.
  • One thing that it’s missing is handling of empty state (i.e. when the state is .success but the data is empty). This could be a great use case for the newly introduced ContentUnavailableView!

Conclusion #

I hope you enjoyed this post and that you found it useful. If you have any questions or feedback, please let me know by leaving a comment below. Thanks for reading! 😊