Skip to content
The Left Bit Stories
TwitterGithubHomepage

SwiftUI in an Async World

SwiftUI, DataSource, Swift Concurrency9 min read

At TheLeftBit we've built great UIKit apps that have stood the test of time and scaled beautifully by following three basic rules:

  • Keep it simple
  • Pass values to functions
  • Don't fight the platform

Now, as we transition to SwiftUI, we're formalizing how things are structured in order for our developers to tackle creating beautiful user interfaces without too much fuzz. We wanted the code to adopt modern Swift concepts like Structured Concurrency, Result Builders and Generics in order to be even more efficient than what we where when building UIs with UIKit.

We quickly came to the realization that in most modern apps, most of the Data and State is remote. It's behind some kind of HTTP call. And this fact should get first-citizen support in whatever patterns we chose.

On the other hand, SwiftUI expects data to be stored and passed around the Views using property wrappers like @State, @Binding or @Published . To simplify this, one of the easiest ways is to store all the required properties in some "Data Model" object that stores all the properties using @Published (not necessary if we were to target iOS 17 thanks to Observable, but the gist is the same) and then pass that object into the View either using simple injection in the initializer or an .environmentObject() call.

Simple Book List:

Let's keep in mind these two things, and build a simple SwiftUI view that shows a list of books that comes from a remote REST API.

The Book model looks like:

1struct Book: Identifiable, Decodable {
2 let id: UUID
3 let title: String
4 let author: String
5}

After that, we need some object to hold all the books for the View. We are calling it DataModel in this example, but could be anything the team decides on. Let's create an ObservableObject for this:

1class DataModel: ObservableObject {
2 @Published var isLoading: Bool
3 @Published var error: Swift.Error?
4 @Published var books: [Book]
5 init(isLoading: Bool = false, error: Error? = nil, books: [BookList.Book] = []) { ... }
6}

Now, let's display this in a list:

1struct BookList: View {
2
3 @ObservedObject var dataModel: DataModel
4
5 var body: some View {
6 if dataModel.isLoading {
7 ProgressView()
8 } else let error = dataModel.error {
9 ErrorView(error)
10 } else {
11 List(dataModel.books, id: \.id) { book in
12 VStack {
13 Text(book.title)
14 .foregroundStyle(.primary)
15 Text(book.author)
16 .foregroundStyle(.secondary)
17 }
18 }
19 }
20 }
21}

This all compiles and similar code has been written in countless blog posts all over the internet. But a question come to mind:

  • How do we populate that data?

We could add to DataModel an async function to populate the data:

1extension DataModel {
2 func populateData() async {
3 self.isLoading = true
4 let request: URLRequest = { ... }
5 do {
6 let response = try await URLSession.shared.data(for: request).0
7 let books = try JSONDecoder().decode([Book].self, from: response)
8 self.books = books
9 } catch {
10 self.error = error
11 }
12 self.isLoading = false
13 }
14}

There's a lot of book-keeping going on. We have to make sure we're setting the correct loading state, capturing the error and storing the response in the @Published property. Doable, but still, definitely improvable.

And now we have to figure out a place to call this from the View to make sure it's populated. Thankfully, since iOS 15 we've had the .task() modifier, which allows us to run async functions when the view is about to be rendered.

1struct BookList: View {
2 var body: some View {
3 { ... }
4 .task {
5 await dataModel.populateData()
6 }
7 }
8}

This works, and most people leave it like this and continue with their lives.

But there are several code smells here:

  • The if {} else if {} else {} branch in the view's body will sure become more and more complex as we add features to this view.
    • Imagine a new requirement to only show a special upsell banner in case the user doesn't have a subscription. Cyclomatic complexity is always something we have to be aware of and try to reduce.
  • If we try to Preview this View, it'll make a network call to populate it's data. This is not something you want to do for your production apps, since there might be Authentication required, but also, it's definetly not the correct thing to do, since it makes your Previews unpredictable, and Previews should be treated as Unit Tests: they should succeed regardless of the network status of your machine.

So how do we address these issues?

Async Initialization

The first breakthrough in the way we architecture SwiftUI apps was that we could add two initializers to our DataModel objects:

  • An async initalizer for when the view is run regularly.
  • A sync initializer when the view is Previewed.

To continue with the previous example, we can now remove those pesky var isLoading: Bool and var error: Swift.Error? properties, the populateData function and do:

1extension DataModel {
2
3 init(restAPIClient: RestAPIClient) async throws {
4 self.books = try await restAPIClient.fetchBookList()
5 }
6
7 init(books: [Book]) {
8 self.books = books
9 }
10}

In the above example, let's assume that RestAPIClient is an object that will perform all the URLSession and JSON parsing work and return a parsed array of Book in case the request is sent and parsed succesfully.

We can now, from a Preview, create a DataModel with mock Books and inject it in the View, allowing us to work the the View's layout without launching the App or sending network requests to servers.

Like we say here at TheLeftBit, "if Previews don't work, it's not SwiftUI!"

And, by removing the loading and error properties, plus annotating the initializer as async throws we've moved the error and async handling a level up. This means that now DataModel is not directly usable from BookListView, since we can't set the @ObservedObject of a view asynchronously. Which brings us to our second breakthrough:

Async View

As we mentioned earlier, we need some async context to create the DataModel before passing it to the View's initializer. And the best way to create this async context is using the .task view modifier, but for that we need to create a View.

Let's wrap this async work in a View: something like this would work:

1struct BookListAsync: View {
2
3 let restAPIClient: RestAPIClient
4 @State var loadingPhase = LoadingPhase.loading
5
6 enum LoadingPhase {
7 case loading
8 case loaded(DataModel)
9 case failed(Swift.Error)
10 }
11
12 var body: some View {
13 contentView
14 .task {
15 self.loadingPhase = .loading
16 do {
17 let dataModel = try await DataModel(restAPIClient: restAPIClient)
18 self.loadingPhase = .loaded(dataModel)
19 } catch {
20 self.loadingPhase = .failed(error)
21 }
22 }
23 }
24
25 @ViewBuilder
26 private var contentView: some View {
27 switch loadingPhase {
28 case .loading:
29 ProgressView()
30 case .failed(let error):
31 Text(error.localizedDescription)
32 case .loaded(let dataModel):
33 BookList(dataModel: dataModel)
34 }
35 }
36}

This is much better: we are using the LoadingPhase enum to model the what to show in the View: either a spinner, a message with the error or the actual BookList. And, we are leveraging the .task modifier to perform the actual creation of the DataModel.

This comes with a lot of advantages:

  • The BookList is only created when there is actual data to show. It made no semantical sense for it to be holding an empty array, much better to express that that Array is in a remote location by making the only way to get to it async.
  • Once the BookList is loaded and on-screen, any error that occurs after that (for example, marking a Book as read) is handled on a completely different context from the loading error; reducing the overall complexity of the system.
  • Since we are encapsulating the Async work in another View, we'll be leveraging SwiftUI to cancel the Task in case the user is not interested anymore. For example, the user will dismiss this view using the back button of a NavigationStack, or swiping down a modal sheet. This will free resources and make sure the app is always performant.

This is already a lot better, but this BookListAsync is tightly coupled to BookList and it's DataModel, making it impossible to reuse it with another view. We have two options to make this view reusable:

  • Use Swift Macros to auto-generate the code
  • Use Generics to inject the View.

We discarted using Swift Macros since we were having trouble making the inputs of the view more flexible.

The final component we built, using Swift Generics and applied to the BookListAsync example, has the following API:

1var body: some View {
2 AsyncView(
3 id: $viewID
4 dataGenerator: {
5 try await DataModel(restAPIClient: restAPIClient)
6 },
7 hostedViewGenerator: { dataModel in
8 BookList(dataModel: dataModel)
9 },
10 loadingViewGenerator: {
11 ProgressView()
12 },
13 errorViewGenerator: { error, onRetry in
14 Text(error.localizedDescription)
15 }
16 )
17}

Where the parameters are:

  • id: is the identifier passed to the .task(id:priority:_:) View Modifier. Having this parameter will allow us to reload the view if an upstream value changes. Think for example, a screen where the there are some sort of filter UI that triggers a new HTTP request to reload the contents of a list. This becomes trivial now.
    • If the view will not be reloaded as a result of user input, you can safely pass a .constant() value here with a hardcoded string.
  • dataGenerator: this closure will generate the data required by the view to render it's contents. If an error is thrown in this closure, the Error View will be shown.
  • hostedViewGenerator: this closure will generate the view once the data it needs is available.
  • loadingViewGenerator: this closure will generate the view that will be shown during the dataGenerator execution.
  • errorViewGenerator: this closure will generate the view that will be shown when an error is thrown.
    • The error is passed in order to make sure that the view can show an appropiate message. Also, an onRetry closure is passed to make sure the user can retry the operation.

The implementation itself is not as interesting, you can check it out here. Having this View allows us to architect our code in a much better way:

1struct BookList: View {
2 @ObservedObject var dataModel: DataModel
3 var body: some View { ... }
4
5 struct Async: View {
6 let restAPIClient: RestAPIClient
7 var body: some View {
8 AsyncView(...)
9 }
10 }
11}

This makes sure that when using autocomplete, the developer won't find a way to create a BookList without a DataModel, and nudge him towards BookList.Async.

Note: How the RestAPIClient instance is injected in the BookList.Async view is up to you; we're using simple value injection, but this could very well be an @EnvironmentObject or anything else you and your team are using for Dependency Injection.

Conclusions:

These two breakthroughs have allowed us to iterate quickly, making sure everyday tasks are handled by this abstractions and allowing us to focus on what sets up each project apart.

All of this, by still leveraging the same three concepts:

  • Keep it simple:

    • A simple API and a simple abstraction allows developers of all seniority levels to use it.
  • Pass values to functions:

    • How the dependencies are passed to the Async view is up to the developer, but it's always evident what's happening.
  • Don't fight the platform:

    • Leverage the platform (in this case SwiftUI) and it's primitives and data-flows, allowing our code to mature along the platform.

Thanks for reading and contact us in case you have any questions!

© 2023 by The Left Bit Stories. All rights reserved.
Theme by LekoArts