Safely Updating The View State

If you’ve been using SwiftUI for a while now, you probably hit the problem where you find yourself trying to update the state of a view from inside its body. Usually, Xcode complains during runtime. When it does, you are forced to put your update inside a DispatchQueue closure (not feeling too good about yourself) but you carry on anyway. Does this sound familiar? In this article, we’ll discuss why it sometimes is perfectly fine to apply that technique, but some other times, it’s a no-no (leading to CPU spikes or app crashes).

We will also discuss a technique (a hack would be a more appropriate word), to avoid crashes when accessing environment objects that have not been set up in the hierarchy.

Gist File: https://gist.github.com/swiftui-lab/ddbb145fb7397aa8923a4604e91f9b3f

What Is The View State?

Definitions may vary slightly, but for the sake of this article, this is my definition: The state of a view, is the set of values of all the @State properties of a view at a given time.

With that in mind, what does it mean when Xcode rewards us with the following runtime message:

Modifying state during view update, this will cause undefined behavior.

This message is telling you that SwiftUI can’t handle the view state being modified while the body of the view is being computed. What will SwiftUI do in such a case? Well, as the message says: it’s undefined! Or in other words, it will not do what you might expect. If you haven’t seen this message before, don’t worry, there’s an example a few lines below.

I’m sure you already figured out that if you update the state inside DispatchQueue.main.async { }, the message disappears and things start to work (for the most part). However, you cannot seem to shake the feeling that it is a bit hacky and there should be a better way.

In some cases, there may be alternatives. If they do exist, you should probably go with them. But in some other scenarios, this may be your only answer and there is nothing wrong with it. That is, as far as you know what you are doing. So let’s see if we can understand what is actually going on when we apply this technique.

Updating the State View

When SwiftUI is computing the body of a view, the state should remain unchanged. But you may say, wait a minute! I change state values inside the view body all the time. Look at this, I’m toggling a boolean!

struct MyView: View {
    @State private var flag = false
    
    var body: some View {
        Button("Toggle Flag") {
            self.flag.toggle()
        }
    }
}

But now I say to you… look at it closer. Are you really? The change to the state value is inside a closure. So although the state change is defined inside the view body, it is not actually executed while the body is computed, but when the button is pressed. So you are completely safe there and Xcode will not complain.

Some other places where you can update the view state without getting the runtime error are: onAppear, onDisappear, onPreferenceChange, onEnded, etc.

Now look at this short example:

struct OutOfControlView: View {
    @State private var counter = 0
    
    var body: some View {

        self.counter += 1
        
        return Text("Computed Times\n\(counter)").multilineTextAlignment(.center)
    }
}

At first glance, it seems the counter variable is updated once every time the body of the view is computed. But if you try to execute this code, you will get the runtime message: “Modifying state during view update, this will cause undefined behavior”. So we quickly update the code and change it to:

DispatchQueue.main.async {
    self.counter += 1
}

Now, if you execute the example, you’ll see that the message goes away, but instead, the CPU goes haywire and the counter increases non-stop. Let’s add a fancy CPU gauge to better illustrate. Full code is available in the gist file referenced at the top.

cpu
struct ContentView: View {
    @State private var showOutOfControlView = false
    
    var body: some View {
        VStack(spacing: 10) {
            CPUWheel().frame(height: 150)

            VStack {
                if showOutOfControlView { OutOfControlView() }
            }.frame(height: 80)

            Button(self.showOutOfControlView ? "Hide": "Show") {
                self.showOutOfControlView.toggle()
            }
        }
    }
}

struct OutOfControlView: View {
    @State private var counter = 0
    
    var body: some View {

        DispatchQueue.main.async {
            self.counter += 1
        }
        
        return Text("Computed Times\n\(counter)").multilineTextAlignment(.center)
    }
}

So what is exactly going on? When we update the state inside an async closure, we are saying: Finish computing the view body, and then, update the state. However, since a state change will trigger a view invalidation, the view body will get computed again, a new state change will be scheduled and this story will never end.

Now that we understand what is going on behind the scenes. When is it safe to update the state asynchronously? We’ll deal with that next.

Breaking The Loop

In the following example, we have a geometry effect that rotates an image. Depending on the animation progress, it updates the state of the view to reflect the cardinal direction the arrow is pointing at. In this case, we are updating the view state through a binding, but the effect is the same. We need to delay the update until the body has been computed. We do so, using the DispatchQueue.main.async trick. If you want to learn more about geometry effect, check my other article: Advanced SwiftUI Animations.

Cardinal Direction
struct ExampleView2: View {
    @State private var flag = false
    @State private var cardinalDirection = ""
    
    var body: some View {
        return VStack(spacing: 30) {
            CPUWheel().frame(height: 150)
            
            Text("\(cardinalDirection)").font(.largeTitle)
            Image(systemName: "location.north")
                .resizable()
                .frame(width: 100, height: 100)
                .foregroundColor(.red)
                .modifier(RotateNeedle(cardinalDirection: self.$cardinalDirection, angle: self.flag ? 0 : 360))
            
            
            Button("Animate") {
                withAnimation(.easeInOut(duration: 3.0)) {
                    self.flag.toggle()
                }
            }
        }
    }
}

struct RotateNeedle: GeometryEffect {
    @Binding var cardinalDirection: String
    
    var angle: Double
    
    var animatableData: Double {
        get { angle }
        set { angle = newValue }
    }
    
    func effectValue(size: CGSize) -> ProjectionTransform {
        DispatchQueue.main.async {
            self.cardinalDirection = self.angleToString(self.angle)
        }
        
        let rotation = CGAffineTransform(rotationAngle: CGFloat(angle * (Double.pi / 180.0)))
        let offset1 = CGAffineTransform(translationX: size.width/2.0, y: size.height/2.0)
        let offset2 = CGAffineTransform(translationX: -size.width/2.0, y: -size.height/2.0)
        return ProjectionTransform(offset2.concatenating(rotation).concatenating(offset1))
    }
    
    func angleToString(_ a: Double) -> String {
        switch a {
        case 315..<405:
            fallthrough
        case 0..<45:
            return "North"
        case 45..<135:
            return "East"
        case 135..<225:
            return "South"
        default:
            return "West"
        }
    }
}

As you can see, the CPU load remains unchanged during the animation. Why is that? To understand it better, we will add a couple of print statements:

struct ExampleView2: View {
    @State private var flag = false
    @State private var cardinalDirection = ""
    
    var body: some View {
        print("body called: cardinalDirection = \(cardinalDirection)")
        
        return VStack(spacing: 30) { ... }
     }
}

and

DispatchQueue.main.async {
    self.cardinalDirection = self.angleToString(self.angle)
    print("effectValue called: cardinalDirection = \(self.cardinalDirection)")
}

If we look at the output during the animation, we will see this:

effectValue called: cardinalDirection = North
body called: cardinalDirection = North
effectValue called: cardinalDirection = North
effectValue called: cardinalDirection = North (repeated 46 times)
effectValue called: cardinalDirection = West
body called: cardinalDirection = West
effectValue called: cardinalDirection = West
effectValue called: cardinalDirection = West (repeated 46 times)
effectValue called: cardinalDirection = South
body called: cardinalDirection = South
effectValue called: cardinalDirection = South
effectValue called: cardinalDirection = South (repeated 46 times)
effectValue called: cardinalDirection = East
body called: cardinalDirection = East
effectValue called: cardinalDirection = East
effectValue called: cardinalDirection = East (repeated 46 times)
effectValue called: cardinalDirection = North
body called: cardinalDirection = North
effectValue called: cardinalDirection = North
effectValue called: cardinalDirection = North (repeated 46 times)

As you can see, SwiftUI is wise enough to know the body does not need to be re-computed every time, only when the state really changed. That means that unless you set a different value in the state, the view will not get invalidated. In the case above: only when the cardinal direction is different it will request a new body.

That is why the CPU does not go crazy. By assigning the same value it had before, we are breaking the never-ending loop we saw in the previous example.

Unexpected Loops

So far, things are pretty clear. However, there might be some scenarios, where you may be stuck into a never-ending loop without suspecting it. Consider this code:

struct ContentView: View {
    @State private var width: CGFloat = 0.0
    
    var body: some View {
        Text("Width = \(width)")
            .font(.largeTitle)
            .background(WidthGetter(width: self.$width))
    }
    
    struct WidthGetter: View {
        @Binding var width: CGFloat
        
        var body: some View {
            GeometryReader { proxy -> AnyView in
                DispatchQueue.main.async {
                    self.width = proxy.frame(in: .local).width
                }
                return AnyView(Color.clear)
            }
        }
    }
}

It seems the code should just output a text string with the width of the text. By now you will probably realize quickly that this may (or may not happen) as we expect. Depending on the font you chose, the width will never stabilize. For example, with the default font, not all numbers have the same width, potentially leading to a never-ending loop.

However, if we change the font to a fixed size font:

.font(.custom("Menlo", size: 32))

The view will reach a point where the loop ends. With the Menlo font, all numbers have the same size, so no matter the value, the text view width will remain stable.

This is a small example that demonstrates how careful you must be when updating the view state using an async closure. Especially delicate are those updates that affect layout. And even if everything seems to work fine, remember that people have the bad habit of rotating their devices! Something working fine when in portrait may go haywire when rotated to landscape and vice-versa.

One More Thing

Since we are dealing with making our apps more stable and robust, I’d like to mention the crash that occurs when an EnvironmentObject is used inside a view, but none has been set higher in the hierarchy. I’m sure you know what I am talking about, but just in case, here’s an example that will make the app crash:

class MyObservable: ObservableObject {
    @Published var someValue = "Here's a value!"
}

struct ContentView: View {
    var body: some View {
        MyView()
    }
}

struct MyView: View {
    @EnvironmentObject var model: MyObservable

    var body: some View {        
        return Text(model.someValue)
    }
}

The app will terminate with the following error:

Fatal error: No ObservableObject of type MyObservable found.
A View.environmentObject(_:) for MyObservable may be missing as an ancestor of this view.

The reason, as I’m sure you already know, is that we did not set a value with the .environmentObject(). When the View tries to use it, the crash occurs (not earlier).

As far as I know, there is no method to check for the existence of a value, before we try to reference it. However, it would be nice if there was. We could make the view have some “default” look, depending on whether there is a value or not. As things stand, if that is what you need, it may be better to create a custom EnvironmentKey.

Having said all that, I did promise a hack at the introduction of this article, didn’t I? The hack uses reflection and it is implemented as a View extension. I must say though, that I only use this when prototyping or testing things out. Not in production code. The internal _store property in ObservableObject may change at any time, and your production code would break!

extension EnvironmentObject {
    var safeToUse: Bool {
        return (Mirror(reflecting: self).children.first(where: { $0.label == "_store" })?.value as? ObjectType) != nil
    }
}

What we are doing in the extension, is using reflection to determine if the ObservableObject has an actual object backing it. This is how you would use the extension (don’t forget the leading underscore, as in _model)

class MyObservable: ObservableObject {
    @Published var someValue = "Here's a value!"
}

struct ExampleView3: View {
    var body: some View {
        VStack(spacing: 20) {
            MyView()
            
            MyView()
                .environmentObject(MyObservable())
        }
    }
}

struct MyView: View {
    @EnvironmentObject var model: MyObservable

    var body: some View {

        let txt = _model.safeToUse ? model.someValue : "No Value"
        
        return Text(txt)
    }
}

In Summary

I hope this article helps you write more robust SwiftUI code. The techniques of scheduling the altering of the View state for later may be very helpful, but caution should be exercised always. Feel free to leave your comment below and if you would like to be notified when new articles come out, follow me on twitter! Until next time.

7 thoughts on “Safely Updating The View State”

  1. Thank you for a very good article that makes progress in parting the curtains for me of how and when SwiftUI updates a view.

    I’m working on an app that has a list of countdowns. In UIKit, in the TableViewController I would set a timer, set run loop mode to .common, poll for visible cells, then call a method within the cell to update its countdown. The beauty was one single timer and a really performant list of countdowns.

    That pattern won’t work in SwiftUI. Any insight you might have as to a good pattern would be welcomed.

    Reply
    • Hi Jim, I think you may still have a single timer, and make it update a single ObservableObject. Then you inject your observable object into the List hierarchy, with .environmentObject(). That way, all your rows will be updated when your single observable object changes. I have to say though, List is not very good at performance yet, so I don’t know if it will be clever enough to not compute the body of the views that are not visible. You’ll have to test it yourself…

      Reply
  2. Xcode 11.4 beta compiler warning at line 219:

    var thread_list: thread_act_array_t? = UnsafeMutablePointer(mutating: [thread_act_t]())

    Initialization of ‘UnsafeMutablePointer’ (aka ‘UnsafeMutablePointer’) results in a dangling pointer
    1. Implicit argument conversion from ‘[thread_act_t]’ (aka ‘Array’) to ‘UnsafePointer’ (aka ‘UnsafePointer’) produces a pointer valid only for the duration of the call to ‘init(mutating:)’
    2. Use the ‘withUnsafeBufferPointer’ method on Array in order to explicitly convert argument to buffer pointer valid for a defined scope

    Reply
  3. while using async can randomly avoid the https://swiftui-lab.com/wp-content/uploads/2019/11/state-error-1536×61.png error, this is no guarantee, because it’s possible for asynchronous work to be scheduled on the main thread *during* a view graph update, as I discovered this week.

    As you point out, there are multiple ways to scheduled work safely, including onAppear, button actions, and also `View.onReceieve` which can be used with a publisher – which is a great way to collect and experience external signals such as callbacks, notifications and timers.

    Reply

Leave a Comment

By continuing to use the site, you agree to the use of cookies. more information

The cookie settings on this website are set to "allow cookies" to give you the best browsing experience possible. If you continue to use this website without changing your cookie settings or you click "Accept" below then you are consenting to this.

Close