swiftmvvmswiftuiswift5property-wrapper

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


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.

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:

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


Solution

  • 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)
        }
    }