Async SwiftUI

When looking at the async/await proposals, I found myself wondering how SwiftUI could make use of it. It'd be neat if we could await functions in our View bodies, making it very easy to bring data into our apps.

Using the Swift 5.5 snapshots, I experimented with this idea.

Starting with DetachView

In Swift 5.5, we can create a detached task using the function detach(operation:). Let see if we can implement a View that uses detach to compute the body:

public struct DetachView<Content: View> : View {
  @StateObject private var resolver: Resolver
  
  final class Resolver : ObservableObject {
    @Published var content: Content?
    init(_ bodyOperation: @escaping @Sendable () async throws -> Content) {
      detach {
        let resolved = try await bodyOperation()
        await MainActor.run { [weak self] in
          self?.content = resolved
        }
      }
    }
  }
  
  public init(@ViewBuilder content: @escaping @Sendable () async throws -> Content) {
    self._resolver = .init(wrappedValue: .init(content))
  }
  
  public var body: some View {
    if let resolvedContent = resolver.content {
      resolvedContent
    } else {
      ProgressView()
    }
  }
}

Let's break that down. In the initializer, we take an async throws closure. This will let us use await and try in the body.

In our Resolver class, we compute the body. We use the detach function to jump into an async context. There we can try await bodyOperation() to compute the body asynchronously. We must publish values on the main thread, however. Previously, we may have used DispatchQueue.main.async to do this. In Swift 5.5, we can use MainActor.run to hop back to the main thread instead.

In body we show a ProgressView until the content is resolved. And that's pretty much it! Let's see what it looks like in practice:

DetachView {
  let data = try await URLSession.shared.dataTask(
    with: URL(string: "https://jsonplaceholder.typicode.com/posts")!
  )
  let posts = try JSONDecoder().decode([Post].self, from: data)
  List(posts, rowContent: PostRow.init)
}

As you can see, we are able to try await our async networking task, decode the JSON, and display the posts in a list:

A list of posts loaded in DetachView
A list of posts loaded in DetachView

This still uses ViewBuilder, so we can emit Views throughout the body. However, the body must reach the end before any Views are displayed. So if you stick another View above the let data, it will still need to wait for data to load. You can get around this by nesting DetachViews.

Let's look at a second method, that I personally think is closer to what an official async SwiftUI would look like.

Using get async

The most obvious way SwiftUI could interoperate with async/await is by allowing View bodies to be computed asynchronously. This is possible thanks to SE-0310.

Since we can't change the requirements of View, lets make our own protocol, AsyncView:

protocol AsyncView : View {
  associatedtype AsyncBody
  @ViewBuilder
  var asyncBody: AsyncBody { get async throws }
}

I made it throwing for good measure.

We only want conforming Views to provide asyncBody, so lets provide a default implementation of body:

extension AsyncView where Body == DetachView<AsyncBody> {
    var body: Body {
        DetachView { try await self.asyncBody }
    }
}

We used our DetachView from before, and await the async body inside. Now we can make Views that compute their body asynchronously. Lets see what that looks like:

struct PostList : AsyncView {
  var asyncBody: some View {
    get async throws {
      let data = try await URLSession.shared.dataTask(
        with: URL(string: "https://jsonplaceholder.typicode.com/posts")!
      )
      let posts = try JSONDecoder().decode([Post].self, from: data)
      List(posts, rowContent: PostRow.init)
    }
  }
}

Using effectful properties, we are able to use try and await in our computed View body. And we get the exact same result as using DetachView:

A list of posts loaded from asyncBody
A list of posts loaded from asyncBody

Thanks for reading to the end! If I made any mistakes, let me know via email or reach out on Twitter.