Why does my SwiftUI view not get onChange updates from a @Binding member of a @StateObject?
Asked Answered
V

2

5

Given the setup I've outlined below, I'm trying to determine why ChildView's .onChange(of: _) is not receiving updates.

import SwiftUI

struct SomeItem: Equatable {
    var doubleValue: Double
}

struct ParentView: View {
    @State
    private var someItem = SomeItem(doubleValue: 45)

    var body: some View {
        Color.black
            .overlay(alignment: .top) {
                Text(someItem.doubleValue.description)
                    .font(.system(size: 50))
                    .foregroundColor(.white)
            }
            .onTapGesture { someItem.doubleValue += 10.0 }
            .overlay { ChildView(someItem: $someItem) }
    }
}

struct ChildView: View {
    @StateObject
    var viewModel: ViewModel

    init(someItem: Binding<SomeItem>) {
        _viewModel = StateObject(wrappedValue: ViewModel(someItem: someItem))
    }

    var body: some View {
        Rectangle()
            .fill(Color.red)
            .frame(width: 50, height: 70, alignment: .center)
            .rotationEffect(
                Angle(degrees: viewModel.someItem.doubleValue)
            )
            .onTapGesture { viewModel.changeItem() }
            .onChange(of: viewModel.someItem) { _ in
                print("Change Detected", viewModel.someItem.doubleValue)
            }
    }
}


@MainActor
final class ViewModel: ObservableObject {
    @Binding
    var someItem: SomeItem

    public init(someItem: Binding<SomeItem>) {
        self._someItem = someItem
    }

    public func changeItem() {
        self.someItem = SomeItem(doubleValue: .zero)
    }
}

Interestingly, if I make the following changes in ChildView, I get the behavior I want.

  • Change @StateObject to @ObservedObject

  • Change _viewModel = StateObject(wrappedValue: ViewModel(someItem: someItem)) to viewModel = ViewModel(someItem: someItem)

From what I understand, it is improper for ChildView's viewModel to be @ObservedObject because ChildView owns viewModel but @ObservedObject gives me the behavior I need whereas @StateObject does not.

Here are the differences I'm paying attention to:

  • When using @ObservedObject, I can tap the black area and see the changes applied to both the white text and red rectangle. I can also tap the red rectangle and see the changes observed in ParentView through the white text.
  • When using @StateObject, I can tap the black area and see the changes applied to both the white text and red rectangle. The problem lies in that I can tap the red rectangle here and see the changes reflected in ParentView but ChildView doesn't recognize the change (rotation does not change and "Change Detected" is not printed).

Is @ObservedObject actually correct since ViewModel contains a @Binding to a @State created in ParentView?

Veasey answered 12/2, 2022 at 0:56 Comment(6)
There's too much missing code here. Is Item a class or struct? What is .onItemChanged? Can you show the code for the UnrelatedViews so that we can have a compilable minimal reproducible example?Radarman
I'll also note that by using @Binding outside of a View, like you are here in your ViewModel, you're making an odd semantic decision, as describe in my answer to your last question.Radarman
@Published would be the way recommended by Apple for use in your ViewModel class.Dartmouth
Hi @jnpdx, once again, thank you for responding to my post. I've updated the question to contain a compilable minimal reproducible example!Veasey
Also @jnpdx, I do recognize that using @Binding in the view model is odd as you suggested in my other question but the code in this question was actually written before the other and I want to get to the bottom of the issue here before I consider changing @Binding to Binding<SomeItem>.Veasey
@Dartmouth thank you but I did try @Published before posting and that gave into some read/write access issues.Veasey
R
5

Normally, I would not write such a convoluted solution to a problem, but it sounds like from your comments on another answer there are certain architectural issues that you are required to conform to.

The general issue with your initial approach is that onChange is only going to run when the view has a render triggered. Generally, that happens because some a passed-in property has changed, @State has changed, or a publisher on an ObservableObject has changed. In this case, none of those are true -- you have a Binding on your ObservableObject, but nothing that triggers the view to re-render. If Bindings provided a publisher, it would be easy to hook into that value, but since they do not, it seems like the logical approach is to store the state in the parent view in a way in which we can watch a @Published value.

Again, this is not necessarily the route I would take, but hopefully it fits your requirements:

struct SomeItem: Equatable {
    var doubleValue: Double
}

class Store : ObservableObject {
    @Published var someItem = SomeItem(doubleValue: 45)
}

struct ParentView: View {
    @StateObject private var store = Store()

    var body: some View {
        Color.black
            .overlay(alignment: .top) {
                Text(store.someItem.doubleValue.description)
                    .font(.system(size: 50))
                    .foregroundColor(.white)
            }
            .onTapGesture { store.someItem.doubleValue += 10.0 }
            .overlay { ChildView(store: store) }
    }
}

struct ChildView: View {
    @StateObject private var viewModel: ViewModel

    init(store: Store) {
        _viewModel = StateObject(wrappedValue: ViewModel(store: store))
    }

    var body: some View {
        Rectangle()
            .fill(Color.red)
            .frame(width: 50, height: 70, alignment: .center)
            .rotationEffect(
                Angle(degrees: viewModel.store.someItem.doubleValue)
            )
            .onTapGesture { viewModel.changeItem() }
            .onChange(of: viewModel.store.someItem.doubleValue) { _ in
                print("Change Detected", viewModel.store.someItem.doubleValue)
            }
    }
}


@MainActor
final class ViewModel: ObservableObject {
    var store: Store

    var cancellable : AnyCancellable?
    
    public init(store: Store) {
        self.store = store
        cancellable = store.$someItem.sink { [weak self] _ in
            self?.objectWillChange.send()
        }
    }

    public func changeItem() {
        store.someItem = SomeItem(doubleValue: .zero)
    }
}

Radarman answered 13/2, 2022 at 18:50 Comment(3)
This is great, thank you for putting this together and writing out the reasoning behind the problem. I agree that it's a convoluted workaround. Given the work it takes to make @StateObject work, would you say it's unacceptable to just use @ObservedObject instead? Also, say there were no architectural requirements here, very briefly, how would you've handled this?Veasey
Also, it looks like the code blocks in your answer got messed up. Fixing it might make it a bit easier to follow for future readers. Cheers :)Veasey
@Veasey If it were me, I'd probably keep one source of truth, owned at the top level. Then, I'd pass it down via bindings or the original ObservableObject to the subviews.Radarman
A
1

Actually we don't use view model objects at all in SwiftUI because the View struct hierarchy is the view model, see [Data Essentials in SwiftUI WWDC 2020]. As shown in the video at 4:33 create a custom struct to hold the item, e.g. ChildViewConfig and init it in an @State in the parent. Set the childViewConfig.item in a handler or add any mutating custom funcs. Pass the binding $childViewConfig or $childViewConfig.item to the to the child View if you need write access. It's all very simple if you stick to structs and value semantics.

Azine answered 12/2, 2022 at 11:7 Comment(1)
Hi @malhal, that's nice but I do in fact use view models with SwiftUI as I'm required to and these are requirements I do not control. I watched this exact WWDC presentation before posting my question but it wasn't able to clear up my issue. Of course, if I could always go in a different direction and follow your suggestion that would be nice but I am looking for an answer specific to my scenario.Veasey

© 2022 - 2024 — McMap. All rights reserved.