BLACK FRIDAY: Save 50% on all books and bundles! >>

SOLVED: Triggering didSet of a PropertyObserver confusion

Forums > SwiftUI

The @Published property observer didSet in the code below will not trigger when the var courses, an array, has all objects removed (.removeAll) or has a object added (.append). It will trigger if the array is replaced (array = array1). I don't understand why? Any insights for me? Thanks.

struct Course: Codable, Identifiable {
    let id = UUID()
    let name: String
}

class CourseItems: ObservableObject {
    // UserDefaults property wrapper, based on this post by Antoine van der Lee ( https://www.avanderlee.com/swift/property-wrappers/ ).
    // "Courses" is the key and defaultValue is the type.
    // When data is accessed, either set or get UserDefaults executes either a write or read .
    @UserDefault("Courses", defaultValue: Data()) var data: Data
    var dataString = ""
    @Published var courses: [Course] {
        didSet {
            print("Did Set")
            let encoder = JSONEncoder()
            encoder.outputFormatting = .prettyPrinted
            if let data = try? encoder.encode(courses) {
                self.data = data
                dataString = String(data: self.data, encoding: .utf8) ?? "Failed to convert data to dataString"
            }
        }
    }

    init() {
        if let data = UserDefaults.standard.data(forKey: "Courses") {
            let decoder = JSONDecoder()
            if let decoded = try? decoder.decode([Course].self, from: data) {
                self.courses = decoded
                return
            }
        }
        self.courses = []
    }
 }

 struct AddCourseView: View {
    @Environment(\.presentationMode) var presentationMode
    @ObservedObject var courseItems: CourseItems
                      .
                      .
                      .
                      .
                        Button("Save & Close") {
line 65              var testCourses = self.courseItems.courses
                            let course = Course(name: self.courseName)
line 67               testCourses.removeAll()
line 68               testCourses.append(course)
line 69               self.courseItems.courses = testCourses
//                         self.courseItems.courses.removeAll()
//                         self.courseItems.courses.append(course)
                            self.presentationMode.wrappedValue.dismiss()
                        }

With lines 65-69 didSet triggers, without and with comment slashes removed, didSet doesn't trigger.

1      

I think this is just an unfortunate side effect of property wrappers. Internally, @Published turns CourseItems.courses from an Array<Course> to Published<Array<Course>>, with the [Course] array now being set in the Published.wrappedValue property. Most of the time this is abstracted away, but at runtime, when you use array operations on the object contained in wrappedValue, that is not seen as a change to the object that is Published<Array<Course>>.

Since you're using Combine, you should be able to convert the operations in the property observer into a subscriber:

...
    @Published var courses = [Course]()

    private var cancellables = Set<AnyCancellable>()

    init() {
        if let data = UserDefaults.standard.data(forKey: "Courses") {
            let decoder = JSONDecoder()
            if let decoded = try? decoder.decode([Course].self, from: data) {
                self.courses = decoded
            }
        }

        self.$courses
            .dropFirst() // Skips the initial value so you only persist changes
            .sink { (courses: [Course]) in
                // Encode and save the value of courses here
            }
            .store(in: &cancellables)
    }
...

   

Thank you @bbatsell. Your subscriber solution solved my problem. I have to admit I am not entirely clear on why I need to go that route or how it solves the problem - so I am now in research mode!

   

I admittedly didn’t do a great job explaining. Let me give it another try.

When you use any property wrapper (of which @Published is an example), internally it wraps the enclosed property in a whole new object, so in reality your var courses: [Course] turns in to this:

struct Published {
    var wrappedValue: [Course]
} 

But your property observer didSet is created for the outer wrapper, not the inner wrapped value. If you use an array operation on Published.wrappedValue and then you ask Swift: Did Published change? The answer is no, because the Published.wrappedValue still points to exactly where it did before: the Array of Courses you created when you initialized your class. Sure, the contents of that Array changed, but Swift doesn’t know that. It only knows that there is a wrappedValue property that points to some Array. It does not inspect the contents of that Array.

When you create var testCourses by copying self.courseItems.courses, you are creating a new Array<Course>. When you then assign the new array to self.courseItems.courses, the Published.wrappedValue DID change. It’s pointing to a whole different array in a different location, so to Swift, Published has now changed, and it fires the didSet property observer.

What’s confusing about it is that Swift abstracts out a lot of what’s happening, so it appears that you are still operating on just an Array<Course> when you modify self.courseItems.courses, but you’re really not under the hood. You could argue that property observers not obeying the same abstraction that Swift uses everywhere else is either unexpected behavior or a bug.

The reason that the subscription works is that the whole purpose of the @Published wrapper is to provide a Combine publisher that sends a message every time the content of its wrappedValue changes, exactly like didSet used to when it was really just an Array<Course>. By creating a subscriber that subscribes to the @Published wrapper’s Publisher, you’re basically doing exactly what SwiftUI does when it wants to be updated on what the latest version of self.courseItems.courses is so it can update its view.

   

Very much appreciate your further explanation @bbatsell. It makes sense to me now. I am not a "real" developer but a retired EE doing development as a hobby so I run into issues like this alot and appreciate when folks like you are willing to take the time to help. Thanks. Jim

   

Thanks for the explanation. I was having this issue with the iExpense project.

I found that if you want to trigger didSet after making an append or remove operation on an array you can just add one line setting the array equal to itself. No need to create extra temp variables. For example:

self.expenses.items.append(item)  // This line doesn't trigger didSet
self.expenses.items = self.expenses.items  // Add this line and it will

or

expenses.items.remove(atOffsets: offsets)  // This line doesn't trigger didSet
expenses.items = expenses.items  // Add this line and it will

   

@twostraws  Site AdminHWS+

@ryanlintott This is a Swift bug, and is fixed in Xcode 11.5.

   

Thanks for the full explanation. I hope to have Xcode 11.5 soon. In the mean time, I have used the simple solution of @ryanlintott.

   

@twostraws, im experiencing this in xcode12 beta. i wonder if there has been a regression?

   

Save 50% in my Black Friday sale.

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

Not logged in

Log in
 

Link copied to your pasteboard.