After some more months, I came across this question once again.
Since my last comment on September 18th, I've worked myself on solving the puzzle of building a SwiftUI document-based app using Core Data.
Looking more in-depth I learned that the UIManagedDocument
(respectively its parent UIDocument
) infrastructure is really close/similar to what SwiftUI tries to implement. SwiftUI even uses UIDocument
in the background to do "its magic". UIDocument
and UIManagedDocument
are simply some more archaic remnants of times where Objective-C was the dominant language. There was no Swift and there were no value-types.
In general I can give you the following tips to solve your challenge using Core Data within a UIManagedDocument
:
- first of all, if you want to use Core Data, you will have to use a package/bundle based document format. This means your
UTType
will have to conform to .package
(=com.apple.package
in your Info.plist file). You won't be able to make Core Data work with only a plain file document.
extension UTType {
static var exampleDocument: UTType {
UTType(exportedAs: "com.example.mydocument", conformingTo: .package)
}
}
use a ReferenceFileDocument
based class to build a wrapper document for your UIManagedDocument
. This is necessary because you will have to know about the time when the object is released. In deinit
you will have to call managedDocument.close()
to ensure the UIManagedDocument
is properly closed and no data is lost.
the required function init(configuration:)
is going to be called when an existing document is opened. Sadly, it is of no use when working with UIManagedDocument
because we have only access to the FileWrapper
of the document and not the URL
. But the URL
is what you need to initialize the UIManagedDocument
.
the required function snapshot(contentType:)
and fileWrappper(snapshot:, configuration:)
is only used to create a new empty document. (This is because we won't use the SwiftUI integrated UndoManager
but the one from UIManagedDocument
respectively Core Datas NSManagedObjectContext
.) Therefore it is not relevant what your type for Snapshot
is. You can use a Date
or Int
because the snapshot taken with the first function is not what you are going to write in the second function.
The fileWrappper(snapshot:, configuration:)
function should return the file structure of an empty UIManagedDocument
. This means, it should contain a directory StoreContent
and an empty file with the filename of the persistent store (default is persistentStore
) as in the screenshot below.
The persistentStore-shm
and persistentStore-wal
files are going to be created automatically when Core Data is starting up, so we do not have to create them in advance.
I am using the following expression to create the FileWrapper
representing the document: (MyManagedDocument
is my UIManagedDocument
subclass)
FileWrapper(directoryWithFileWrappers: [
"StoreContent" : FileWrapper(directoryWithFileWrappers: [
MyManagedDocument.persistentStoreName : FileWrapper(regularFileWithContents: Data())
])
])
- above steps allow us to create an empty document. But it still cannot be connected to our
UIManagedDocument
subclass, because we have no idea where the document (represented by the FileWrapper
we have created) is located. Luckily SwiftUI is passing us the URL
of the currently opened document in the ReferenceFileDocumentConfiguration
which is accessible in the DocumentGroup
content closure. The property fileURL
can then be used to finally create and open our UIManagedDocument
instance from the wrapper. I'm doing this as follows: (file.document
is an instance of our ReferenceFileDocument
class)
DocumentGroup(newDocument: { DemoDocument() }) { file in
ContentView()
.onAppear {
if let url = file.fileURL {
file.document.open(fileURL: url)
}
}
}
in my open(fileURL:)
method, I then instantiate the UIManagedDocument
subclass and call open
to properly initialize it.
With above steps you will be able to display your document and access its managedObjectContext
in a view similar to this: (DocumentView
is a regular SwiftUI view using for example @FetchRequest
to query data)
struct ContentView: View {
@EnvironmentObject var document: DemoDocument
var body: some View {
if let managedDocument = document.managedDocument {
DocumentView()
.environment(\.managedObjectContext, managedDocument.managedObjectContext)
} else {
ProgressView("Loading")
}
}
}
But you will soon encounter some issues/crashes:
You see the app freeze when a document is opened. It seems as if UIManagedDocument
open
or close
won't finish/return.
This is due to some deadlock. (You might remember, that I initially told you that SwiftUI is using UIDocument
behind the scene? This is probably the cause of the deadlock: we are running already some open
while we try to execute another open
command.
Workaround: run all calls to open
and close
on a background queue.
Your app crashes when you try to open another document after having previously closed one. You might see errors as:
warning: Multiple NSEntityDescriptions claim the NSManagedObject subclass 'Item' so +entity is unable to disambiguate.
warning: 'Item' (0x6000023c4420) from NSManagedObjectModel (0x600003781220) claims 'Item'.
warning: 'Item' (0x6000023ecb00) from NSManagedObjectModel (0x600003787930) claims 'Item'.
error: +[Item entity] Failed to find a unique match for an NSEntityDescription to a managed object subclass
After having debugged this for some hours, I learned that the NSManagedObjectModel
(instantiated by our UIManagedDocument
) is not released between closing a document and opening another. (Which, by good reason, is not necessary as we would use the same model anyway for the next file we open). The solution I found to this problem was to override the managedObjectModel
variable for my UIManagedDocument
subclass and return the NSManagedObjectModel
which I'm loading "manually" from my apps bundle. I suppose there are nicer ways to do this, but here is the code I'm using:
class MyManagedDocument: UIManagedDocument {
// We fetch the ManagedObjectModel only once and cache it statically
private static let managedObjectModel: NSManagedObjectModel = {
guard let url = Bundle(for: MyManagedDocument.self).url(forResource: "Model", withExtension: "momd") else {
fatalError("Model.xcdatamodeld not found in bundle")
}
guard let mom = NSManagedObjectModel(contentsOf: url) else {
fatalError("Model.xcdatamodeld not load from bundle")
}
return mom
}()
// Make sure to use always the same instance of the model, otherwise we get crashes when opening another document
override var managedObjectModel: NSManagedObjectModel {
Self.managedObjectModel
}
}
So this answer has become really lengthy, but I hope it is helpful to others struggling with this topic. I've put up this gist with my working example to copy and explore.
ReferenceFileDocument
(you have to use/connect theUndoManager
to make it save), but don't know how to make it work withUIManagedDocument
– Nessie