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: