Weekly Swift articles, podcasts and tips by John Sundell.

Handling loading states within SwiftUI views

Published on 11 Oct 2020
Discover page available: SwiftUI

When 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.

Architecting SwiftUI apps with MVC and MVVM

This ad keeps all of Swift by Sundell free for everyone. If you can, please check this sponsor out, as that directly helps support this site:

Architecting SwiftUI apps with MVC and MVVM

Architecting SwiftUI apps with MVC and MVVM: Although you can create an app simply by throwing some code together, without best practices and a robust architecture, you’ll soon end up with unmanageable spaghetti code. Learn how to create solid and maintainable apps with fewer bugs using this free guide.

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
            )
        }
    }
}

Support Swift by Sundell by checking out this sponsor:

Architecting SwiftUI apps with MVC and MVVM
Architecting SwiftUI apps with MVC and MVVM

Architecting SwiftUI apps with MVC and MVVM: Although you can create an app simply by throwing some code together, without best practices and a robust architecture, you’ll soon end up with unmanageable spaghetti code. Learn how to create solid and maintainable apps with fewer bugs using this free guide.

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! 🚀