Reputation: 404
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.
@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())
Upvotes: -1
Views: 1123
Reputation: 404
The solution we went with is very similar to @vadian answer (but without the binding) and effectively what @DalijaPrasnikar proposed in the comments - add another property to know the state of the system. This feels inherently anti-SwiftUI (declarative) UI development. Every solution proposed has created a state machine.
It feels like I should be able to write code that will restrict/modify/filter the UI without needing additional properties to maintain state - but seemingly that can't be done.
With that being said, here is our solution:
import SwiftUI
import PlaygroundSupport
class VM: ObservableObject {
private let networkFailed = true
@Published var isSaving: Bool = false
@Published var publishedBool: Bool = false
{
didSet {
guard !isSaving else {
print("ignore didSet")
return
}
isSaving = true
Task {
try! await Task.sleep(for: .seconds(2))
if networkFailed {
print("revert state")
publishedBool = oldValue
}
isSaving = false
}
}
}
}
struct ContentView: View {
@ObservedObject var vm = VM()
var body: some View {
Toggle("Persist this state on server", isOn: $vm.publishedBool)
}
}
PlaygroundPage.current.setLiveView(ContentView())
The key to this code is the isSaving
property. We set it to true
before the asynchronous code, set the property to our desired value (if need be) - this will call didSet
again but the guard
breaks the loop, then set isSaving
to false at the end of the function.
Upvotes: -1
Reputation: 285082
The cause of the endless loop is pretty clear: You modify isOn
in its didSet
property observer
didSet
againisOn
didSet
isOn
didSet
isOn
Conclusion: The Toggle
cannot be reset in a @Published
property observer.
A reliable alternative is a custom Binding
in the view.
In VM create an additional Bool
property disableToggle
.
Create also a function which first disables the Toggle
, then performs the network request (here a sleeping Swift Concurrency Task
) and on failure switches the Toggle
back. Finally it re-enables the Toggle
@MainActor
class VM: ObservableObject {
var networkSuccess = false
@Published var isOn: Bool = false
@Published var disableToggle: Bool = false
public func networkRequest() {
disableToggle = true
Task {
try! await Task.sleep(for: .seconds(2))
networkSuccess = [true, false].randomElement()!
if !networkSuccess { isOn.toggle() }
disableToggle = false
}
}
}
In the view add the custom Binding
which just calls the network function in the set
scope
struct ContentView: View {
@StateObject var vm = VM()
var body: some View {
let toggleBinding = Binding(
get: { return vm.isOn },
set: {
vm.isOn = $0
vm.networkRequest()
}
)
Toggle("Persist this state on server", isOn: toggleBinding)
.disabled(vm.disableToggle)
}
}
Upvotes: 2
Reputation: 8508
Here is an even simpler code example to demonstrate the issue you're experiencing.
import SwiftUI
import PlaygroundSupport
class VM: ObservableObject {
@Published var publishedBool: Bool = false {
didSet {
print("publishedBool didSet \(oldValue) -> \(publishedBool)")
publishedBool = oldValue
}
}
}
struct ContentView: View {
@ObservedObject var vm = VM() // BTW, this should be @StateObject because you init it here, and not injecting the VM.
var body: some View {
Button("press") {
vm.publishedBool = true
}
}
}
PlaygroundPage.current.setLiveView(ContentView())
When you press the button, the endless loop starts.
I think this simpler example demonstrates better what's going on. Notice the output:
publishedBool didSet false -> true
publishedBool didSet true -> false
publishedBool didSet false -> true
publishedBool didSet true -> false
publishedBool didSet false -> true
publishedBool didSet true -> false
...
...
...
And btw, if you try to use willSet
, you will end up with the same endless loop.
The @Published
is the reason for this odd behavior;
If you remove @Published
it doesn't happen. Interesting!
Without the @Published
it's just a property without the "magical" logic behind the scenes, so it works as expected.
I'm not 100% sure why this enters into this endless loop because of @Published
, but I assume it's probably because this property wrapper has an internal logic that causes this continues update. The didSet
you placed is not on the value change, but the property wrapper struct change. It could be a bug in @Published
implementation.
So you need to consider alternative solutions, which are a better approach anyway. You tried to set a side effect in the VM from the UI onChanged
. Instead, you should listen to changes in the VM and update the VM.
import SwiftUI
import PlaygroundSupport
import Combine
class VM: ObservableObject {
private var cancellables = Set<AnyCancellable>()
var loopBreaker = 0
var networkSuccess = false
@Published var isOn: Bool = false
var enabled: Bool = false
{
didSet {
print("enabled: \(enabled), oldValue \(oldValue)")
enabled = oldValue
print("enabled: \(enabled), oldValue \(oldValue)")
}
}
init() {
$isOn.sink { [weak self] isOn in
print("isOn changed: \(isOn)")
guard let self else { return }
self.enabled = isOn // changed inside the VM
guard isOn else { return }
// Code only to break loop
self.loopBreaker += 1
if self.loopBreaker > 4 {
print("break loop!")
self.networkSuccess.toggle()
self.loopBreaker = 0
}
///////////////////////////////////////////////
// call server to store state
guard self.networkSuccess else {
self.isOn = false
return
}
}
.store(in: &cancellables)
}
}
struct ContentView: View {
@StateObject var vm = VM()
var body: some View {
Toggle("Hello World", isOn: $vm.isOn)
}
}
PlaygroundPage.current.setLiveView(ContentView())
Upvotes: 1