[SC]()

iOS. Apple. Indies. Plus Things.

Running Code Only Once in SwiftUI

// Written by Jordan Morgan // Nov 1st, 2022 // Read it in about 2 minutes // RE: 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:

  1. The Text(greeting) view begins with “Initial”, but we never see it. By the time the view is drawn, .onAppear has been invoked…
  2. …which means that the first thing the user sees in that Text view is “On Appear”.
  3. Now, if I tap the Text view - it reads “Tapped”.
  4. 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):

Demo of the outlined scenario above running via Xcode Previews.

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:

  1. Some code to fire initially.
  2. 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 ✌️

···

Spot an issue, anything to add?

Reach Out.