Example Project

SwiftUI Core Data example

Our next example is a SwiftUI app that makes use of the Core Data framework to store and manage a more complex set of objects locally. The example app is a simple gradebook app that allows the user to record grades for a students and a set of assignments and exams.

The app has a tabbed interface, with tabs that allow the user to create and manage a list of students, create and manage a set of quizzes, and a tab where the user can record grades for the quizzes.

Using Core Data to manage an app's data

Our example app needs to be able to store and retrieve collections of interrelated objects. This app will deal with Student objects, Quiz objects, and Grade objects. Furthermore, these things are related to each other: each Student is connected to a set of Grades and each Quiz is connected to a set of Grades.

The Core Data framework is a system for managing and storing networks of interrelated objects in an app. Your textbook has a couple of chapters on Core Data that demonstrate how to use this framework in a Storyboard application. This example will show you how to use Core Data in a SwiftUI application.

When you create a new app in Xcode one of the options you will see when setting up the project is a checkbox that you can check to include Core Data in your project. Checking this option automatically generates a lot of code and other resources for your project to get you started using Core Data. For a first example in Core Data this automatically generated code is going to be a bit overwhelming, so in setting up this example project I chose not to check that option when setting up the project. Instead, I am going to walk you through the process of setting up and using Core Data manually. This way we can include just enough Core Data in our project to be useful.

Creating a set of Entities

The classes that Core Data manages for us are called Entities. We will be using Core Data to set up these classes. The first step in setting up our Entities is to add a Data Model to our project. To create the Data Model, right click on the project folder in Xcode and select the option New File. In the New File dialog select the option Core Data/Data Model.

The Data Model tracks all of the Entities in our project along with their properties and relations. When the data model view opens you will see a special user interface that Core Data uses to set up all of these things.

To create a new entity in our data model we start by clicking the Add Entity button. We supply a name for our entity and then give our entity one or more properties. We add properties by clicking the + button in the Attributes section of the window. Each attribute has a name and a type. As we create entities in Core Data the system will automatically generate a class for each entity we create. That class will have properties that correspond to the attributes we gave to the entity.

As we create entity objects in our app Core Data will be storing those objects in a database that it generates for us. Specifically, that database is a SQLite database. For each entity type we set up in Core Data there will be a corresponding table in the database with columns that correspond to the attributes of the entity.

After setting up Student, Quiz, and Grade entities we will also want to describe the connections between these classes. We do this by adding Relationships to the data model. To create a new relationship, click on one of the entities involved in the relationship and click the + button in the Relationships section of the data model. A relationship connects one entity to another: the entity that the relationship comes from is the Source, while the entity the relationship connects to is the Destination. You can select a destination by setting the Destination property in the property inspector for the relationship. Another property you will want to pay attention to is the Type property. The options here are either 'To One' or 'To Many'. In a relationship that goes from entity A to entity B each A object can either be connected to a single B object or to a list of B objects. For example, in our app each Student object will be connected to many Grade objects, and each Quiz object will be connected to many Grade objects. Finally, we will also want to specify whether or not this is an inverse relationship. If the relationship is an inverse relationship from entity A to entity B, each B object will contain a property that allows us to get back to the A object it is connected to. For our app we will want both of our relationships to have inverses. That way each Grade entity will have a quiz property that links it back to the quiz the grade is for and a student property that links the grade to the student whose grade this is.

Adding code for Core Data

The next step in setting up Core Data in our app is to set up a PersistentContainer object. This object will be our gateway to the Core Data system. All requests to load and store entities will be funneled through this object. A convenient way to set up the PersistentContainer is to wrap it in a simple model class. The model class I use for that purpose is a DataController:

class DataController : ObservableObject {
    let container = NSPersistentContainer(name: "GradeBook")

    init() {
        container.loadPersistentStores {
            description, error in
            if let err = error {
                print("Core Data failed to load \(err.localizedDescription)")
            }
        }
    }
}

To make the container property of this class available throughout the app I set up a DataController in the app's App object and share its container as an Environment object:

@main
struct UIGradesApp: App {
    @StateObject private var dataController = DataController()
    var body: some Scene {
        WindowGroup {
            MainView().environment(\.managedObjectContext,dataController.container.viewContext)
        }
    }
}

The MainView class

Since our app's interface is a TabView with three views inside it, our app's first view class will set up the TabView:

struct MainView: View {
    @Environment(\.managedObjectContext) var moc
    var body: some View {
        TabView {
            StudentView().tabItem {
                Label("Students",systemImage: "person.fill")
            }
            QuizView().tabItem {
                Label("Quizzes",systemImage: "questionmark.folder")
            }
            GradeView().tabItem {
                Label("Grades",systemImage: "percent")
            }
     }
    }
}

A TabView is simply a list of views. We use the tabItem() modifier to set up the tab for each view. Typically a tabItem will contain a Label that displays both a string and an icon. Each of the views we set up in the tab view will automatically inherit a copy of our environment object, which will give the view access to our core data object context.

Creating and listing Students

The first two views are used to set up Students and Quiz objects. Here is the code for the StudentView class:

struct StudentView: View {
    @Environment(\.managedObjectContext) var moc
    @FetchRequest(sortDescriptors: [SortDescriptor(\.last_name),SortDescriptor(\.first_name)]) var students : FetchedResults<Student>
    @State var first_name : String = ""
    @State var last_name : String = ""
    var body: some View {
        VStack {
            List {
                ForEach(students) { student in
                    Text("\(student.first_name ?? "Unknown") \(student.last_name ?? "Unknown")")
                }.onDelete(perform: delete)
            }
            HStack {
                Text("First Name:")
                TextField("",text:$first_name).disableAutocorrection(true).textFieldStyle(.roundedBorder)
            }
            HStack {
                Text("Last Name:")
                TextField("",text:$last_name).disableAutocorrection(true).textFieldStyle(.roundedBorder)
            }
            Button("Add Student") {
                let student = Student(context: moc)
                student.id = UUID()
                student.last_name = last_name
                student.first_name = first_name
                try? moc.save()
            }
        }
    }

    func delete(at offsets: IndexSet) {
        offsets.forEach { i in
            moc.delete(students[i])
            try? moc.save()
        }
    }
}

A new kind of property you will see here is a Core Data @FetchRequest property. In Core Data a FetchRequest is essentially a database query that pulls a set of objects from the database and also includes information on how to order the objects as they come out of the database. In this view we are working with students, so we will want the list of all Student objects in the app to be ordered first by last name and then by first name.

By default a fetch request will load all of the objects of a given type that are in the database. You can fetch just a subset of these objects by also supplying a predicate in the fetch request. We will see an example of this later in these notes.

This view will display a list of all of the Student objects that are currently in the system along with some additional elements that allow the user to create new students. The action closure for the 'Add Student' button demonstrates how to use Core Data to make a new entity. We use the Student class that Core Data generated for us automatically to make a new Student object in our context, set its properties, and then tell the context to save changes to the database.

There is also code in this view to demonstrate how to delete Core Data objects. I added an onDelete() modifier to the list of students that calls the view's delete() method to handle deletions. Fortunately, our Core Data context provides a delete() method that we can pass an object to delete. The object to delete sits in the array linked to our original fetch request.

The second view, which manages the list of quizzes, is very similar.

Managing Grades

Once we have a set of Students and Quizzes set up in our app we will be able to start recording Grades. Here is what that view looks like:

At the top of the view are two Picker objects that allow the user to select a Student from the list of available students and a Quiz from the list of available quiz objects. Below the pickers we have a text field where the user can enter a grade for that student on that quiz and a button to save the grade to the database. Note that in some cases the student/quiz combination we selected will already have a grade: in that case we will simply want to update the grade that we previously recorded.

Here now is the code for the GradeView:

struct GradeView: View {
    @Environment(\.managedObjectContext) var moc
    @FetchRequest(sortDescriptors: []) var quizzes : FetchedResults<Quiz>
    @FetchRequest(sortDescriptors: [SortDescriptor(\.last_name),SortDescriptor(\.first_name)]) var students : FetchedResults<Student>
    @State var quiz : Quiz?
    @State var student : Student?
    var body: some View {
        if quizzes.count == 0 {
            Text("No quizzes available to display")
        } else if students.count == 0 {
            Text("No students available to display")
        } else {
            VStack {
                Picker(selection: $quiz,label:Text("Select a quiz:")) {
                    ForEach(quizzes) { q in
                        Text(q.name ?? "Unknown").tag(q.self as Quiz?)
                    }
                }
                Picker(selection: $student,label:Text("Select a student:")) {
                    ForEach(students) { s in
                        Text("\(s.first_name ?? "Unknown") \(s.last_name ?? "Unknown")").tag(s.self as Student?)
                    }
                }
                Spacer()
                if let q = quiz, let s = student {
                    let g = getGrade(quiz: q, student: s)
                    GradeDetailView(grade: g,student: s,quiz: q)
                }
                Spacer()
            }.onAppear {
                quiz = quizzes.first
                student = students.first
            }
        }
    }

    func getGrade(quiz q : Quiz,student s : Student) -> Grade! {
        let sPred = NSPredicate(format: "student.id == %@", s.id! as NSUUID)
        let qPred = NSPredicate(format: "quiz.id == %@", q.id! as NSUUID)
        let request = Grade.fetchRequest()
        request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [sPred,qPred])
        request.sortDescriptors = []
        var g : Grade! = nil
        do {
            let grades =  try moc.fetch(request)
            if grades.count > 0 {
                g = grades[0]
            }
        } catch {

        }
        return g
    }
}

As in the prior two views, there are @FetchRequest properties to load the list of all Student and Quiz objects. We then use the Picker class to make pickers that display these lists. When we set up a Picker we have to provide a selection property for the picker, which is a binding to a state variable that will store the user's selection from the picker. When we set up the items displayed in the picker with the ForEach construct we will also want to give each displayed choice a tag property that has the same type as the selection. When the user selects one of the picker items, the picker will copy the tag for that item into the selection property.

Once the user has selected a quiz from the first picker and a student from the second picker we will want to see if whether or not a Grade object has already been created for that combination. The logic needed to fetch that Grade object is encapsulated in the getGrade() method. The code there demonstrates another way to run a fetch request in Core Data. Since we are trying to fetch a Grade object, we start by asking the Grade class to give us a fetch request object. When then supply that fetch request with both predicates and sort descriptors, and then pass the fetch request to the Core Data context to do the fetch.

As you can see from the code above, setting up predicates to filter the results starts with creating NSPredicate objects. Each NSPredicate object contains a format string, which looks a little bit like an SQL prepared statement. The format string contains one or more @ placeholders to fill. The values for those placeholders are supplied by the arguments that follow the format string.

For this particular query we want to find the one Grade object that is linked to our student/quiz combination. To do this I set up two predicates. The first predicate will fetch all of the Grade objects in the database whose student property has an id number that matches the id number of the student we were told to look for. This effectively will grab all of the Grades for that student. Likewise, the second predicate will load all of the Grades for the quiz we are interested in. Finally, the actual predicate we use for the fetch request is a compound predicated formed by doing the and of the two simpler predicates. This will end up fetching just the one Grade that is in the system for that student and quiz. Since there may be no Grade in the database for that combination, we arrange for the getGrade() method to return an optional Grade. The method will return nil in cases where no grade is currently recorded.

Once we have located the Grade we want, we pass that Grade to a GradeDetailView class that will allow the user to update an existing grade or add a new Grade. Here is the code for that view class:

struct GradeDetailView: View {
    @Environment(\.managedObjectContext) var moc
    let grade : Grade!
    let student : Student!
    let quiz : Quiz!
    @State var score : String

    init(grade g : Grade!,student s : Student!,quiz q : Quiz!) {
        grade = g
        student = s
        quiz = q
        if let gr = grade {
            _score = State(initialValue: String(gr.points))
        } else {
            _score = State(initialValue: "")
        }
    }

    var body: some View {
        VStack {
            HStack {
                Text("Score:")
                TextField("",text:$score)
            }
            Button("Save") {
                if let g = grade {
                    g.points = Int32(score) ?? 0
                } else {
                    let g = Grade(context: moc)
                    g.id = UUID()
                    g.quiz = quiz
                    g.student = student
                    g.points = Int32(score) ?? 0
                }
                try? moc.save()
            }
        }
    }
}

This view contains a state variable linked to the text field where the user can enter a grade. We want to prefill that text field with the existing grade if there is one, so I added a custom init() method here that will determine whether or not there is an existing Grade. If there is, we will want to copy that Grade's points attribute into the score property. The code here shows the special procedure needed to initialize a state variable in an init() method.

In the closure linked to the 'Save' button you can see that code to either update the points property of an existing Grade or create a new Grade object. After making either change we call the context's save() method to commit the changes to the database.