- Published on
A Lesson about Implicitly Copying States in SwiftUI
While I was working on Puddles – a new SwiftUI architecture – I stumbled upon a very strange bug. I had a @State
that wasn't updating the view it belonged to. It took me half a day to figure out what was going on and the actual cause is quite interesting.
I have boiled down my code to include only the pieces necessary to reproduce the bug.
The setup is fairly easy.
I have a StateWrapper
struct that conforms to DynamicProperty
, allowing it to hold state that a SwiftUI view can access and react to. Inside, there is another type Inner
, which stores a closure that should, at some point, toggle the state variable flag
.
Running this code, unfortunately, results in the view not updating, even though handle()
is properly called and the state has indeed flipped. why?
Figuring out what's going on with the code is not that easy. Let's start with initialization.
Implicit capure of self
is something that can easily be forgotten about when writing code, especially when it's comprised of mostly value types.
Take a look at the initializer of the StateWrapper
type:
struct StateWrapper: DynamicProperty { // ... init() { inner = Inner(handler: handle) } // ...}
The Inner
initializer takes a closure and stores it, making that closure escaping. In StateWrapper
, we then pass the signature of the handle
method as the argument.
Let's unpack this. Passing a function signature as an argument is syntactic sugar for something like this:
struct StateWrapper: DynamicProperty { // ... init() { inner = Inner(handler: { self.handle() }) // Error: Escaping closure captures mutating 'self' parameter } // ...}
Interestingly though, this code does not compile. We get a compiler error stating: »Escaping closure captures mutating 'self' parameter« .
This is because an initializer is technically a mutating function, and letting self
escape in such a context has all sorts of unexpected consequences1.
That's why, in Swift 3.0, the above-mentioned error was introduced:
Capturing an inout parameter, including self in a mutating method, becomes an error in an escapable closure literal, unless the capture is made explicit (and thereby immutable).
That quote also gives us an immediate solution to make the code compile.
We have to make the capture of self
explicit:
struct StateWrapper: DynamicProperty { // ... init() { inner = Inner(handler: { [self] in self.handle() }) // Compiles } // ...}
Since StateWrapper
is a value type, capturing self
actually creates an immutable copy of it.
And that is also what happens, when we pass handle
as a function signature. The compiler creates a copy of self
, which is then stored inside Inner
. It's just all done implicitly, so it's easy to overlook.
So, this explains the problem, right? We're storing a copy of self
, so changing the value of flag
on that copy obviously shouldn't affect the view and that's why we're seeing the bug.
However, it's actually not that easy. There's more to it.
See, it's totally fine to create copies of structs containing view states2. In fact, this happens all the time in SwiftUI. The entire framework is built upon the notion of recreating view hierarchies of deeply nested value types, any time some state changes.
This works, because views don't actually store any of their own states.
A @State
(and for that matter a @StateObject
) is just a pointer to an external storage managed by SwiftUI.
That pointer is determined by the view's identity3, which is guaranteed to be stable over the entire lifetime of a view.
Regardless of how many copies are made, SwiftUI will make sure that each copy has access to the correct values that belong to the view.
The problem, however, is that the lifetime of ContentView
hasn't started at the time of its – and therefore StateWrapper
's – initalization.
So when we're capturing self
, we're actually creating a copy of a state (flag
) that isn't attached to a view yet.
Its pointer is effectively empty.
And that is the root cause of the problem! We are making this copy too early, way before SwiftUI has started managing it. This is very subtle and usually Xcode shows runtime warnings for these kinds of scenarios but this one might have slipped through the radar.
So what's the solution? It's actually really simple:
struct StateWrapper: DynamicProperty { // ... var inner: Inner { Inner(handler: handle) } init() {} // ...}
Turning inner
into a computed property delays the initialization and when it's eventually accessed, the view's lifetime has already started, so copying self
is not a problem. As a bonus, inner
doesn't have to be an Optional anymore.
This was a surprisingly hard problem to debug and figure out but I am fascinated by the different mechanisms at play here. Sometimes, Swift and SwiftUI are not as expressive as one might think.
-
This mostly applies to views but in our case,
StateWrapper
conforms toDynamicProperty
, which tells SwiftUI that this struct contains state that it should manage and attach to a view. ↩ -
I highly recommend watching the WWDC21 Session Demystify SwiftUI. It might be the single most important piece of content there is, when it comes to getting a grasp on SwiftUI. ↩