pinglock
pinglock

Reputation: 1263

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?

Upvotes: 6

Views: 9263

Answers (2)

malhal
malhal

Reputation: 30719

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.

Upvotes: 0

jnpdx
jnpdx

Reputation: 52555

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

Upvotes: 6

Related Questions