How to Preview a SwiftUI View with Core Data Elements in Xcode

Published on

Get weekly handpicked updates on Swift and SwiftUI!

From the day SwiftUI was born, Canvas Preview has been a feature that developers both love and hate. When the preview works normally, it can greatly improve development efficiency; however, the preview may crash at any time for various inexplicable reasons, not only affecting the development process but also causing frustration for developers (as it is difficult to troubleshoot the cause of the preview crashes).

The frequency of crashes increases when previewing views containing Core Data elements, which may have already impacted developers’ enthusiasm for using Core Data in SwiftUI.

Combining my two years of experience and lessons learned using Core Data in SwiftUI, we will explore in this article:

  • Some reasons behind SwiftUI preview crashes
  • How to avoid similar crashes in future development
  • How to safely and reliably preview SwiftUI views with Core Data elements in Xcode

If you want to understand more about how Preview works, please read Building Stable Preview Views: How SwiftUI Previews Work

Preview

Preview is a Simulator

The preview is a simulator, a highly optimized and streamlined simulator.

The working principle of the preview in Xcode is very similar to the standard simulator. However, to allow it to respond instantly to changes in SwiftUI views, Apple has made several modifications. If the standard simulator can cover 90% of the real device’s functionality, then the simulator used for the preview might only offer 50% device fidelity.

The simulator used for previews also uses a sandbox mechanism and has the same directory structure as standard devices (or simulators).

The preview simulator does not support console output display or breakpoint debugging. Even in dynamic preview mode (an interactive preview mode), we will not get any console output content from the code in Xcode. Therefore, when there are problems with the preview, the means of troubleshooting are very limited.

Once we understand the concept that the preview is a simulator, many problems that arise in the preview have new solutions.

Reasons for the Inability to Preview a View Are Not Just in the Current View’s Code

Like running a project on a standard simulator, when previewing a particular view, the preview simulator requires that the entire code of the project can be compiled normally. Errors in the code of other views, methods, declarations, etc., can all prevent you from previewing the current view.

When troubleshooting view preview crashes, one must not only focus on the current or nearby view’s code. Errors in other code might be the real culprits. Usually, in this case, many views, or even all views, cannot be previewed.

Experience Fixing Standard Simulator Faults Also Applies to Troubleshooting Preview Faults

When debugging programs with the standard simulator, we encounter various strange situations due to the simulator. Usually, in this case, we might try the following methods to solve it:

  • Delete the application on the simulator and reinstall it
  • Clear the compilation cache (Clean Build Folder)
  • Delete the project’s Derived Data
  • Reset the simulator
  • Delete the simulator in the Simulator Device Manager and re-add it

Most of the above methods also apply to fixing some preview crashes. Since the preview simulator does not provide a management entry, we usually need to use more straightforward and crude methods to achieve the above fixes.

The data of the preview simulator is saved in the /Users/your_username/Library/Developer/Xcode/UserData/Previews directory, where you will see many subdirectories named by UUID. In the case where the preview is still usable, by adding the following in the view code:

Swift
Text("\(FileManager.default.urls(for: .applicationDirectory, in: .userDomainMask).first!)")

You can see the corresponding UUID directory name in the preview view (it must be in dynamic preview mode to display).

image-20210827150544279

By clearing the corresponding directory, you can complete items 1, 4, and 5 above.

If your preview is already not working, and you cannot determine the corresponding directory through means such as file modification time, it is also possible to delete all directories.

Sometimes it is necessary to restart Xcode or even the system to return to normal.

Core Data in SwiftUI

SwiftUI App Life Cycle

Starting from Xcode 12, developers can create projects in Xcode using the native application lifecycle of SwiftUI. The execution entry point of the project adopts a code form similar to that of view definitions.

Swift
@main
struct PreviewStudyApp: App {
    var container = PersistenceController.shared.previewInBundle

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.managedObjectContext, container.viewContext)
        }
    }
}

In the App, we need to complete preparations such as creating or referencing the CoreDataStack instance and injecting the environment. As the root structure of the project code, its compilation and execution time are earlier than other codes.

Environment Injection

SwiftUI provides several ways to pass data between views. Among them, passing data through environment values (EnvironmentValue) or environment objects (EnvironmentObject) are two widely used methods. SwiftUI presets a large number of system-related environment values, and by setting or responding to these data, we can modify system configurations or read system information.

SwiftUI views are organized in a tree structure, and environmental data injected at any node view will affect all its child views. For the environment injection of the current view, it must be completed in its ancestor views.

If a view declares a dependency on some environmental data but forgets to inject it in its ancestor views, it will not cause a compilation error. However, the application will crash directly when running to that view.

The managedObjectContext environment value provided by SwiftUI offers a foundation and convenience for using or operating Core Data elements in views.

Redux-like

SwiftUI + Combine is Apple’s declarative + reactive structure solution. The development logic of a SwiftUI application is very similar to the Redux design pattern. By adopting a unidirectional data flow method, it separates view description from data logic.

Under this model, we usually do not perform complex behaviors in views (unrelated to view description). By sending Action to the Store, let the Reducer adjust the program’s State, and the view is merely a representation of the current state.

Therefore, it is generally not recommended to directly fetch or manipulate Core Data data in views (except for very simple applications). Send the requirements to the Store, and after the data is processed and refined, submit it to the State. The data often used in views is not the native data produced by the Core Data framework (such as managed objects).

@FetchRequest is an exception. Although it completely breaks the logic and aesthetics of the unidirectional data flow, due to its excessive convenience, it is still widely used in SwiftUI development.

Common Core Data Element View Preview Faults

In cases where the application runs normally, the real reasons for preview crashes due to Core Data factors are actually not many.

Forgetting to Inject Context

A considerable proportion of crashes in previews of views containing Core Data elements are due to forgetting to inject the persistent store context (NSManagedObjectContext) into the environment values.

If you are using @Environment(\.managedObjectContext) var viewContext or @FetchRequest in your view, make sure to check if the corresponding PreviewProvider provides the correct context injection for the preview view, such as:

Swift
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
           .environment(\.managedObjectContext,
                        PersistenceController.shared.previewInMemory.viewContext)
    }
}

Incorrect Use of Singletons

Some developers prefer to use singletons in CoreDataStack, like the PersistenceController.shared.previewInMemory.viewContext used in the above code to complete the context injection in the preview view.

Due to the uniqueness of the SwiftUI App life cycle mentioned earlier, you cannot use singletons to inject persistent contexts in the root view. For example, the following code will cause a runtime error (but not a compilation error):

Swift
@main
struct PreviewStudyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.managedObjectContext,
                             PersistenceController.shared.container.viewContext)
        }
    }
}

And this error will cause all your views containing Core Data elements to crash.

The preview is also a simulator and will execute all the application’s code. If the App execution fails, all views cannot be previewed normally.

The correct way is to first reference the CoreDataStack singleton in the App, and then inject it:

Swift
@main
struct PreviewStudyApp: App {
    var container = PersistenceController.shared.container

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.managedObjectContext, container.viewContext)
        }
    }
}

Apart from not being able to use the CoreDataStack singleton in the App, it can be used normally in other parts of the code, including the Preview.

Other Common Core Data Faults

When we make modifications to Core Data’s DataModel, if the structure changes are too large and there is no mapping set, Core Data will not be able to perform automatic migration, causing application runtime errors. In this case, we usually delete the app on the simulator and reinstall it to solve the problem. Since the preview is also a simulator, similar problems may occur in its sandbox. You can try the repair methods for the preview simulator mentioned above to solve them.

Incorrect Use of Preview Modifiers

For views containing Core Data elements, use preview specific modifiers (Modifier) with caution in the preview. Some Modifiers may cause the preview simulator to operate in a more restricted state. For example:

Swift
struct Test_Previews: PreviewProvider {
    static var previews: some View {
        Test()
            .environment(\.managedObjectContext,
                         PersistenceController.shared.previewInMemory.viewContext)
            .previewLayout(.sizeThatFits)
    }
}

After adding .previewLayout, the view containing Core Data elements will not be able to be previewed normally.

Previewable but with Error Messages

Sometimes, views containing Core Data elements may show the following error message during preview:

image-20210827191644251

Switching the preview to dynamic mode usually allows it to display normally.

In some cases, even if the preview seems normal (actually, the data is not refreshed), switching to dynamic mode can also force a refresh of Core Data data.

Providing Core Data Data for SwiftUI Previews

In this section, we will introduce several ways to organize Core Data data for previews, improving the development efficiency of SwiftUI + Core Data.

The solutions introduced in this section are not only applicable to previews but also to Unit Tests. The demo code can be downloaded here.

Not Using Core Data Elements

The best way to prevent errors is not to give an opportunity for errors. SwiftUI usually adopts a Redux development model, by converting the fetched Core Data data into standard Swift structures to avoid using managed object contexts or managed objects in views.

For example, we have a Student managed object:

Swift
@objc(Student)
public class Student: NSManagedObject {
    @NSManaged public var name: String?
    @NSManaged public var age: Int32
}

We exchange data through native Swift structures:

Swift
struct StudentViewModel {
    var name: String
    var age: Int
}

extension Student {
    var viewModel: StudentViewModel {
        .init(name: name ?? "",
              age: Int(age))
    }
}

Create a Connect (or Controller) view for StudentRowView to handle data conversion. Use Swift structure data directly in StudentRowView.

Swift
struct StudentRowViewConnect: View {
    let student: Student
    var body: some View {
        StudentRowView(student: student.viewModel)
    }
}

struct StudentRowView: View {
    let student: StudentViewModel
    var body: some View {
        Text("\(student.name)'s age is \(student.age)")
    }
}

struct StudentRowView_Previews_2: PreviewProvider {
    static var previews: some View {
        let student = StudentViewModel(name: "fat", age: 18)
        StudentRowView(student: student)
    }
}

This method not only avoids the possibility of preview crashes but also makes it easier to use in code since the properties of the transformed ViewModel are controllable (no need for type conversion, no need to determine optional values, etc.).

Although the Redux mode of SwiftUI has many advantages, since there is only one type of presentation - the view, many data calculations and arrangements are often mixed in the view descriptions. By adding a parent view that is specifically designed to handle data for this type of view, the two logics can be effectively separated. This example is for demonstration purposes only; normally, the data preparation work of the Connect view would be much more complex.

Directly Using Managed Objects

Of course, we can still directly pass managed objects to the view. For ease of reuse in previews, we can create preview data in advance in CoreDataStack or wherever you find appropriate and call it directly during preview.

Swift
struct RowView: View {
    let item: Item
    var body: some View {
        VStack {
            Text("Item at \(item.timestamp!, formatter: itemFormatter)")
        }
    }
}

struct RowView_Previews: PreviewProvider {
    static var previews: some View {
        RowView(item: PersistenceController.shared.sampleItem)
    }
}
// Pre-set demonstration data
extension PersistenceController {
    var sampleItem: Item {
        let context = Self.shared.previewInMemory.viewContext
        let item = Item(context: context)
        item.timestamp = Date().addingTimeInterval(30000000)
        return item
    }
}

In-Memory Database

Starting from Xcode 12, Apple has added the inMemory option to the preset CoreDataStack template Persistence.swift, creating a dedicated Container for previews. This form of creating an in-memory database has been used in Unit Tests for a long time.

Core Data supports four types of persistent storage: Sqlite, XML, Binary, and In-Memory. However, the in-memory persistent storage we create in CoreDataStack is still of the Sqlite type. It saves data files to /dev/null as Sqlite. This kind of in-memory database has the same functionality as the standard Sqlite database except for the lack of persistence. The performance of an in-memory Sqlite database is slightly higher than that of a normal Sqlite database but is not significantly different.

The Core Data template in Xcode mixes the inMemory with the standard Sqlite Container definition. I personally prefer to separate them.

Swift
    lazy var previewInMemory: NSPersistentContainer = {
        let container = NSPersistentContainer(name: modelName)
        container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
        container.loadPersistentStores(completionHandler: { _, error in

            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
        let viewContext = container.viewContext
     

 // Create demonstration data
        for _ in 0..<10 {
            let newItem = Item(context: viewContext)
            newItem.timestamp = Date()
        }
        do {
            try viewContext.save()
        } catch {
            let nsError = error as NSError
            fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
        }
        return container
    }()

    lazy var container: NSPersistentContainer = {
        let container = NSPersistentContainer(name: modelName)
        container.loadPersistentStores(completionHandler: { _, error in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
        return container
    }()

After creating the persistent container, the code creates demonstration data in the database for preview. Batch-created data is useful for views using @FetchRequest in previews.

Swift
struct ContentView: View {
    @FetchRequest(
        sortDescriptors: [NSSortDescriptor(keyPath: \Item.timestamp, ascending: true)],
        animation: .default
    )
    private var items: FetchedResults<Item>

    var body: some View {
        ...      
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            .environment(\.managedObjectContext,
                         PersistenceController.shared.previewInMemory.viewContext)
    }
}

This method is usually used for data models that are not complex; otherwise, creating demonstration data would require a lot of code.

Pre-Set Complex Data in Bundle Database

How to create demonstration data for previews for applications with complex data models?

When I develop applications using SwiftUI + Core Data, the development of the Core Data part is completely separate from the UI construction of the application. After completing various methods for processing Core Data data, I usually create some very simple views or Unit Tests to verify the code and create test datasets. Thus, when developing the UI, I already have a demonstrable database file.

Using print, viewing debug output, po NSHomeDirectory(), and other means, you can get the URL of the database file in the simulator. Drag the three database files (including wal and shm) into the project and create an NSPersistentContainer using the database file in the Bundle for convenient previews of views using complex data models.

image-20210827202250305

Swift
    lazy var previewInBundle: NSPersistentContainer = {
        let container = NSPersistentContainer(name: modelName,managedObjectModel: Self.model())
        guard let url = Bundle.main.url(forResource: "PreviewStudy", withExtension: "sqlite") else {
            fatalError("Failed to retrieve database file from Bundle")
        }
        container.persistentStoreDescriptions.first?.url = url
        container.loadPersistentStores(completioner: { _, error in
            if let error = error as NSError? {
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
        return container
    }()

Use previewInBundle in previews:

Swift
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            .environment(\.managedObjectContext,
                         PersistenceController.shared.previewInBundle.viewContext)
    }
}

Although the Bundle is read-only, you can still add or modify data in the standard simulator or dynamic preview mode. After restarting the app or preview, the data will return to the original dataset in the Bundle (sometimes in preview mode, the data does not revert immediately and needs several dynamic mode switches to restore).

Enhanced Bundle Database

The aforementioned Bundle database facilitates developers in previewing views with complex data models. However, since the Bundle is read-only, any data you modify or create in the dynamic preview will not be truly persisted. If persistence is needed, the following solution can be used. It involves saving the database files from the Bundle into the Catch directory.

Swift
lazy var previewInCatch: NSPersistentContainer = {
    let container = NSPersistentContainer(name: modelName,managedObjectModel: Self.model())
    let fm = FileManager.default
    let DBName = "PreviewStudy"

    guard let sqliteURL = Bundle.main.url(forResource: DBName, withExtension: "sqlite"),
          let shmURL = Bundle.main.url(forResource: DBName, withExtension: "sqlite-shm"),
          let walURL = Bundle.main.url(forResource: DBName, withExtension: "sqlite-wal")
    else {
        fatalError("Unable to get database files from Bundle")
    }
    let originalURLs = [sqliteURL, shmURL, walURL]

    let storeURL = fm.urls(for: .cachesDirectory, in: .userDomainMask).first!

    let sqliteTargetURL = storeURL.appendingPathComponent(sqliteURL.lastPathComponent)
    let shmTargetURL = storeURL.appendingPathComponent(shmURL.lastPathComponent)
    let walTargetURL = storeURL.appendingPathComponent(walURL.lastPathComponent)

    let targetURLs = [sqliteTargetURL, shmTargetURL, walTargetURL]

    zip(originalURLs, targetURLs).forEach { originalURL, targetURL in
        do {
            if fm.fileExists(atPath: targetURL.path) {
                if Self.alwaysCopy {
                    try fm.removeItem(at: targetURL)
                    try fm.copyItem(at: originalURL, to: targetURL)
                }
            } else {
                try fm.copyItem(at: originalURL, to: targetURL)
            }
        } catch let error as NSError {
            fatalError(error.localizedDescription)
        }
    }

    container.persistentStoreDescriptions.first?.url = sqliteTargetURL
    container.loadPersistentStores(completionHandler: { _, error in
        if let error = error as NSError? {
            fatalError("Unresolved error \(error), \(error.userInfo)")
        }
    })
    return container
}()

Creating Managed Object Instances Directly Through EntityDescription

In fact, in Core Data, we can create managed object instances simply by obtaining the corresponding EntityDescription from the NSManagedObjectModel, without the need to create a container or context. Although managed object instances created in this way do not correspond to any persistent data, they are perfectly suitable for preview scenarios.

Swift
extension PersistenceController {
    static let itemByEntityDescription: Item = {
        // get Item entity description from Data Model
        guard let entityDescription = model().entitiesByName["Item"] else {
            fatalError()
        }
        let item = Item(entity: entityDescription, insertInto: nil)
        item.timestamp = Date.now
        return item
    }()
}

RowView(item: PersistenceController.itemByEntityDescription)

Conclusion

The demo code for this article can be downloaded here.

In my two years of using SwiftUI+Core Data, pain and pleasure have always accompanied each other. As long as you always maintain attentiveness, patience, equanimity, and a little bit of luck, you will always find a way to solve problems.

I'm really looking forward to hearing your thoughts! Please Leave Your Comments Below to share your views and insights.

Fatbobman(东坡肘子)

I'm passionate about life and sharing knowledge. My blog focuses on Swift, SwiftUI, Core Data, and Swift Data. Follow my social media for the latest updates.

You can support me in the following ways