swiftswiftuicombineobservedobjectproperty-wrapper-published

@Published variable not updating in SwiftUI View. Swift 5, Combine


I am currently writing a ViewModel that uses the @Published property wrapper to update data pulled from by backend.

I have a class ViewResult that allows the frontend to display the state of the pulled data, so the published variable is of type ViewResult. I am worried my understanding of ObservableObject and @Published is wrong. Here is it's definition:

class ViewResult<T> : ObservableObject {
    
    @Published var data: T?
    var state: ViewResultState = .loading
    var errorCode: APIResponseCode? = nil
    
    init(){
        self.state = .loading
        self.data = nil
    }
    
    ///OTHER COVIENIENCE INITIALIZERS NOT SHOWN
    
    
    ///Sets object and state variables if the APIResponse was successful.
    ///If it was unsuccessful, it sets error code and sets the ViewResult State to code.
    func SetData(_ result: APIResult<T>) -> Void {
        switch result {
        
            case .success(let data):
                self.data = data.data
                self.state = .object
                
            case .failure(let error):
                self.errorCode = APIResponseCode.Error(error)
                self.state = .code
        }
    }

The ViewModel itself is pretty basic, here is a specific example in which I am working:

class GuidelinesVM : ObservableObject {
    
    var community_id: Int
    
    @Published var guidelines: ViewResult<[Guideline]> = ViewResult(defaultValue: [])
    private var dispatcher : ViewModelAPIDispatcher = ViewModelAPIDispatcher()
    
    init(community_id: Int){
        self.community_id = community_id
      
        //Get guidelines before view is initialized. 
        //**NOTE: I AM CHANGING THIS TO EXECUTE IN THE ONAPPEAR CLOSURE
        dispatcher.SendRequest(.getCommunityGuidlines(community_id: community_id)){ result in
            self.guidelines.SetData(result)
        }
    }
    
    func ReorderGuidelines(_ fromOffsets : IndexSet, _ toOffset : Int){
        
        //Conversions from Swift's List system to mine
        let oldOffset = fromOffsets.first! + 1
        let guideline_id = self.guidelines.data![oldOffset - 1].id
        let newOffset = toOffset > oldOffset ? toOffset : toOffset + 1
        
        //Dispatch API Call
        dispatcher.SendRequest(.reOrderGuidelines(community_id: community_id, guideline_id: guideline_id, fromOffset: oldOffset, toOffset: toOffset)) { apiResult in
            if IsSuccessful(apiResult) {
                self.guidelines.data!.move(fromOffsets: fromOffsets, toOffset: toOffset)
            }
        }
    }
    
    func DeleteGuideline(_ offsets: IndexSet){
        let index = offsets.first!
        dispatcher.SendRequest(.deleteGuideline(community_id: community_id, guideline_id: index)){ result in
            if IsSuccessful(result) {
                self.guidelines.data!.remove(at: index)
            }
        }
    }
    
    func CreateGuidline(orderNumber: Int, text: String) {
        dispatcher.SendRequest(.createGuideline(community_id: community_id, orderNumber: orderNumber, text: text)){
            result in
            if(IsSuccessful(result)){
                //Right now just generate random int. I know this is terrible and lazy, but MVP
                self.guidelines.data!.append(Guideline(id: Int.random(in: -1000...0), order_number: self.guidelines.data!.count + 1, text: text))
            }
        }
    }
    
}

Now with that in mind, here is the view to which the @Published variable is being used:

struct CommunityGuidelinesView: View {

    var psCommunityData : psCommunityClass
    @State private var newGuideline : String = ""
    @ObservedObject private var VM : GuidelinesVM
    
    init(community : psCommunityClass){
        self.psCommunityData = community
        self.VM = GuidelinesVM(community_id: community.id)
    }
    
    var body: some View {
        
        EditableView(userRole: psCommunityData.role){ inEditMode in
            
            VStack{
                APIResultView(apiResult: $VM.guidelines){ guidelines in
                    
                    List {
                        // LIST DISPLAY CODE
                        }
                        .onDelete { indexSet in
                            VM.DeleteGuideline(indexSet)
                        }
                        .onMove{ fromOffsets, newOffset in
                            VM.ReorderGuidelines(fromOffsets, newOffset)
                        }
                        //NEED TO PASS A BINDING FOR NESTED VIEW CONTROL
                        if inEditMode.wrappedValue {
                            
                            EditingView(
                                //VIEW IF NOT EDITING
                                },
                                EditingContent: {
                                    //VIEW IF EDITING

                                }, completion: {
                                    //FUNCTION EXECTUED ONCE DONE EDITING
                                    VM.CreateGuidline(orderNumber: guidelines.count + 1, text: newGuideline)
                                }
                            )
                        }
                    }
                }
            }
        }
    }

Now with that knowledge, I believe the issue comes in with the editing view. I have the data displaying correctly when the view appears, and everyhere else in the application. I can successfully edit the object inside the class, I.E. adding to the list, deleting from it, and reording it. But the ListView that is displayedc, VM.Guidelines, doesn't update until "Edit Mode" is toggled back to "View Mode". The behavior goes something like this:

1.) Enter edit mode by hitting edit on the top right toolbar.

2.) Enter some text and execute VM.CreateGuideline().

3.) The API call is successful (I know this) and edits the data with the @Published guidelines variable.

4.) View doesn't update.

5.) Hit "Done" on the top right.

6.) List View is updated with new list item.

I'm worried that I am using these Combine defined aspects of Swift incorrectly. It also seems plausible, however that the nested nature of EditableView and APIResultView are getting in the way of one another in some capacity.

Also: One last piece of code that might be necessary is the APIResultView that handles the display logic for incoming data from the backend:

struct APIResultView<SuccessDisplay : View, T>: View {
    
    @Binding var apiResult : ViewResult<T>
    var content : (T) -> SuccessDisplay
    
    
    var body: some View {
        switch apiResult.state{
        case .object:
            content(apiResult.data!)
        case .loading:
            ProgressView("Loading...")
        case .code:
            if let errorCode = apiResult.errorCode {
                HttpStatusView(statusCode: errorCode)
            } else {
                HttpStatusView(statusCode: apiResult.data as! APIResponseCode)
            }
            
        }
    }

All things I've tried are just in hopes of getting the List to update.

I've tried adding @escaping closures to the VM function calls (CreateGuideline, DeleteGuideline, ReorderGuidelines). In hopes of editing the data from the View directly. I hate this because it defeats the point of my software architecture and the functions of objects in it. Luckily, it didn't work anyways.

I've tried passing the arguments through inout parameters in hopes that by making it a reference it would update one place in memory, though I think that happens anyways.

That's about it. Just been trial and erroring otherwise. No other thought out solutions.


Solution

  • While I didn't study your code or text in extreme detail, I noticed something that is probably causing your problem:

    ViewResult is a class, conforms ObservableObject, and is also used as the type of a Published property in GuidelinesVM.

    This is a recipe for trouble.

    The problem is that changes to the properties of a ViewResult, including changes to the data property and changes to properties of the data value, will not show up as changes to the properties of the GuidelinesVM.

    For example:

    let gvm = GuidelinesVM(community_id: 1)
    
    gvm.guidelines = ViewResult()
    // The assignment above does cause gvm to fire its objectWillChange publisher.
    
    gvm.guidelines.state = .object
    // The assignment above DOES NOT cause gvm to fire its objectWillChange publisher.
    // It also does not cause gvm.guidelines to fire its objectWillChange publisher,
    // because the state property is not @Published.
    
    gvm.guidelines.data = []
    // The assignment above DOES NOT cause gvm to fire its objectWillChange publisher.
    // It causes gvm.guidelines to fire its objectWillChange publisher.
    
    gvm.guidelines.data[0].text = "blerg"
    // The assignment above DOES NOT cause gvm to fire its objectWillChange publisher.
    // It causes gvm.guidelines to fire its objectWillChange publisher.
    

    I recommend changing ViewResult to a struct (and removing ObservableObject and @Published from it). Making it a struct will make GuidelinesVM see any changes to its properties and fire objectWillChange.

    If you can't make it a struct for some reason, then you need to manually propagate changes in ViewResult to changes in GuidelinesVM, perhaps by making GuidelinesVM subscribe to guidelines.objectWillChange:

    class GuidelinesVM : ObservableObject {
        
        var community_id: Int
        
        @Published var guidelines: ViewResult<[Guideline]> = ViewResult(defaultValue: [])
        private var dispatcher : ViewModelAPIDispatcher = ViewModelAPIDispatcher()
    
        private var tickets: [AnyCancellable] = []
    //  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ADD THIS
        
        init(community_id: Int){
            self.community_id = community_id
    
            guidelines.objectWillChange.sink { [weak self] in
                self?.objectWillChange.send()
            }.store(in: &tickets)
         // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ADD THIS
          
            //Get guidelines before view is initialized. 
            //**NOTE: I AM CHANGING THIS TO EXECUTE IN THE ONAPPEAR CLOSURE
            dispatcher.SendRequest(.getCommunityGuidlines(community_id: community_id)){ result in
                self.guidelines.SetData(result)
            }
        }
    
    If you use this approach, you may also need to add `@Published` to the `state` and `errorCode` properties of `ViewResult`. You will also need to do extra work to update the subscription if you ever assign an entirely new  `ViewResult` instance to the `guidelines` property.