Handling loading states within SwiftUI views
Discover page available: SwiftUIWhen building any kind of modern app, chances are incredibly high that, at one point or another, we’ll need to load some form of data asynchronously. It could be by fetching a given view’s content over the network, by reading a file on a background thread, or by performing a database operation, just to name a few examples.
One of the most important aspects of that kind of asynchronous work, at least when it comes to building UI-based apps, is figuring out how to reliably update our various views according to the current state of the background operations that we’ll perform. So this week, let’s take a look at a few different options on how to do just that when building views using SwiftUI.
Self-loading views
When working with Apple’s previous UI frameworks, UIKit and AppKit, it’s been really common to perform view-related loading tasks within the view controllers that make up an app’s overall UI structure. So, when transitioning to SwiftUI, an initial idea might be to follow the same kind of pattern, and let each top-level View
type be responsible for loading its own data.
For example, the following ArticleView
gets injected with the ID
of the article that it should display, and then uses an ArticleLoader
instance to load that model — the result of which is stored in a local @State
property:
struct ArticleView: View {
var articleID: Article.ID
var loader: ArticleLoader
@State private var result: Result<Article, Error>?
var body: some View {
...
}
private func loadArticle() {
loader.loadArticle(withID: articleID) {
result = $0
}
}
}
Within the above view’s body
property, we could then switch on our current result
value, and construct our UI accordingly — which gives us the following implementation:
struct ArticleView: View {
var articleID: Article.ID
var loader: ArticleLoader
@State private var result: Result<Article, Error>?
var body: some View {
switch result {
case .success(let article):
// Rendering our article content within a scroll view:
ScrollView {
VStack(spacing: 20) {
Text(article.title).font(.title)
Text(article.body)
}
.padding()
}
case .failure(let error):
// Showing any error that was encountered using a
// dedicated ErrorView, which runs a given closure
// when the user tapped an embedded "Retry" button:
ErrorView(error: error, retryHandler: loadArticle)
case nil:
// We display a classic loading spinner while we're
// waiting for our content to load, and we start our
// loading operation once that view appears:
ProgressView().onAppear(perform: loadArticle)
}
}
private func loadArticle() {
loader.loadArticle(withID: articleID) {
result = $0
}
}
}
It could definitely be argued that the above pattern works perfectly fine for simpler views — however, mixing view code with tasks like data loading and networking is not really considered a good practice, as doing so tends to lead to quite messy and intertwined implementations over time.
View models
So let’s separate those concerns instead. One way of doing so would be to introduce a view model companion to the above ArticleView
, which could take on tasks like data loading and state management, letting our view remain focused on what views do best — rendering our UI.
In this case, let’s implement an ArticleViewModel
, which’ll act as an ObservableObject
by publishing a state
property. We’ll reuse our existing ArticleLoader
from before to perform our actual loading, which we’ll implement within a dedicated load
method (since we don’t want our initializer to trigger side-effects like networking):
class ArticleViewModel: ObservableObject {
enum State {
case idle
case loading
case failed(Error)
case loaded(Article)
}
@Published private(set) var state = State.idle
private let articleID: Article.ID
private let loader: ArticleLoader
init(articleID: Article.ID, loader: ArticleLoader) {
self.articleID = articleID
self.loader = loader
}
func load() {
state = .loading
loader.loadArticle(withID: articleID) { [weak self] result in
switch result {
case .success(let article):
self?.state = .loaded(article)
case .failure(let error):
self?.state = .failed(error)
}
}
}
}
With the above in place, we can now have our ArticleView
contain just a single property — its view model — and by observing it using the @ObservedObject
attribute, we can then simply switch on its state
property within our view’s body in order to render our UI according to the current state:
struct ArticleView: View {
@ObservedObject var viewModel: ArticleViewModel
var body: some View {
switch viewModel.state {
case .idle:
// Render a clear color and start the loading process
// when the view first appears, which should make the
// view model transition into its loading state:
Color.clear.onAppear(perform: viewModel.load)
case .loading:
ProgressView()
case .failed(let error):
ErrorView(error: error, retryHandler: viewModel.load)
case .loaded(let article):
ScrollView {
VStack(spacing: 20) {
Text(article.title).font(.title)
Text(article.body)
}
.padding()
}
}
}
}
That’s already quite a substantial improvement when it comes to separation of concerns and code encapsulation, as we’ll now be able to keep iterating on our model and networking logic without having to update our view, and vice versa.
But let’s see if we can take things a bit further, shall we?
A generic concept
Depending on what kind of app that we’re working on, chances are quite high that we won’t just have one view that relies on asynchronously loaded data. Instead, it’s likely a pattern that’s repeated throughout our code base, which in turn makes it an ideal candidate for generalization.
If we think about it, the State
enum that we previously nested within our ArticleViewModel
doesn’t really have much to do with loading articles at all, but is instead a quite generic encapsulation of the various states that any asynchronous loading operation can end up in. So, let’s actually turn it into just that, by first extracting it out from our view model, and by then converting it into a generic LoadingState
type — like this:
enum LoadingState<Value> {
case idle
case loading
case failed(Error)
case loaded(Value)
}
Along those same lines, if we end up following the view model-based architecture that we started using within our ArticleView
all throughout our app, then we’re highly likely to end up with a number of different view model implementations that all have a published state
property and a load
method. So, let’s also turn those aspects into a more generic API as well — this time by creating a protocol called LoadableObject
that we’ll be able to use as a shared abstraction for those capabilities:
protocol LoadableObject: ObservableObject {
associatedtype Output
var state: LoadingState<Output> { get }
func load()
}
Note how we can’t strictly require each implementation of the above protocol to annotate its state
property with @Published
, but we can require each conforming type to be an ObservableObject
.
With the above pieces in place, we now have everything needed to create a truly generic view for loading and displaying asynchronously loaded content. Let’s call it AsyncContentView
, and make it use a LoadableObject
implementation as its Source
, and then have it call an injected content
closure in order to transform the output of that loadable object into a SwiftUI view — like this:
struct AsyncContentView<Source: LoadableObject, Content: View>: View {
@ObservedObject var source: Source
var content: (Source.Output) -> Content
var body: some View {
switch source.state {
case .idle:
Color.clear.onAppear(perform: source.load)
case .loading:
ProgressView()
case .failed(let error):
ErrorView(error: error, retryHandler: source.load)
case .loaded(let output):
content(output)
}
}
}
While the above implementation will work perfectly fine as long as we’re always just returning a single view expression within each content
closure, if we wanted to, we could also annotate that closure with SwiftUI’s @ViewBuilder
attribute — which would enable us to use the full power of SwiftUI’s DSL within such closures:
struct AsyncContentView<Source: LoadableObject, Content: View>: View {
@ObservedObject var source: Source
var content: (Source.Output) -> Content
init(source: Source,
@ViewBuilder content: @escaping (Source.Output) -> Content) {
self.source = source
self.content = content
}
...
}
Note how we (currently) need to implement a dedicated initializer if we wish to add view building capabilities to a closure, since it can’t be applied directly to a closure-based property. To learn more about what adding those capabilities enables us to do, check out articles like “Adding SwiftUI’s ViewBuilder attribute to functions” and “How Swift 5.3 enhances SwiftUI’s DSL”.
With our AsyncContentView
completed, let’s now make our ArticleView
from before use it — which once again lets us simplify its implementation by handing off parts of its required work to other, dedicated types:
struct ArticleView: View {
@ObservedObject var viewModel: ArticleViewModel
var body: some View {
AsyncContentView(source: viewModel) { article in
ScrollView {
VStack(spacing: 20) {
Text(article.title).font(.title)
Text(article.body)
}
.padding()
}
}
}
}
Really nice! With the above change in place, our ArticleView
is now truly focused on just a single task — rendering an Article
model.
Connecting Combine publishers to views
Adopting SwiftUI also often provides a great opportunity to start adopting Combine as well, as both of those two frameworks follow a very similar, declarative and data-driven design.
So, rather than having each of our views follow the classic one-time load-and-render pattern, perhaps we’d like to be able to continuously feed new data to our various views as our overall app state changes.
To update our current loading state management system to support that kind of approach, let’s start by creating a LoadableObject
implementation that takes a Combine publisher, and then uses that to load and update its published state
— like this:
class PublishedObject<Wrapped: Publisher>: LoadableObject {
@Published private(set) var state = LoadingState<Wrapped.Output>.idle
private let publisher: Wrapped
private var cancellable: AnyCancellable?
init(publisher: Wrapped) {
self.publisher = publisher
}
func load() {
state = .loading
cancellable = publisher
.map(LoadingState.loaded)
.catch { error in
Just(LoadingState.failed(error))
}
.sink { [weak self] state in
self?.state = state
}
}
}
Then, using the power of Swift’s advanced generics system, we could then extend our AsyncContentView
with a type-constrained method that automatically transforms any Publisher
into an instance of the above PublishedObject
type — which in turn makes it possible for us to pass any publisher directly as such a view’s source
:
extension AsyncContentView {
init<P: Publisher>(
source: P,
@ViewBuilder content: @escaping (P.Output) -> Content
) where Source == PublishedObject<P> {
self.init(
source: PublishedObject(publisher: source),
content: content
)
}
}
The above method is using a new feature in Swift 5.3, which enables us to attach generic constraints based on the enclosing type to individual method declarations.
The above gives us quite a lot of added flexibility, as we’re now able to make any of our views use a Publisher
directly, rather than going through an abstraction, such as a view model. While that’s likely not something that we want each of our views to do, when it comes to views that simply render a stream of values, that could be a great option.
For example, here’s what our ArticleView
could look like if we updated it to use that pattern:
struct ArticleView: View {
var publisher: AnyPublisher<Article, Error>
var body: some View {
AsyncContentView(source: publisher) { article in
ScrollView {
VStack(spacing: 20) {
Text(article.title).font(.title)
Text(article.body)
}
.padding()
}
}
}
}
A type of situation that the above pattern could become quite useful in is when a given view primarily acts as a detail view for some kind of list — in which the list itself only contains a subset of the complete data that will be lazily loaded when each detail view is opened.
As a concrete example, here’s how our ArticleView
might now be used within an ArticleListView
, which in turn has a view model that creates an Article
publisher for each preview that the list contains:
struct ArticleListView: View {
@ObservedObject var viewModel: ArticleListViewModel
var body: some View {
List(viewModel.articlePreviews) { preview in
NavigationLink(preview.title
destination: ArticleView(
publisher: viewModel.publisher(for: preview.id)
)
)
}
}
}
However, when using the above kind of pattern, it’s important to make sure that our publishers only start loading data once a subscription is attached to them — since otherwise we’d end up loading all of our data up-front, which would likely be quite unnecessary.
Since the topic of Combine-based data management is much larger than what can be covered in this article, we’ll take a much closer look at various ways to manage those kinds of data pipelines within future articles.
Supporting custom loading views
Finally, let’s take a look at how we could also extend our AsyncContentView
to not only support different kinds of data sources, but also completely custom loading views. Because chances are that we don’t always want to show a simple loading spinner while our data is being loaded — sometimes we might want to display a tailor-made placeholder view instead.
To make that happen, let’s start by making AsyncContentView
capable of using any View
-conforming type as its loading view, rather than always rendering a ProgressView
in that situation:
struct AsyncContentView<Source: LoadableObject,
LoadingView: View,
Content: View>: View {
@ObservedObject var source: Source
var loadingView: LoadingView
var content: (Source.Output) -> Content
init(source: Source,
loadingView: LoadingView,
@ViewBuilder content: @escaping (Source.Output) -> Content) {
self.source = source
self.loadingView = loadingView
self.content = content
}
var body: some View {
switch source.state {
case .idle:
Color.clear.onAppear(perform: source.load)
case .loading:
loadingView
case .failed(let error):
ErrorView(error: error, retryHandler: source.load)
case .loaded(let output):
content(output)
}
}
}
However, while the above change does successfully give us the option to use custom loading views, it now also requires us to always manually pass a loading view when creating an AsyncContentView
instance, which is a quite substantial regression in terms of convenience.
To fix that problem, let’s once again use the power of generic type constraints, this time by adding a convenience initializer that lets us create an AsyncContentView
with a ProgressView
as its loading view by simply omitting that parameter:
typealias DefaultProgressView = ProgressView<EmptyView, EmptyView>
extension AsyncContentView where LoadingView == DefaultProgressView {
init(
source: Source,
@ViewBuilder content: @escaping (Source.Output) -> Content
) {
self.init(
source: source,
loadingView: ProgressView(),
content: content
)
}
}
The above pattern is also heavily used within SwiftUI’s own public API, and is what lets us do things like create Button
and NavigationLink
instances using strings as their labels, rather than always having to inject a proper View
instance.
With the above in place, let’s now go back to our ArticleView
and first extract the parts of it that we’re using to render its actual content, which we’ll then use to implement a Placeholder
type using SwiftUI’s new redaction API:
extension ArticleView {
struct ContentView: View {
var article: Article
var body: some View {
VStack(spacing: 20) {
Text(article.title).font(.title)
Text(article.body)
}
.padding()
}
}
struct Placeholder: View {
var body: some View {
ContentView(article: Article(
title: "Title",
body: String(repeating: "Body", count: 100)
)).redacted(reason: .placeholder)
}
}
}
Then, let’s turn our ArticleView
into its final form — a view that renders a custom placeholder while its content is being asynchronously loaded, all through a very compact implementation that utilizes shared abstractions to perform a large part of its work:
struct ArticleView: View {
var publisher: AnyPublisher<Article, Error>
var body: some View {
ScrollView {
AsyncContentView(
source: publisher,
loadingView: Placeholder(),
content: ContentView.init
)
}
}
}
Conclusion
In many ways, making full use of what SwiftUI has to offer really requires us to break down many of the conventions and assumptions that we might have established when using frameworks like UIKit and AppKit. That’s not to say that SwiftUI is universally better than those older frameworks, but it’s definitely different — which in turn warrants different patterns and different approaches when it comes to tasks like data loading and state management.
I hope that this article has given you a few different ideas and options on how loading states could be handled and rendered within SwiftUI views — and if you have any questions, comments, or feedback, then feel free to reach out via either Twitter or email.
Thanks for reading! 🚀