SwiftUI prevent @NSManaged property from being @Published

106 Views Asked by At

The prerequisite to my question is the following: I have an @NSManaged object and I wish to observe and manipulate it. However, I do not want to receive all updates. To illustrate why – suppose it has properties selectedIndex and currentArray. Those are @NSManaged and they are all I need to store and recreate currentItem.

There are two ways I could do this. The wrong one would be to use currentArray[currentIndex] in my views. Wrong because should the update get triggered when I set index and before I change array – I can easily get out of bounds exception. Same if I change array first.

The second approach would be to restrict direct access to these properties from other classes and instead provide an interface to interact with it which would also take care of the internal logic. For example goToNextItem(), changeArrayTo(), etc.

And along this route the easiest approach would be to mark selectedIndex and currentArray as private. And, one would assume, this would also prevent other views from receiving updates when they change.

The problem – it doesn't.

Here is a super simple example:

final class DataModel:ObservableObject {
    @Published private var internalCounter = 0
    var externalCounter = 0
    
    func incInternalCount() {
        externalCounter += 1
        internalCounter += 1
    }
}

struct Playground: View {
    @ObservedObject var dataModel = DataModel()
    var body: some View {
        Text("Refresh count \(dataModel.externalCounter)")
        Button {
            dataModel.incInternalCount()
        } label: {
            Text("Increment")
        }
    }
}

And this is what I get:

enter image description here

As you can see, even though I am not able to access internalCounter from my Playground AND that changes to externalCounter are not published – I still receive updates whenever internalCounter changes.

This doesn't necessarily create an issue for me – as long as my publicly facing interface has a solid logic, nothing will crash. However, I kind of don't like this idea of having body recalculations in response to properties changes, when I explicitly made those properties unavailable for access :)

Hence my question – is there a way to make @NSManaged property not @Published? :)

UPDATE

I see that my simplified example is causing some confusion, so let me outline a slightly more elaborate one:

final class DataModel:ObservableObject {
    @Published private var itemIndex:Int = 0
    @Published private var items:[String] = ["hello", "world"]
    @Published var currentItem:String
    
    var operationsCount = 0
    
    init() {
        currentItem = ""
        currentItem = items[itemIndex]
    }
    
    func goToNextItem() {
        operationsCount += 1
        itemIndex += 1
        currentItem = items[itemIndex] //Comment this to see problem
    }
    
    func goToPreviousItem() {
        operationsCount += 1
        itemIndex -= 1
        currentItem = items[itemIndex] //AND this
    }
    
    func nextItemAvailable() -> Bool {
        return itemIndex < items.count - 1
    }
    
    func previousItemAvailable() -> Bool {
        return itemIndex > 0
    }
}

struct Playground: View {
    @ObservedObject var dataModel = DataModel()
    var body: some View {
        Text("Item: \(dataModel.currentItem) \(dataModel.operationsCount)")
        HStack {
            Button {
                dataModel.goToPreviousItem()
            } label: {
                Text("Previous")
            }
            .disabled(!dataModel.previousItemAvailable())
            Button {
                dataModel.goToNextItem()
            } label: {
                Text("Next")
            }
            .disabled(!dataModel.nextItemAvailable())
        }
        
    }
}

items and itemIndex is what I need to persist my data. However to display it, I need currentItem and next/previous functionality.

If you comment out the lines indicated - updates will happen. And although they are not a problem given that nothing will crash – would be nice to avoid them for the benefits of performance :)

1

There are 1 best solutions below

8
On

The data model object should only store the array of data, not any info about selection, that should be stored in the View struct hierarchy using @State and then @Binding for write access, make funcs for logic, computed properties to transform data. If you think about it, what if you wanted to allow selection of the same data in 2 screens? That wouldn't be possible if the selection is within the data model.

I kind of don't like this idea of having body recalculations in response to properties changes, when I explicitly made those properties unavailable for access

That's why the first step in designing a View is ask yourself what data does it need to do its job? In your case it doesn't need the whole model object, only write access to the property so it should be:

struct Playground: View {
    @Binding var counter: Int
    var body: some View {
        Text("Refresh count \(counter)")
        Button {
            counter += 1
        } label: {
            Text("Increment")
        }
    }
}

If you want a side effect when setting the counter, in a model struct you can use didSet or in an ObservableObject configure a Combine pipeline in init, starting with $counter and ending with .assign. Usually objects are only used when you need reference type semantics, ie async (Not async/await though, because we have .task's internal convenience object for that).