I'm attempting to use SwiftUI Toggle
to control the state of a boolean stored on the server.
On the didSet
of the @Published
property, I'm making a network call to persist the state. Successful network calls work great. On failure, I am attempting to set the property back to its previous state. This causes an infinite loop. This seemingly only happens if the property is wrapped (@Published
, @State
, @Binding
).
When a property is not using a wrapper, the dev has full control to programmatically determine what the value of the property should be and can set it without the didSet
being called infinitely. This is intentional - it's a primary example of why didSet
even exists - to allow the user to validate, filter, restrict, etc. the result and then set it to what is allowable.
Presumably this has to do with the property wrappers using Combine
and listening to any state changes and triggering the property observers endlessly.
Is there a way to stop this behavior? It feels like a bug. If not, any proposals on how to handle my request?
Here is a simple playground that shows the issue:
import SwiftUI
import PlaygroundSupport
class VM: ObservableObject {
var loopBreaker = 0
var networkSuccess = false
@Published var isOn: Bool = false
{
didSet {
print("isOn: \(isOn), oldValue \(oldValue)")
// Code only to break loop
loopBreaker += 1
if loopBreaker > 4 {
print("break loop!")
networkSuccess.toggle()
loopBreaker = 0
}
///////////////////////////////////////////////
// call server to store state
guard networkSuccess else {
// ENDLESS LOOP!
isOn = oldValue
return
}
}
}
var enabled: Bool = false
{
didSet {
print("enabled: \(enabled), oldValue \(oldValue)")
enabled = oldValue
print("enabled: \(enabled), oldValue \(oldValue)")
}
}
}
struct ContentView: View {
@ObservedObject var vm = VM()
var body: some View {
Toggle("Hello World", isOn: $vm.isOn)
.onChange(of: vm.isOn) {
vm.enabled = $0
}
}
}
PlaygroundPage.current.setLiveView(ContentView())
Output:
isOn: true, oldValue: false
isOn: false, oldValue: true
isOn: true, oldValue: false
isOn: false, oldValue: true
isOn: true, oldValue: false
break loop!
enabled: true, oldValue: false
enabled: false, oldValue: false
Note that I show both cases here, an @Published
property: isOn
and an unwrapped, generic swift property: enabled
.
I also added a way to break the loop so your entire Xcode doesn't crash or become unresponsive.
Additional info:
@DalijaPrasnikar Pointed me to an answer that may provide a hint as to the problem. According to this, you can only set the property in a didSet
if you have direct memory access
. Perhaps I don't have that when these properties are wrapped by these types? But how do I gain direct memory access of a wrapped property
Here is a link to an answer that extracts out the swift documentation that leads me to believe I should be able to do this. This same documentation alludes to the fact that a property can not be set in the willSet
observer.
Below is an even more concise playground to show the differences:
class VM: ObservableObject {
@Published var publishedBool: Bool = false { didSet {
publishedBool = oldValue // ENDLESS LOOP - Will need to comment out to see nonWrappedBool functionality
} }
var nonWrappedBool: Bool = false { didSet {
nonWrappedBool = oldValue // WORKS AS EXPECTED - Must comment out publishedBool `didSet` in order for this to get hit
} }
}
struct ContentView: View {
@ObservedObject var vm = VM()
var body: some View {
Toggle("Persist this state on server", isOn: $vm.publishedBool)
.onChange(of: vm.publishedBool) {
vm.nonWrappedBool = $0 // OnChange won't be called unless publishedBool `didSet` is commented out. Endless loop occurs first
}
}
}
PlaygroundPage.current.setLiveView(ContentView())