3

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?

pinglock
  • 982
  • 2
  • 12
  • 30
  • There's too much missing code here. Is `Item` a `class` or `struct`? What is `.onItemChanged`? Can you show the code for the `UnrelatedView`s so that we can have a compilable [mre]? – jnpdx Feb 12 '22 at 01:08
  • 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. – jnpdx Feb 12 '22 at 01:09
  • `@Published` would be the way recommended by Apple for use in your `ViewModel` class. – Yrb Feb 12 '22 at 01:39
  • Hi @jnpdx, once again, thank you for responding to my post. I've updated the question to contain a compilable minimal reproducible example! – pinglock Feb 13 '22 at 02:23
  • 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`. – pinglock Feb 13 '22 at 02:33
  • @Yrb thank you but I did try `@Published` before posting and that gave into some read/write access issues. – pinglock Feb 13 '22 at 02:34

2 Answers2

2

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

jnpdx
  • 45,847
  • 6
  • 64
  • 94
  • 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? – pinglock Feb 14 '22 at 00:18
  • 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 :) – pinglock Feb 14 '22 at 00:20
  • @pinglock 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. – jnpdx Feb 14 '22 at 22:13
1

Actually we don't use view model objects at all in SwiftUI, 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.

malhal
  • 26,330
  • 7
  • 115
  • 133
  • 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. – pinglock Feb 13 '22 at 02:28