Modeling view state in SwiftUI
Table of Contents
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 true10 case let (.success(lhsValue), .success(rhsValue)):11 return lhsValue == rhsValue12 case let (.failed(lhsError), .failed(rhsError)):13 return lhsError.localizedDescription == rhsError.localizedDescription14 default:15 return false16 }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 in18 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 error16 let _ = Dependencies.apiService.register { .mock(.error(.example)) }17 let viewModel2 = NewsView.ViewModel()18 NewsView(viewModel: viewModel2)19 .previewDisplayName("Error")20 21 // Preview while loading22 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 // Given10 let newsEntry: NewsEntry = .mock11 Dependencies.apiService.register {12 .mock(.data([newsEntry]))13 }14 let viewModel = NewsView.ViewModel()15 XCTAssertEqual(viewModel.state, .loading)16 17 // When18 await viewModel.loadNews()19 20 // Then21 XCTAssertEqual(viewModel.state, .success([newsEntry]))22 }23 24 func testNewsLoadingFail() async throws {25 // Given26 Dependencies.apiService.register {27 .mock(.error(.example))28 }29 let viewModel = NewsView.ViewModel()30 XCTAssertEqual(viewModel.state, .loading)31 32 // When33 await viewModel.loadNews()34 35 // Then36 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! 😊