Home > Software design >  SwiftUI/Combine notification overwrites my struct with incorrect values
SwiftUI/Combine notification overwrites my struct with incorrect values

Time:01-01

I've posted a minimal example to github.

This question seems like it might be related, but I couldn't make any progress.

I have an @ObservableObject class that contains a @Published struct. I have a Slider bound to a field in that struct, and two observers on the struct. One of the observers makes a change to a different field in the struct. When I read the updated struct during notification, everything looks right. After the notification, my struct has been overwritten. I can't find where it's happening in the debugger; it breaks on @main. I've put diagnostics all over the place in my real app, and can't figure out where it's being set to the wrong value.

It seems like what's happening is that SwiftUI is making copies of the struct. I could understand making copies if the struct weren't part of an object, but I don't get why it would do it here.

Changing my struct to a class causes the overwrite to stop. But my struct really shouldn't be a class; it's kind of like CGRect in my example. So I wonder:

  • Why does it behave this way with a struct?
  • Is this a misuse of Combine or SwiftUI or Swift?
  • Is something I'm doing here generally a bad practice?
  • Is there a better way to do it?

Here's the main app module:

@ObservedObject var arena = Arena()

var body: some Scene {
    WindowGroup {
        ContentView(arena: arena).onAppear { arena.postInit() }
    }
}

Here's ContentView:

@ObservedObject var arena: Arena

func bind() -> Binding<Double> {
    // A sanity check of sorts, to verify that I'm really
    // reading and writing the slider values.
    Binding(
        get: { arena.frame.origin.x },
        set: {
            arena.frame.origin.x = $0
            print("Binding (x: \(arena.frame.origin.x), y: \(arena.frame.origin.y))")
        }
    )
}

var body: some View {
    // Slide this slider around; it will write to the X in the
    // frame struct.
    Slider(
        value: bind(), in: -1.0...1.0,
        label: { Text("Origin.x \(arena.frame.origin.x)") }
    )

    Button("Check values") {
        print("Button (x: \(arena.frame.origin.x), y: \(arena.frame.origin.y))")
    }
}

And here's Arena:

@Published var frame: CGRect = .zero

var xObserver: AnyCancellable?

func postInit() {
    // Whenever the X is changed, update the Y
    xObserver = $frame
        .removeDuplicates {
            $0.origin.x == $1.origin.x
        }
        .sink { [weak self] in
            guard let myself = self else { return }
            myself.frame.origin.y = $0.origin.x
            print("Writing (x: \(myself.frame.origin.x), y: \(myself.frame.origin.y))")
        }
}

If you run the app and move the slider around, you can see in the console output that I'm writing to the x/y values in the rectangle, but when you click the button you can see that we're reading the rectangle values and getting zeros.

One clue is that if I add RunLoop.main or DispatchQueue.main to my observer as shown below, the correct values are read back when clicking the button, although they're not correct while the slider is being moved around.

// In the Arena class
func postInit() {
    xObserver = $frame
        .removeDuplicates {
            $0.origin.x == $1.origin.x
        }

        // Adding this line changes the behavior; it
        // doesn't seem to clear up the whole issue, but it's a
        // clue. Note that this works the same using
        // DispatchQueue.main, but not using ImmediateScheduler.shared
        .receive(on: RunLoop.main)

        .sink { [weak self] in
            guard let myself = self else { return }
            myself.frame.origin.y = $0.origin.x
            print("Writing (x: \(myself.frame.origin.x), y: \(myself.frame.origin.y))")
        }
}

CodePudding user response:

Look in the documentation for the Published property wrapper

There you will find the sentence:

When the property changes, publishing occurs in the property’s willSet block, meaning subscribers receive the new value before it’s actually set on the property

With the unspoken caveat that the value of the property will be set after the subscribers receive the new value.

So your $frame publisher fires in the willSet of the frame property and it is passed the value that frame will be set to after publishing happens. Your subscriber changes the frame of the object, and prints that new value in the "Writing" string.

But once the publishing is done the system does the set part of the property change and carefully overwrites the changes you made in your subscriber with the original value.

When you delay when your subscriber, then instead of having the subscription make the change immediately, it publishes a block on the main queue to be run later so you don't see the value change during the "current" event handling cycle that's changing the slider.

  • Related