-1

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())
Scott Wood
  • 364
  • 5
  • 11
  • 2
    did you try `willSet`? – ChrisR Feb 28 '23 at 21:21
  • Does this answer your question? [How is didSet called again when setting inside through a function?](https://stackoverflow.com/questions/54028478/how-is-didset-called-again-when-setting-inside-through-a-function) – Dalija Prasnikar Mar 01 '23 at 10:03
  • @ChrisR I did try `willSet` but am unable to set the property in that observer. The [Swift documentation](https://developer.apple.com/library/ios/documentation/Swift/Conceptual/Swift_Programming_Language/Properties.html) also alludes to that (although not definitively). – Scott Wood Mar 01 '23 at 14:13
  • @DalijaPrasnikar Thank you for the link but it confirms to me that this is a bug when using certain property wrappers. [This answer](https://stackoverflow.com/a/29363412/4896756) extracts out the pertinent Swift documentation that leads me to believe I should be able to do this. [This answer](https://stackoverflow.com/a/54128975/4896756) may give a hint as to why I'm unable to do this. Essentially, in the didSet you can _only_ set the property using direct access memory, but I'm guessing that these property wrappers are hiding the ability to set the real property. – Scott Wood Mar 01 '23 at 14:24
  • Did you notice that answer in linked question states that since Swift 5 there are additional restrictions to what constitutes direct memory access. I don't know what is the status for most recent compiler, but this shows a trend. If you think this is a bug then file a bug report with Apple. – Dalija Prasnikar Mar 01 '23 at 14:37
  • Your code shows the issue, abut it is not clear what you want to achieve with such code. What is the purpose of a property that will just set itself to different value every time? That falls out of the "restricting input" purpose, as you can easily break out of loop in such scenarios because you will not set property again if its value is within acceptable values. – Dalija Prasnikar Mar 01 '23 at 14:40
  • By setting the value of `isOn` in `didSet`, you trigger the `didSet` again, could that be the cause of the behavior? – Baglan Mar 01 '23 at 14:51
  • @DalijaPrasnikar As mentioned in the question, imagine the toggle controls a state that is stored on a server. If the network call fails, the toggle is in the wrong state - it should be set back to the original state. Another example is a toggle to turn on biometrics, when the toggle is turned on - several checks need to be made before the toggle state should actually be turned on. On any of those failures, the toggle should go back to off state. – Scott Wood Mar 02 '23 at 14:58
  • @Baglan Yes, that is the cause of the issue. But this is allowable, intentional functionality that _shouldn't_ trigger `didSet` and _doesn't_ if no property wrapper is used. It is only triggering it when using `@Published` property wrappers. The example playground shows the differences. – Scott Wood Mar 02 '23 at 15:09
  • I would assume that with property wrapper you no longer have direct memory access. I don't have any references, it is just logical conclusion. As linked question shows there are documented restrictions in what didSet can do, and in such cases restrictions mean there are deeper reasons why some behavior cannot be implemented. So most likely this is not a bug. – Dalija Prasnikar Mar 03 '23 at 08:31
  • As far as solution is concerned, I should have been more clear - I don't understand why you need to entangle yourself in a loop. Your own code show you how you can break out of the loop (just without that many iterations). Try syncing with the network state, and if it fails revert to old value and use additional flag to indicate failure, then in next didSet interaction you will know that you have come from failed sync and just don't revert to old value again, and reset the network state flag. – Dalija Prasnikar Mar 03 '23 at 08:39
  • @ScottWood You have 2 answers that seem to address your bounty criteria. Please consider accepting one of them. – thedp Mar 06 '23 at 14:01

3 Answers3

0

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())
thedp
  • 8,350
  • 16
  • 53
  • 95
0

The cause of the endless loop is pretty clear: You modify isOn in its didSet property observer

  • which calls didSet again
  • which modifies isOn
  • which calls didSet
  • which modifies isOn
  • which calls didSet
  • which modifies isOn
  • and so on … and so on … and so on ...

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)
    }
}
vadian
  • 274,689
  • 30
  • 353
  • 361
-1

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.

Scott Wood
  • 364
  • 5
  • 11