Drafting and Discarding Edits to Core Data Objects in SwiftUI
Published on 25 November 2024
When working with Core Data in SwiftUI, you might often need to manage temporary edits or drafts of objects before committing changes to your persistent store. This is essential for workflows where edits might be canceled, ensuring a seamless and flexible user experience.
In my app, Score Wonders, which scores games in the board game 7 Wonders, I needed a clean way to handle these edits. To achieve this, I developed a reusable component that simplifies drafting and discarding edits using child contexts in SwiftUI. Here's how it works!
The Key Components
DraftOperation
The DraftOperation
struct is the foundation of this approach. It creates and manages a child context for temporary edits. Child contexts isolate changes, allowing you to discard or commit edits as needed. Changes made in child contexts are committed to their parent on save.
Here’s the implementation:
import CoreData
struct DraftOperation<Object: NSManagedObject>: Identifiable {
let id = UUID()
let childContext: NSManagedObjectContext
let object: Object
// Initialize with a new object in a child context
init(withParentContext parentContext: NSManagedObjectContext) {
childContext = Self.createChildContext(withParentContext: parentContext)
object = Object(context: childContext)
}
// Initialize with an existing object fetched into a child context
init?(withExistingObject existingObject: Object, inParentContext parentContext: NSManagedObjectContext) {
childContext = Self.createChildContext(withParentContext: parentContext)
guard let objectInChildContext = try? childContext.existingObject(with: existingObject.objectID) as? Object else { return nil }
object = objectInChildContext
}
private static func createChildContext(withParentContext parentContext: NSManagedObjectContext) -> NSManagedObjectContext {
let childContext = NSManagedObjectContext(concurrencyType: .mainQueueConcurrencyType)
childContext.parent = parentContext
return childContext
}
}
DraftingView
The DraftingView
is a generic SwiftUI view that handles the display of your UI and injects the child context for editing. It saves changes to the parent context when the view disappears, ensuring that the changes are preserved only when appropriate. If the child context is not saved, the edits are discarded.
Here’s the implementation:
import CoreData
import SwiftUI
struct DraftingView<Object, Content>: View where Object: NSManagedObject, Content: View {
private let operation: DraftOperation<Object>
private let content: (Object) -> Content
@Environment(\.managedObjectContext) private var parentContext
init(operation: DraftOperation<Object>, content: @escaping (Object) -> Content) {
self.operation = operation
self.content = content
}
var body: some View {
content(operation.object)
.environment(\.managedObjectContext, operation.childContext)
.onDisappear {
try! parentContext.save()
}
}
}
Using DraftOperation
and DraftingView
Here’s an example of how you can use these components in an app. Suppose you’re building a list of games, where the user is able to add new games and edit existing ones:
struct GamesView: View {
@State private var addGameOperation: DraftOperation<Game>?
@State private var updateGameOperation: DraftOperation<Game>?
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(sortDescriptors: [.init(key: "date", ascending: false)])
private var games: FetchedResults<Game>
var body: some View {
NavigationStack {
List(games) { game in
HStack {
Text(game.date!.formatted())
Spacer()
Button("Edit") {
updateGameOperation = DraftOperation(withExistingObject: game, inParentContext: viewContext)
}
}
}
.toolbar {
Button {
addGameOperation = DraftOperation(withParentContext: viewContext)
} label: {
Label("Add Game", systemImage: "plus")
}
}
.sheet(item: $addGameOperation) { operation in
DraftingView(operation: operation) { draft in
AddGameView(game: draft)
}
}
.sheet(item: $updateGameOperation) { operation in
DraftingView(operation: operation) { draft in
EditGameView(game: draft)
}
}
}
}
}
Bonus: View Modifier for Drafting
To make this approach even more seamless, you can add a custom SwiftUI view modifier for DraftingView
. This modifier uses a Binding
to manage the DraftOperation
object and presents a sheet
for editing.
Here’s the implementation:
extension View {
func draftingSheet<Object>(operation: Binding<DraftOperation<Object>?>, @ViewBuilder content: @escaping (Object) -> some View) -> some View where Object: NSManagedObject {
self.sheet(item: operation) { operation in
DraftingView(operation: operation, content: content)
}
}
}
You can use it like this:
struct EditGameView: View {
@State private var draftOperation: DraftOperation<Game>? = nil
@Environment(\.managedObjectContext) private var viewContext
var body: some View {
Button("Edit Game") {
draftOperation = DraftOperation(withParentContext: viewContext)
}
.draftingSheet(operation: $draftOperation) { draft in
GameEditorView(game: draft)
}
}
}
This modifier allows you to present a drafting interface effortlessly while keeping your code clean and declarative.
Why This Approach?
- Isolation of Changes: Drafting allows edits to happen in a temporary context without affecting the main database until saved.
- User Flexibility: Users can confidently edit objects, knowing they can cancel without unintended side effects.
- Reusability: The generic implementation can handle any Core Data object.
- SwiftUI Integration: Easily embed drafting functionality into SwiftUI views with minimal boilerplate.
Conclusion
This approach to drafting and discarding edits with Core Data in SwiftUI makes handling temporary changes intuitive and reliable. Whether you’re adding new objects or editing existing ones, the DraftOperation
and DraftingView
components (along with the bonus view modifier) provide a reusable and clean solution.
If you're working with Core Data in SwiftUI, give this method a try! I'd love to hear how it works for you or see how you adapt it to your needs. 😊
Tagged with: