loading...
Cover image for How to make a task list using SwiftUI and Core Data

How to make a task list using SwiftUI and Core Data

maeganwilson_ profile image Maegan Wilson Originally published at Medium Updated on ・7 min read

This post is going to walk through how to make a task list app that stores the task in Core Data.

This app will have the following features:

  • Add a task
  • Complete a task
  • Delete a task
  • Persist data if the app is closed

Here is what the finished app will look like

Finished app "design"

The finished app can be found in this GitHub repo

GitHub logo maeganjwilson / taskList

CoreData and SwiftUI task list

This post is going to walk through how to make a task list app that stores the task in Core Data.

This app will have the following features:

  • Add a task
  • Complete a task
  • Delete a task
  • Persist data if the app is closed

Here is what the finished app will look like

Finished app "design"

The finished app can be found in this GitHub repo:

1. Create a new single page iOS app

Create a new Xcode project for a single view iOS app.

Check the boxes for SwiftUI and to use Core Data.

2. CoreData Entities and Attributes

The first thing we need to do is add an entity to the CoreData model. To do this, open ProjectName.xcdatamodeld, where ProjectName is what you called the project in Step 1, and click on Add Entity at the bottom of the window. Name the new entity Task.

The image below highlights…

1. Create a new single page iOS app

Create a new Xcode project for a single view iOS app.

Check the boxes for SwiftUI and to use Core Data.

2. CoreData Entities and Attributes

The first thing we need to do is add an entity to the CoreData model. To do this, open ProjectName.xcdatamodeld, where ProjectName is what you called the project in Step 1, and click on Add Entity at the bottom of the window. Name the new entity Task.

The image below highlights where to change the name in the Inspector.

picture of naming task

2.1 Adding attributes to the Task Entity

Next, we need the Task entity to have attributes to store the following information:

  • id: used as a unique identifier for each task
  • name: what the user will call the task
  • isComplete: defines whether or not a task is completed
  • dateAdded: to know when a task was added

To add attributes to Task, click the + in the Attributes section, and give the attribute a name and type. The GIF below shows how to do this.

GIF of steps above

This table describes each attribute and the type associated with the attribute.

Attribute Type
id UUID
name String
isComplete Bool
dateAdded Date

The ProjectName.xcdatamodeld should now look like the picture below.

Picture of Attributes Finished

2.2 Add a new Swift file

Now, we are adding a new swift file that will make Task identifiable making the List of tasks easier to call.

Add a new Swift file and call it Task+Extensions

In the file, add the following:


extension Task: Identifiable {
}

By adding the code above, the Task class now conforms to the Identifiable class.

2.3 Add CoreData to ContentView.swift

We need to add a variable that accesses our Managed Object Context in ContentView.swift. To do this, open ContentView.swift and add @Environment(.\managedObjectContext) var context before the body variable. ContentView should now look like this:


struct ContentView: View {
    @Environment(\.managedObjectContext) var context

    var body: some View {
        Text("Hello world!")
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

What did we do?

We declared context as an environment variable, meaning that the value is going to come from the view's environment. In this case, it is going to come from SceneDelegate.swift on lines 23 through 27 where context is declared and then given to ContentView().

3. UI Time and make it work!

We are now going to work on the UI in ContentView.swift.

3.1 Adding a TextField

Let's start by adding a TextField to the app. Change the Text(HelloWorld) to TextField(title: StringProtocol, text:Binding<String>). TextField needs two properties, a StringProtocol and a Binding<String>. For the StringProtocol, give it a property of "Task Name". When the TextField is empty, Task Name will appear in light gray.

Now, we still need a Binding<String>, this isn't as easy as TextField. We need to declare a variable for it. Before the body variable declaration, add @State private var taskName: String = "", and then make the second property of TextField $taskName. ContentView.swift should now look like this:


struct ContentView: View {
    @Environment(\.managedObjectContext) var context

    // this is the variable we added
    @State private var taskName: String = ""

    var body: some View {
        // this is the TextField that we added
        TextField("Task Name", text: $taskName)
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Since we are using SwiftUI, if you use the canvas, you can see what the UI looks like without having to run the app in the simulator.

What did we do?

I'm going to explain the parts of @State private var taskName: String = "" and why we needed to do this.

First, this is declaring a State property by using the @State property wrapper so that taskName is a binding value. A State property is going to store the value in taskName and allow the view to watch and update when the value changes.

3.2 Adding the task to our CoreData

First, we need to add a button so that when the user is done typing, they can then add the task to their list.

To do this, we are going to wrap TextField in an HStack and then add a Button(). When adding the button, the action should be self.addTask() and label in the button should be Text("Add Task).

Here's what the code in body should look like now.


var body: some View {
    HStack{
        TextField("Task Name", text: $taskName)
        Button(action: {
            self.addTask()
        }){
            Text("Add Task")
        }
    }
}

Now, this causes Xcode to give the error Value of type 'ContentView' has no member 'addTask', so this means we have to add the function addTask().

After the body variable, add the following:


func addTask() {
    let newTask = Task(context: context)
    newTask.id = UUID()
    newTask.isComplete = false
    newTask.name = taskName
    newTask.dateAdded = Date()

    do {
        try context.save()
    } catch {
        print(error)
    }
}

What did we do?

In addTask(), we made a new Task object and then gave the attributes of newTask values. Then we use the save() on context to add it to CoreData.

Here is what the UI looks like so far.

3.3 Creating the Task List

It's finally time to create the task list!

First, we need to make a fetch request to get the tasks that are added. Here's what we need to add the ContentView.


@FetchRequest(
    entity: Task.entity(),
    sortDescriptors: [NSSortDescriptor(keyPath: \Task.dateAdded, ascending: false)],
    predicate: NSPredicate(format: "isComplete == %@", NSNumber(value: false))
) var notCompletedTasks: FetchedResults<Task>

Now, I'm going to break this down a bit.

  • entity declares what Core Data entity we are retrieving
  • sortDescriptors describes how we want to sort the entities
  • predicate acts as a filter

So with the code above, we are asking for all Tasks that are not completed and for those Tasks to be sorted by date newest to oldest.

Next, we need to make a list that shows the tasks. Let's embed the HStack inside a VStack. It should look like this:


VStack {
    HStack {
        // TEXTFIELD CODE HERE
    }
}

Now, we can add a list. After the HStack, add the following:


List {
    Text("Hi")
}

This adds a list underneath the TextField and makes the UI look like this.

Next, we are going to make "Hi" repeat for however many tasks we have. Embed Text("Hi") inside a ForEach like this:


ForEach(notCompletedTasks){ task in
    Text("Hi")
}

We did not have to specify the id for notCompletedTasks in the ForEach because Task conforms to Identifiable thanks to our work in step 2.3.

If you run the app, then put in a task name and hitting Add Task will make another row of "Hi".

Let's make a new struct for a TaskRow view that will take in the task in ContentView.swift. Above ContentView(), add the following:


struct TaskRow: View {
    var task: Task

    var body: some View {
        Text(task.name ?? "No name given")
    }
}

Inside the Text you will see that we have to use the nil-coalescing operator, ??, to give a default value. The reason we do this is because the value for the Task attributes are optional and might not have a value.

Now, inside the ForEach replace the Text with TaskRow(task). ContentView.swift should have the following code.


import SwiftUI

struct TaskRow: View {
    var task: Task

    var body: some View {
        Text(task.name ?? "No name given")
    }
}

struct ContentView: View {
    @Environment(\.managedObjectContext) var context

    @FetchRequest(
        entity: Task.entity(),
        sortDescriptors: [NSSortDescriptor(keyPath: \Task.dateAdded, ascending: false)],
        predicate: NSPredicate(format: "isComplete == %@", NSNumber(value: false))
    ) var notCompletedTasks: FetchedResults<Task>

    @State private var taskName: String = ""

    var body: some View {
        VStack {
            HStack{
                TextField("Task Name", text: $taskName)
                Button(action: {
                    self.addTask()
                }){
                    Text("Add Task")
                }
            }
            List {
                ForEach(notCompletedTasks){ task in
                    TaskRow(task: task)
                }
            }
        }
    }

    func addTask() {
        let newTask = Task(context: context)
        newTask.id = UUID()
        newTask.isComplete = false
        newTask.name = taskName
        newTask.dateAdded = Date()

        do {
            try context.save()
        } catch {
            print(error)
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Here is how the app should work now.

Adding a task

4. Marking a task as complete!

Now, we are going to mark the task as complete, which should make the task disappear from the list.

First, we are going to embed the TaskRow into a Button and the action of the button is going to be self.updateTask(task). Now that will look like this.

Button(action: {
    self.updateTask(task)
}){
    TaskRow(task: task)
}

Next, we need to make a function called updateTask so that we can actually update the task and mark as complete.

After addTask, let's add func updateTask(_ task: task){}. Using the _ says that we can ignore the argument label when calling the function. If you want to read more about argument labels, click here to read my post about it, next, let's add the internals of the function.

let isComplete = true
let taskID = task.id! as NSUUID
let fetchRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "Task")
fetchRequest.predicate = NSPredicate(format: "id == %@", taskID as CVarArg)
fetchRequest.fetchLimit = 1
do {
    let test = try context.fetch(fetchRequest)
    let taskUpdate = test[0] as! NSManagedObject
    taskUpdate.setValue(isComplete, forKey: "isComplete")
} catch {
    print(error)
}

Let's dive into this a bit. The first thing we do is set a constant for the new value of isComplete for the task. Then, we set the id of the task to a constant to use in the predicate. Next, we need to create a fetch request that gets the specific task that we are updating. Then we perform the update.

Now, if you run the app, the app will allow you to add a task and then tap on it to mark as complete. Since we are only using the non-completed tasks in the list, the completed task disappears from the list. The gif below shows the final app.


Edit #1: Posted on 2019-01-09

Here's a finished project as an example if you wanted to implement a Core Data helper class for the functions addTask() and updateTask().

GitHub Link


If you enjoy my posts, please consider sharing it or Buying me a Coffee!

Buy Me A Coffee

Discussion

pic
Editor guide
Collapse
cpiersigilli profile image
Cesare Piersigilli

Your post has been very helpful to me. Thank you.
I would like to ask you if you can create a swift file that groups all the methods (addTask, updateTask, ect) that can then be used in each view.
In swift with uikit I had create CoredataHelper shared class for use in every app with little modify.
Thank you

Collapse
maeganwilson_ profile image
Maegan Wilson Author

You definitely can! I do this in my production apps. I generally then add the helper as an EnvironmentObject so that I can access it when I need it.

You can see my messy source code for one of my apps here. The HelperClasses folder will have the CoreData helper CalculationManager.swift. This updates and deletes items from my CoreData context.

Collapse
cpiersigilli profile image
Cesare Piersigilli

Thank you. It is very difficult, for me. Could you tell me how to extract the methods from your TaskList, since I don't think I can do it?

Thread Thread
maeganwilson_ profile image
Maegan Wilson Author

Here's a branch of the original project with what you're looking to do that's been implemented.

Create a new file with the functions like this one linked here.

In the ContentView.struct file, add let core_data_helper = CoreDataHelper(). Then in the button's actions, call the functions from core_data_helper.

Here's an example of addTask():

Button(action: {
    self.core_data_helper.addTask(self.taskName, context: self.context)
}){
    Text("Add Task")
}

Here's a link to what ContentView.swift should look like.

Thread Thread
cpiersigilli profile image
Cesare Piersigilli

Great solution. But, wanting to move from ContentView to CoreDataHelper:
@Environment(.managedObjectContext) var context
and
@FetchRequest(
entity: Task.entity(),
sortDescriptors: [NSSortDescriptor(keyPath: \Task.dateAdded, ascending: false)],
predicate: NSPredicate(format: "isComplete == %@", NSNumber(value: false))
) var notCompletedTasks: FetchedResults
How can it be done?
It's possible?
Thank you.

Thread Thread
maeganwilson_ profile image
Maegan Wilson Author

You can, but the @Environment and @FetchRequest are used with SwiftUI views. I wouldn't move the @Environment to make sure that there is a context associated with the view. I also like to leave my FetchRequests in the view because that's where I need the data to be viewed.

Thread Thread
cpiersigilli profile image
Cesare Piersigilli

Your explanation convinced me. Thank you.

Collapse
icekold profile image
ICE-KOLD

Whenever I add the notCompletedTasks to the forEach loop it says that my app has crashed. How could I fix this, thanks for any help. I tried with your code as well with the same crash error.

ForEach(notCompletedTasks) { task in
Text("hi")
}

Collapse
maeganwilson_ profile image
Maegan Wilson Author

Is your core data entity class extending Identifiable? If not, then you will need to specify the id property of the task.

Collapse
icekold profile image
ICE-KOLD

I have added the Identifiable extension without luck.

Collapse
andywalt profile image
Andy Walt

Hi Maegan! This is very helpful. I'm trying to do something similar but using a One-To-Many relationship with Core Data.

Collapse
maeganwilson_ profile image
Maegan Wilson Author

Thanks I'm glad you found it useful!

At some point, I'd like to have more posts about Core Data and SwiftUI.

Collapse
andywalt profile image
Andy Walt

Looking forward to them and I'll keep hoping stackoverflow can come to the rescue haha

Thread Thread
maeganwilson_ profile image
Maegan Wilson Author

I know this feeling all too well! Hacking with Swift is also a great guide!

Thread Thread
andywalt profile image
Andy Walt

Been using Paul's resources! It's my favorite but trying to make something is way different than following the guides haha. Appreciate your responsiveness!

Collapse
bobmcnerney profile image
bobmcnerney

Thanks for the helpful post Maegan!

It helped me, a SwiftUI beginner, a great deal. But when I tried to build up I definitely got stumped.

I have a need for similar functionality but with the need to update a text field such as "name" rather than the Boolean "isComplete". I changed your code to update the "name" field from a constant:

taskUpdate.setValue("testJohn", forKey: "name")

This did not update CoreData. But, If when I added the following after the .setValue, core data is updated.

try context.save()

However, the tab view is not updated until I close and re-open the app.

Any assistance that you can provide will be immensely helpful!

Collapse
maeganwilson_ profile image
Maegan Wilson Author

I’ve run into this issue myself. I solved this by using an environment object that stores an array of the objects. It’s probably a bit of a performance hit, but it was a work around. I do mean to look into it at some point though. I’m sorry that I couldn’t be more help.