Async SwiftUI
The code you're about to see is based on Xcode 13 beta 5. Things have allready changed since the first beta, and may change again, so be prepared for the final look of concurrency and SwiftUI to be a little different. If things change again, I'll update this post.
That said, we can still get a grasp on some concepts.
The basics
SwiftUI has a simple modifier for attatching a task to a view, that will fire off the task when it appears, .task()
. In this example, when the view loads, we fetch the characters for the list. We also handle an error by simply showing the user an alert.
struct BasicCharacterListView: View {
@StateObject var characterStore = CharacterStore()
@State var characters: [Character] = []
@State var showFailure: Bool = false
var body: some View {
ScrollView {
LazyVStack {
ForEach(characters, id: \.self) { character in
CharacterView(character: character)
}
}
}
.alert("Something went wrong.", isPresented: $showFailure) {
Button("OK", role: .cancel) { }
}
.task {
do {
self.characters = try await characterStore.fetchCharacters()
} catch {
self.showFailure = true
}
}
}
}
struct BasicCharacterListView: View {
@StateObject var characterStore = CharacterStore()
@State var characters: [Character] = []
@State var showFailure: Bool = false
var body: some View {
ScrollView {
LazyVStack {
ForEach(characters, id: \.self) { character in
CharacterView(character: character)
}
}
}
.alert("Something went wrong.", isPresented: $showFailure) {
Button("OK", role: .cancel) { }
}
.task {
do {
self.characters = try await characterStore.fetchCharacters()
} catch {
self.showFailure = true
}
}
}
}
The modifier actually has a parameter for the priority of the task, but we don't have to give it here as the deafult userInitiated
works well for us.
You might want to consider a different priority if you're doing something non-important, like firing analytics events. This is how that would look.
struct CharacterView: View {
@EnvironmentObject var analyticsManager: AnalyticsManager
let character: Character
var body: some View {
HStack(alignment: .center, spacing: 12) {
avatarView
informationView
Spacer()
}
.task(priority: .background) {
await analyticsManager.characterShown(character: character)
}
}
var avatarView: some View { ... }
var informationView: some View { ... }
}
struct CharacterView: View {
@EnvironmentObject var analyticsManager: AnalyticsManager
let character: Character
var body: some View {
HStack(alignment: .center, spacing: 12) {
avatarView
informationView
Spacer()
}
.task(priority: .background) {
await analyticsManager.characterShown(character: character)
}
}
var avatarView: some View { ... }
var informationView: some View { ... }
}
Both of these examples are great for simple work, like that initial fetch, but there's more options available to us.
Taking it further
New in Xcode 13 beta 5 is the option to have a task fire on change of an equatable value. An example for this could be a view that shows either characters or episodes, and when the user changes the segmented control we change whats loaded. Lets take a look at how that works.
@State var viewState: CharactersAndEpisodesViewState = .loading
@State var characters: [Character]
@State var episodes: [Episode]
@State var selection: CharactersAndEpisodesViewOption = .characters
var body: some View {
VStack {
Picker(selection: $selection, label: Text("Would you like to see characters or episodes?")) {
Text("Characters").tag(CharactersAndEpisodesViewOption.characters)
Text("Episodes").tag(CharactersAndEpisodesViewOption.episodes)
}
.pickerStyle(SegmentedPickerStyle())
.padding()
ScrollView {
LazyVStack {
contentBody
.padding()
}
}
}
.task(id: selection, {
await loadContent()
})
.navigationTitle("Rick & Morty")
.background(Color(uiColor: .systemGroupedBackground))
}
@State var viewState: CharactersAndEpisodesViewState = .loading
@State var characters: [Character]
@State var episodes: [Episode]
@State var selection: CharactersAndEpisodesViewOption = .characters
var body: some View {
VStack {
Picker(selection: $selection, label: Text("Would you like to see characters or episodes?")) {
Text("Characters").tag(CharactersAndEpisodesViewOption.characters)
Text("Episodes").tag(CharactersAndEpisodesViewOption.episodes)
}
.pickerStyle(SegmentedPickerStyle())
.padding()
ScrollView {
LazyVStack {
contentBody
.padding()
}
}
}
.task(id: selection, {
await loadContent()
})
.navigationTitle("Rick & Morty")
.background(Color(uiColor: .systemGroupedBackground))
}
When the value changes, the task will start again. In this case, changing the selection will trigger our load content function.
There's something to be aware of here - If you send off one task, then the value changes and it sends off another, it wont actually cancel your code for the initial one, so you'll have to check if cancelled inside the code. Lets look inside the loadContent
function and see how we can be cancellation aware.
func loadContent() async {
do {
self.viewState = .loading
switch selection {
case .characters:
self.characters = try await characterStore.fetchCharacters()
self.viewState = .showCharacters
case .episodes:
self.episodes = try await episodeStore.fetchEpisodes()
self.viewState = .showEpisodes
}
} catch {
self.viewState = .error
}
}
func loadContent() async {
do {
self.viewState = .loading
switch selection {
case .characters:
self.characters = try await characterStore.fetchCharacters()
self.viewState = .showCharacters
case .episodes:
self.episodes = try await episodeStore.fetchEpisodes()
self.viewState = .showEpisodes
}
} catch {
self.viewState = .error
}
}
If this function was called multiple times now, we could potentially get to a point where the control says episodes, but the characters are showing. This could happen quite easily, as the request for characters takes longer. Lets add a cancellation check to make sure this doesn't happen. We do this with the special Task.isCancelled
variable.
func loadContent() async {
do {
self.viewState = .loading
switch selection {
case .characters:
self.characters = try await characterStore.fetchCharacters()
guard !Task.isCancelled else { return }
self.viewState = .showCharacters
case .episodes:
self.episodes = try await episodeStore.fetchEpisodes()
guard !Task.isCancelled else { return }
self.viewState = .showEpisodes
}
} catch {
self.viewState = .error
}
}
func loadContent() async {
do {
self.viewState = .loading
switch selection {
case .characters:
self.characters = try await characterStore.fetchCharacters()
guard !Task.isCancelled else { return }
self.viewState = .showCharacters
case .episodes:
self.episodes = try await episodeStore.fetchEpisodes()
guard !Task.isCancelled else { return }
self.viewState = .showEpisodes
}
} catch {
self.viewState = .error
}
}
The difference here, is that we make sure we only update the view state if this task wasn't cancelled.
Conclusion
SwiftUI has always been a very powerful tool, and I think that the clean integrations with concurrency open the door for some really lovely code. What do you think of having this logic attatched to the view?
You can find the code samples in my repo as always.
If you fancy reading a little more, or sharing your experiences, I’m @SwiftyAlex on twitter.