UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

Hacking with Swift Data: Merging contexts doesn't always result in a View update

Forums > Books

I'm migrating from Core Data and have always used child contexts to enable easy edit / discard of models without resorting to loads of boilerplate assignment between view properties and the model. My models have up to 10 properties and so things get a bit tedious if I follow the example provided by Apple for editing a model in SwiftData so I was delighted to read Paul's suggestion that we use a peer context instead.

The difference between my project and the example in the book is that the editing/creating view is presented as a sheet. Interestingly, the presenting view is not updated following an edit which is countrary to what I understand from the way that SwiftData utilises the Observation framework. Specifically, properties in the view that are assigned to a model will force a refresh of the view provided they "touch" an updated property. Model properties that are not displayed by the view will not force an update thus avoiding a discard / rebuild much of the view tree. This is great in principle but it looks like something might be broken here....

I've created a small demo project which illustrates the problem. You can edit the book title or author (both displayed by the presenting view) but the edits are not visible until you return to the list (i.e. you need to manually discard and rebuild the view to get the edits)

Model:

@Model final class Book {
    var title: String
    var author: String

    init(title: String, author: String) {
        self.title = title
        self.author = author
    }
}

App and View code:

@main
struct SandboxApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .modelContainer(for: Book.self)
        }
    }
}

struct ContentView: View {
    @Environment(\.modelContext) var modelContext
    @State private var sheetPresented = false
    @State private var selectedBook: Book?

    @Query<Book> var books: [Book]

    var body: some View {
        NavigationSplitView {
            List(books, id: \.self, selection: $selectedBook) { book in
                HStack {
                    Text(book.title)
                    Text(book.author).bold()
                }
            }
            .navigationTitle("Books")
        } detail: {
            if let selectedBook {
                DetailView(book: selectedBook)
            }
        }
        .onAppear {
            if books.count == 0 {
                let book = Book(title: "IT", author: "Stephen King")
                modelContext.insert(book)
                let book2 = Book(title: "Lord of the Rings", author: "JRR Tolkien")
                modelContext.insert(book2)
            }
        }
    }
}

struct DetailView: View {
    @State private var sheetPresented = false

    @Environment(\.modelContext) var modelContext

    let book: Book

    var body: some View {
        NavigationStack {
            Form {
                Text("Title: \(book.title)")
                Text("Author: \(book.author)")
                Spacer()
                Button("Edit") { sheetPresented.toggle() }
            }
            .navigationTitle("Selected book").navigationBarTitleDisplayMode(.inline)
            .sheet(isPresented: $sheetPresented) {
                EditView(modelId: book.persistentModelID, in: modelContext.container)
            }
        }
    }
}

struct EditView: View {
    @Bindable var book: Book
    private let peerContext: ModelContext

    @Environment(\.modelContext) private var modelContext
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        Form {
            TextField(text: $book.title, prompt: Text("Enter the title"), label: { Text("Title") })
            TextField(text: $book.author, prompt: Text("Name of the author"), label: { Text("Author") })
            Spacer()
            Button("Save") {
                try! peerContext.save()
                dismiss()
            }
        }
    }

    init(modelId: PersistentIdentifier, in container: ModelContainer) {
        let peerContext = ModelContext(container)
        peerContext.autosaveEnabled = false
        let book = peerContext.model(for: modelId) as? Book ?? Book(title: "", author: "")

        self.peerContext = peerContext
        self.book = book
    }
}

There is supposed to be a GIF here but I'm obviously not forming the Sharepoint link correctly...any poiinters would be appreciated! Simulator

Is this a bug or am I missing something? Regarding a workaround, I thought perhaps the presenting view could use a query (on the passed-in model's identifier, if applicable) but it seems like I might be fighting the system....

3      

Displaying the details for the selected book using a @Query (constructed in a subview) resolves the issue but does seem clunky to me and will perhaps incur a bit more overhead... Thoughts?

struct DetailView: View {
    @State private var sheetPresented = false

    @Environment(\.modelContext) var modelContext

    let book: Book

    var body: some View {
        NavigationStack {
            Detail(modelId: book.persistentModelID, sheetPresented: $sheetPresented)
                .navigationTitle("Selected book").navigationBarTitleDisplayMode(.inline)
                .sheet(isPresented: $sheetPresented) {
                    EditView(modelId: book.persistentModelID, in: modelContext.container)
                }
        }
    }
}

struct Detail: View {
    @Query var books: [Book]
    private var book: Book {
        books.first ?? Book(title: "", author: "")
    }
    @Binding private var sheetPresented: Bool

    var body: some View {
        Form {
            Text("Title: \(book.title)")
            Text("Author: \(book.author)")
            Spacer()
            Button("Edit") { sheetPresented.toggle() }
        }
    }

    init(modelId: PersistentIdentifier, sheetPresented: Binding<Bool>) {
        _books = Query(filter: #Predicate { $0.persistentModelID == modelId })
        _sheetPresented = sheetPresented
    }
}

3      

Hi,

Passing the book object to the EditView works as it should.

struct DetailView: View {
    @State private var sheetPresented = false

    @Environment(\.modelContext) var modelContext

    let book: Book

    var body: some View {
        NavigationStack {
            Form {
                Text("Title: \(book.title)")
                Text("Author: \(book.author)")
                Spacer()
                Button("Edit") { sheetPresented.toggle() }
            }
            .navigationTitle("Selected book").navigationBarTitleDisplayMode(.inline)
            .sheet(isPresented: $sheetPresented) {
                EditView(book: book)
            }
        }
    }
}

struct EditView: View {
    @Bindable var book: Book
//    private let peerContext: ModelContext

    @Environment(\.modelContext) private var modelContext
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        Form {
            TextField(text: $book.title, prompt: Text("Enter the title"), label: { Text("Title") })
            TextField(text: $book.author, prompt: Text("Name of the author"), label: { Text("Author") })
            Spacer()
            Button("Save") {
                try? modelContext.save()
                dismiss()
            }
        }
    }

//    init(modelId: PersistentIdentifier, in container: ModelContainer) {
//        let peerContext = ModelContext(container)
//        peerContext.autosaveEnabled = false
//        let book = peerContext.model(for: modelId) as? Book ?? Book(title: "", author: "")
//
//        self.peerContext = peerContext
//        self.book = book
//    }
}

3      

This happened to me too so I've been reloading the view. I assigned a uuid to the id value of the outer view of the code that wasn't refreshing and reset the uuid to a new value when returning. I'd welcome a better solution too.

3      

Hacking with Swift is sponsored by RevenueCat.

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

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

You are not logged in

Log in or create account
 

Link copied to your pasteboard.