Mastering ViewState in SwiftUI: A Comprehensive Overview
Written on
Chapter 1 Understanding ViewState
In the realm of SwiftUI, views don't behave like traditional views. Instead, they represent lightweight descriptions of our intended user interfaces, created by combining application data with specific structures that dictate layout and behavior.
At the heart of this is the principle of a single source of truth, which suggests that each UI component should be driven by one definitive piece of data. When this data changes, the interface should reflect those changes seamlessly. This powerful principle can resolve many of the issues found in imperative programming.
However, challenges arise when an interface depends on multiple data points. How do we ensure that the definition we generate is accurate and the information displayed is correct? A somewhat tongue-in-cheek response would be to simply avoid such situations. Interestingly, this is indeed a valid approach.
Let's explore this concept with a familiar scenario: the Loading View.
Section 1.1 The Loading View Scenario
We all have experience creating loading views, and we understand the challenges involved. For instance, let's define a few requirements for a fictional banking application:
- Call an API to retrieve a list of user accounts.
- Display the accounts if the API call is successful (which is a different discussion).
- If the API call fails, show an error message and allow the user to retry.
- Present a progress view during loading, ideally with a modern shimmer effect.
- If the API call is successful but no active accounts are found, inform the user accordingly.
Now, let's consider what we need to achieve this. What constitutes our source of truth?
If you propose that the view model serves as the source of truth, I concur. However, this leads us to another question: what data does the view model require to present to the view?
It's evident that we need to manage an array of accounts, an error message, an empty message, and an indicator for loading status. Additionally, we need a mechanism to load the accounts.
Here's a SwiftUI view model that encapsulates these requirements:
class ClassicSwiftUIViewModel: ObservableObject {
@Published var accounts: [Account] = []
@Published var loading: Bool = false
@Published var empty: String?
@Published var error: String?
let manager = AccountManager()
@MainActor
func load() async {
do {
accounts = []
empty = nil
error = nil
loading = true
accounts = try await manager.load()
if accounts.isEmpty {
empty = "No accounts found"}
loading = false
} catch {
self.error = error.localizedDescription
loading = false
}
}
}
This model is a classic example, featuring published properties for each value we intend to display, along with an asynchronous load function to fetch our data.
Anyone who has spent a few days coding in SwiftUI or has watched a WWDC presentation would recognize this approach immediately. For simplicity, we are not injecting our AccountManager into the view model for this discussion, but feel free to apply your best practices.
#### Section 1.1.1 The View Implementation
struct ClassicSwiftUIView: View {
@StateObject var viewModel = ClassicSwiftUIViewModel()
var body: some View {
Group {
if let error = viewModel.error {
StandardErrorView(message: error) {
Task { await viewModel.load() }}
} else if let empty = viewModel.empty {
StandardEmptyView(message: empty)} else if viewModel.loading {
StandardProgressView()} else {
AccountsListView(accounts: viewModel.accounts)}
}
.navigationTitle("Accounts")
.onAppear {
Task { await viewModel.load() }}
}
}
The logic of this view is convoluted, with checks for error messages, empty states, and loading indicators. This complexity can lead to potential pitfalls, especially if we forget to clear an error message before a new load attempt. Even with tests in place for the view model, we can only assume that the view logic will hold up.
Section 1.2 Simplifying with ViewState
A popular solution is to use an enumerated type for view states, defined as follows:
enum ViewState {
case loading
case loaded
case empty
case error
}
This enumeration clarifies the various states our view can assume, allowing us to streamline our view code:
struct ComputedStateView: View {
@StateObject var viewModel = ComputedStateViewModel()
var body: some View {
Group {
switch viewModel.state {
case .error:
StandardErrorView(message: viewModel.error) {
Task { await viewModel.load() }}
case .empty:
StandardEmptyView(message: viewModel.empty)case .loading:
StandardProgressView()case .loaded:
AccountsListView(accounts: viewModel.accounts)}
}
.navigationTitle("Accounts")
.onAppear {
Task { await viewModel.load() }}
}
}
This approach is much cleaner, devoid of complex logic within the view. Now, we only need to ensure that our view model is functioning correctly.
#### Chapter 2 Advanced ViewState Management
The first video titled "5 Steps to Better SwiftUI Views" explores effective techniques for enhancing your SwiftUI views.
The second video, "Switching view states with enums – Bucket List SwiftUI Tutorial 3/12," dives into practical applications of enum-based state management in SwiftUI.
By implementing these strategies, you can improve the clarity and maintainability of your SwiftUI applications. The incorporation of enumerated states eliminates ambiguity and enhances your code's reliability, making your development process smoother and more efficient.