Running Code Only Once in SwiftUI
This post is brought to you by Emerge Tools, the best way to build on mobile.
I’ve had a few situations in SwiftUI where I only want things to fire once and initially. At face value, this seems like a job for either .onAppear
or the more nascent .task
.
Consider the following:
struct FirstAppearView: View {
@State private var greeting: String = "Initial"
var body: some View {
NavigationStack {
VStack {
Text(greeting)
.onTapGesture {
greeting = "Tapped"
}
NavigationLink(destination: Text("Pushed Text")) {
Text("Push a View")
}
.padding()
}
.onAppear {
greeting = "On Appear"
}
}
}
}
Here’s what happens:
- The
Text(greeting)
view begins with “Initial”, but we never see it. By the time the view is drawn,.onAppear
has been invoked… - …which means that the first thing the user sees in that
Text
view is “On Appear”. - Now, if I tap the
Text
view - it reads “Tapped”. - Finally, if I push another view onto the navigation stack and come back - the
Text
view now reads “On Appear” again - likely not what I wanted. I’d want its last set text to persist, so “Tapped”.
Here’s a gif of this in action (notice how the Text
changes when the navigation stack pops off, since .onAppear
fires again):
At first blush, you might consider moving to .task
. But in reality, it by and large has the same heuristics as .onAppear
does, just simply more suited to Swift’s concurrency model. As such, we’d get the exact same result as we did above. Further, setting values in the init
is also not what you want - something like this also wouldn’t work:
struct FirstAppearView: View {
@State private var greeting: String = "Initial"
init(greeting: String) {
self.greeting = greeting
}
}
This would still result in the Text
view displaying “Initial”. So, taking stock of our current predicament, what we need is:
- Some code to fire initially.
- And, to only do it once.
I looked at the problem a few different ways, and I didn’t really devise any stratagem that seemed viable. So, to solve this, I did what I always do when I don’t know how to achieve something in SwiftUI, ask Ian Keen
. He had a slick modifier that achieves exactly this. The idea is simple but practical: you track a private variable to see if you’ve done the work you want to do, and tie that to the View
life cycle. Here’s what it looks like:
public extension View {
func onFirstAppear(_ action: @escaping () -> ()) -> some View {
modifier(FirstAppear(action: action))
}
}
private struct FirstAppear: ViewModifier {
let action: () -> ()
// Use this to only fire your block one time
@State private var hasAppeared = false
func body(content: Content) -> some View {
// And then, track it here
content.onAppear {
guard !hasAppeared else { return }
hasAppeared = true
action()
}
}
}
With that in place, our issue is solved:
struct FirstAppearView: View {
@State private var greeting: String = "Initial"
var body: some View {
NavigationStack {
VStack {
Text(greeting)
.onTapGesture {
greeting = "Tapped"
}
NavigationLink(destination: Text("Pushed Text")) {
Text("Push a View")
}
.padding()
}
.onFirstAppear {
greeting = "On Appear"
}
}
}
}
Now, we can push and pop the view on and off the navigation stack all day long, and our Text
view’s text persists, no longer hampered by code firing again in .onAppear
or within a .task
.
I wish SwiftUI had first class support for this situation, it seems like a fairly common scenario. I’ve seen a lot of SwiftUI code shove the entire kitchen sink into .onAppear
without really grasping that it’ll likely fire more than once. It leads to some odd bugs.
Until next time ✌️