Modeling Screen States
Maintaining consistency when modeling screen states calls for a reusable solution. In this post, I’ll demonstrate a Combine-based implementation and then refactor it to use async/await with Async Algorithms.
States
Any data-fetching screen typically transitions through states such as idle, loading, loaded, and failure. Naturally, these states can be represented with an enum, along with a Progress object for more granular updates.
Loadable
To standardize the handling of loadable content, define a protocol that every loading operation must conform to. For testing we’ll create mock loaders that emit predetermined sequences of states.
In this implementation
var state
could be a closure type, but Combine has sofisticated functionality, operators, back pressure management, and multiple listeners. I’m in for not having to implement debounce, etc.Never
requires that errors are catched and sent as a.failure(error)
states instead.- A conforming object will be held by the view’s ViewModel. Since the ViewModel is more about coordination, it makes for a nice separation of roles.
Here’s an example usage:
Additional complexity could include:
- Sending a
Progress
instance with increasing completion percentages - Checking
isCancelled
before emitting new state - Composing loadables to modify the wrapped loadable’s behavior (e.g., adding debouncing or retries)
But let’s move to the view instead.
Loading View
I typically design components by imagining how they’ll be used in code. For example:
That requires an init
where a closure builds the view content, and a loader providing states.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct LoadingView<L: Loadable, Content: View>: View {
let viewModel: LoadingViewModel<L>
var content: (L.Value) -> Content
init(loader: L, @ViewBuilder content: @escaping (L.Value) -> Content) {
self.viewModel = LoadingViewModel(loader: loader)
self.content = content
}
var body: some View {
switch viewModel.loadingState {
case .loaded(let value):
content(value)
// ... render other states
default:
EmptyView()
}
}
}
Note: the viewModel doesn’t need @State
because I plan to use the Observation framework.
Custom views
To customize the views for each state we need to store the views, and create modified copies of the struct. It looks like view modifiers, but it’s just regular methods. What counts is the final view returned.
It’s a bit involved, but now we can do this:
ViewModel
Now let’s look at how the ViewModel
ties these pieces together. It primarily listens to state updates through a sink
. I still like it to be in a separate file so I don’t have to read it if I don’t need to.
The pattern receive
/sink
/store
is a common occurrence with Combine. But modern Swift has a better way to consume streams of values:
This illustrates why Apple is focusing efforts in AsyncSequence. We get isolation from the containing object, no need to manage the subscription, and it’s naturally integrated with Swift and Structured Concurrency.
AsyncSequence
Apple hasn’t updated Combine for years. Meanwhile, Async Algorithms provides a modern approach to reactive-style operators in Swift’s concurrency system. So unless you need multiple listeners, which is rarely the case, there is little reason to keep using Combine.
The protocol replacement is straightforward:
Implementation notes:
- I added
@MainActor
because an easy way to adopt Structure Concurrency is to explicitly do that for code intended to run on main. Otherwise we have to proof to the compiler that it’s multi thread-safe. - I wrote
AsyncSequence
and notAsyncStream
because operators transform the type of the sequence. eg.AsyncStream
+ debounce =AsyncDebounceSequence
.
Now for the slightly convoluted part. We need to
- Create an internal
AsyncStream
but store it asAsyncSequence
- Also store its continuation, because this is what we’ll use to send updates
This is the sauce:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@MainActor
final class UserLoader: Loadable, Sendable {
// MARK: - Loadable
public var isCancelled = false
public var state: any AsyncSequence<LoadingState<User>, Never>
// `state` implementation
private let internalStream: AsyncStream<LoadingState<User>>
// continuation of the internalStream, where we’ll be yielding states
private var continuation: AsyncStream<LoadingState<User>>.Continuation?
public init() {
var localContinuation: AsyncStream<LoadingState<User>>.Continuation!
let stream = AsyncStream<LoadingState<User>> { continuation in
localContinuation = continuation
}
self.internalStream = stream
self.continuation = localContinuation
self.state = stream
// Add here any transformation from AsyncAlgorithms, e.g.
// .debounce(for: .seconds(0.5))
self.continuation?.onTermination = { @Sendable _ /* termination */ in
Task { @MainActor [weak self] in
self?.isCancelled = true
}
}
}
...
The init is a bit long but I can’t use self
before all is initialized.
Line 21 is important: that’s where we add the operators from Async Algorithms.
If not for that single line we would have to compose that behavior.
The rest is easy:
Conclusion
This is what we got
- Consistency with a reusable solution
- Type-safety with an enum-based state
- Customizable views
- Progress tracking
- Accessibility support for error states
- Composable loadables
Composing loaders
You can compose by calling multiple loaders:
Or by wrapping other loaders:
This example source is in GitHub. Features that could be added next:
- retry for failed operations –gave it a try here.
- timeout for long-running operations
- a composed loader to cache results
This approach to state management could be adapted to UIKit or other UI frameworks. It could use more work, but I’m looking forward to add it to a Tuist template and reuse it in future projects.