In a recent tutorial, we looked at the SwiftUI List and included a very brief introduction to Swipe actions. In this tutorial we’ll expand on this in the form of a simple To-Do list app that allows you to swipe to flag or delete tasks. We will also utilise SwiftData hosted in CloudKit so that tasks can be synchronised across devices.
Start by creating a new iOS app by opening Xcode and selecting New > Project.
Select SwiftUI for interface, Swift for language, SwiftData for storage, and make sure Host in CloudKit is selected. Ensure that you select a Team that is a company and not the personal option. SwiftData stored in iCloud requires a development team to be selected.
When done, choose a location to save the project. It is your choice if you select to create a Git repository on your Mac. We wont be using version control here, but if you want to do so, then go ahead and check that box.
The new project will launch. With you specifying SwiftData and Host in CloudKit, the project is almost set up and ready to automatically store data in iCloud so that any other devices you have that are logged into the same account. The only thing left is creating a container in iCloud to store the data. To do that, select the project at the top left of the left menu, then select the app listed under Targets, and finally select the Signing & Capabilities tab. Scroll down to this section:
Note that there is no container specified. If you have one listed, you can select one, but ideally, you should click the + button and create a new one. Write something in reverse domain format such as com.devfright.yourappname. It will add iCloud to the front of it automatically. Make sure that you have selected your development team for this to work.
Now that the project is ready to go, let’s begin by creating a model.
import Foundation
import SwiftData
@Model
final class ToDoItem {
var timestamp: Date = Date()
var task: String?
var note: String?
var due: Date?
var completed: Bool = false
var flagged: Bool = false
init (task: String, note: String? = nil, due: Date? = nil) {
self.task = task
self.note = note
self.due = due
}
}
This model is a class called ToDoItem. SwiftData requires that all properties are either declared or marked as optional, or you will see the following error:
CloudKit integration requires that all attributes be optional, or have a default value set.
We then have an initialiser that takes a task as a string, note as an optional string (defaulted to nil), and a due date that is also optional and defaulted to nil.
When creating a ToDoItem, the completed and flag booleans are defaulted to false as it would be odd to create a task already completed.
To use SwiftData within our app, we need to add the model container. We can do that at the app entry point. This uses the basic settings for a model container, which are sufficient for our needs in this example.
@main
struct To_DoApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: ToDoItem.self)
}
}
For this, we only have one model to use, so the code is relatively easy to implement, given that we will use all of the default options.
The model container is an intermediary between its associated model contexts and your app’s underlying persistent storage. The context for each model provides the ability to save, fetch, sync, cache, and so on. In simpler terms, you don’t need to worry about how the data is stored and manipulated because this is efficiently handled for you.
By adding ToDoItem.self in the model container, we are making the ToDoItem model accessible for SwiftData.
Now that storage is in place, the next challenge is to build our views so that we can start added and working with the data in the app.
In our ContentView we need to add the @Environment property wrapper as follows:
@Environment(\.modelContext) private var modelContext
The property wrapper, @Environment, gives us access to the app’s environment from the view. The environment is accessible from any view in the app, meaning that wherever you want to access the environment, you specify this in the struct.
The \.modelContext is available when we use the .modelContainer in the scene like we just did in an earlier step.
We then set this to be a private var and named it modelContext. This provides the ability to call something like modelContext.insert(item) and automatically have the item persist. If the item is of type ToDoItem, it will be inserted. If you have multiple models specified in the container, you pass in the item of any valid types, and Swift will handle the storage for you.
The next step is to use @Query to fetch the data:
@Query(sort: [SortDescriptor(\ToDoItem.due, order: .forward)]) private var toDoItems: [ToDoItem]
@Query provides the ability to pull data from SwiftData. The example above uses sort with an array of SortDescriptore where we specify the model and property, in this case, the due date and then the order.
It stores the results in a private variable called toDoItems, an array of ToDoItem. This macro stays up to date when the data changes. When changes happen, the view is rendered automatically. SwiftData handles all of this.
You’ll see errors at this point. Just add the following to the top:
import SwiftData
We will come back to ContentView later on in the tutorial.
Building the Views
SwiftUI provides the ability to break views down into subviews or components. Doing this lets you make the code readable and separate view logic into different files. It also means that components can be reused, which prevents the need to duplicate code.
The ToDo list app is going to have a straightforward interface. It will be a table with each row being a task. Because SwiftUI allows to break down views into smaller views, I opted to put the information in a view and then have a view called ToDoItemRow that contains the buttons and swipe gestures. Here is the code for ToDoItemInfo:
struct ToDoItemInfo: View {
let item: ToDoItem
var body: some View {
HStack {
if item.flagged {
Image(systemName: "flag.fill")
.foregroundColor(.orange)
.padding(.trailing, 5)
}
VStack(alignment: .leading, spacing: 5) {
Text(item.task ?? "No Title")
.font(.system(size: 20, weight: .bold))
.foregroundColor(item.completed ? .gray : .primary)
.strikethrough(item.completed, color: .gray)
Text(item.note ?? "")
.font(.system(size: 16, weight: .regular))
.foregroundColor(item.completed ? Color(UIColor.lightGray) : .gray)
if let dueDate = item.due {
let isOverdue = dueDate < Date()
Text("Due: \(dueDate.formatted(.dateTime))")
.padding(4)
.background(isOverdue ? Color.red : Color.clear)
.foregroundColor(isOverdue ? .white : .primary)
.cornerRadius(8)
.font(.system(size: 12, weight: .regular))
}
}
}
}
}
This view require a ToDoItem be passed in. We nest the views in a HStack.
On line 6, we check to see if the item is flagged. If it is, then it shows a small orange flag image. If not, this section is skipped over.
Line 12 begins a VStack that includes a place for the title and below that, a note. If a due date was set, this is included below the note. We use a number of modifiers on each view to make it look better.
Now that we have the ToDoItem passed in, we can access the item’s task, note, and due date.
This view is called from ToDoItemRow and is implemented as follows:
struct ToDoItemRow: View {
let item: ToDoItem
let toggleCompletion: (ToDoItem) -> Void
let flagItem: (ToDoItem) -> Void
let deleteItem: (ToDoItem) -> Void
var body: some View {
HStack {
ToDoItemInfo(item: item)
Spacer()
Button(action: {
toggleCompletion(item)
}) {
Image(systemName: item.completed ? "checkmark.circle.fill" : "circle")
.resizable()
.frame(width: 24, height: 24)
.foregroundColor(item.completed ? .green : .gray)
}
.buttonStyle(PlainButtonStyle())
}
.padding(.vertical, 8)
.swipeActions(edge: .leading) {
Button {
flagItem(item)
} label: {
Label("Flag", systemImage: "flag")
}
.tint(.orange)
}
.swipeActions {
Button(role: .destructive) {
deleteItem(item)
} label: {
Label("Delete", systemImage: "trash")
}
.tint(.red)
}
}
}
The ToDoItemRow will consist of the ToDoItemInfo and have some extra features which include the ability to toggle if the task is complete, swipe to flag, and swipe to delete.
Line 2 expects a ToDoItem to be passed in.
Lines 3 – 5 are closures. In short, a closure is called on line 26 “flagItem(item)”. In our use case, the parent view handles this request and flags the item. Doing it this way is common so that the child view does not need to manage data. It simply displays it, and then the parent can do the work.
We put these items in an HStack. The ToDoItemInfo is first. We pass in the item containing all the view needs to render the title, note, and due date. Note that this is declared with let, which makes it a constant. This particular view does not modify the item.
We then add a spacer to push the subview to the left and the next item on line 13, a button, to the right. The button has a checkmark.circle.fill for the image and when tapped, it calls toggleCompletion and passes the current item.
The view heirarchy is that we have the content view which shows ToDoItemRows with each ToDoItemRow showing ToDoItemInfo.
Finishing the ContentView
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: [SortDescriptor(\ToDoItem.due, order: .forward)]) private var toDoItems: [ToDoItem]
@State private var sheetVisible = false
var body: some View {
NavigationView {
List(toDoItems) { item in
ToDoItemRow(
item: item,
toggleCompletion: { toggleCompletion(item) },
flagItem: { flagItem(item) },
deleteItem: { deleteItem(item) }
)
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle("To-Do List")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
Spacer()
Button(action: {
sheetVisible.toggle()
}) {
Image(systemName: "plus")
}
.sheet(isPresented: $sheetVisible) {
CreateTaskSheet()
}
}
}
}
}
}
private func toggleCompletion(_ item: ToDoItem) {
item.completed.toggle()
}
private func flagItem(_ item: ToDoItem) {
item.flagged.toggle()
}
private func deleteItem(_ item: ToDoItem) {
modelContext.delete(item)
}
}
The query on line 6 automatically populates the array of ToDoItems, and when changes are made, it also keeps it updated and refreshes the view.
In the body we’re going to use a List which is declared at the end of line 6.
A sheetVisible bool has also been included. The sheet hasn’t been implemented yet, but will be in the next step. This boolean is changed to indicate if the sheet should be visible or not.
On line 10 we create a NavigationView, and from lines 19 onwards we add a title, a + button for adding a new task, and set the title and plus button to be inline.
Line 30 is where the sheet will be presented with the sheetVisible toggled as needed.
Lines 11 to 18 loop around the toDoItems list and pass in the parameters to the ToDoItemRow view that we created earlier.
Line 39 onwards declares three methods for manipulating the data. We provide the ability to toggle, flag, and delete a todo item. With SwiftData managing how data is stored, we don’t need to specificaly save changes, although if you want to, you can use modelContext.save() to force an immediate save.
Create Task Sheet
One final step in this demo app is to create a sheet that is made visible on screen so that the user can add a new task:
import SwiftUI
import SwiftData
struct CreateTaskSheet: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) var dismiss
@State private var todoItem = ""
@State private var note = ""
@State private var dueDate: Date = {
let calendar = Calendar.current
let now = Date()
let today = calendar.startOfDay(for: now)
let sevenPMToday = calendar.date(bySettingHour: 19, minute: 0, second: 0, of: today)!
return now > sevenPMToday ? calendar.date(byAdding: .day, value: 1, to: sevenPMToday)! : sevenPMToday
}()
var body: some View {
NavigationView {
VStack(spacing: 20) {
HStack {
Button("Cancel") {
dismiss()
}
.foregroundColor(.red)
Spacer()
Text("New Task")
.font(.title3)
.bold()
Spacer()
Button("Add") {
addItem()
}
.foregroundColor(.blue)
.disabled(todoItem.isEmpty)
}
.padding()
.background(Color(UIColor.systemGray6))
VStack(alignment: .leading, spacing: 20) {
TextField("Task", text: $todoItem)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding(.horizontal)
ZStack(alignment: .topLeading) {
if note.isEmpty {
Text("Add a note...")
.foregroundColor(.gray)
.padding(.horizontal, 8)
.padding(.vertical, 12)
}
TextEditor(text: $note)
.padding(8)
.background(Color(UIColor.systemGray6))
.cornerRadius(10)
.overlay(RoundedRectangle(cornerRadius: 10)
.stroke(Color.gray.opacity(0.5), lineWidth: 1))
.frame(height: 150)
}
.padding(.horizontal)
VStack(alignment: .leading) {
Text("Due Date")
.font(.headline)
.padding(.leading)
DatePicker(
"Select a date and time",
selection: $dueDate,
displayedComponents: [.date, .hourAndMinute]
)
.datePickerStyle(.compact)
.labelsHidden()
.padding(.horizontal)
}
}
.padding(.top, 20)
Spacer()
}
.background(Color(UIColor.systemBackground))
.navigationBarTitleDisplayMode(.inline)
}
}
private func addItem() {
let newToDoItem = ToDoItem(task: todoItem, note: note, due: dueDate)
modelContext.insert(newToDoItem)
dismiss()
}
}
struct CreateTaskSheet_Previews: PreviewProvider {
static var previews: some View {
CreateTaskSheet()
}
}
On line 12, we access the modelContext which is needed later on in the code to insert new todo items.
Line 13 accesses dismiss from the environment. This particular enviroment object allows the view to dismiss itself when needed.
On the next few lines, 8 to 18, we declare some @State variables so that we can work with some data, particularly the todoItem, the associated note, and the due date which is computed by default as 7pm today, or 7pm tomorrow if past 7pm today.
Our view is fairly simple again, although may look a little more complicated due to all of the modifiers in there which make it look a lot larger.
It begins with a NavigationView on line 21. The next few lines contain code to show the cancel button (which dismisses the view), a title, and an Add button which is used to create the task.
We then have some nested V and Z stacks with modifiers to put the view together which includes a text field at the top with “Task” as a placeholder, followed by TextEditor which is where the note is put. The ZStack is used to put a placeholder in the TextEditor to give the user a hint at what needs to be put in the box.
We follow all of this on lines 68 onwards with a Due date which includes a picker for selecting the time. It is defaulted to today at 7pm as mentioned above.
When the new task is created, you can hit the Add button which calls line 92 onwards and creates the new ToDoItem and inserts it in the modelContext. At this point, the task will be saved. The view is then dismissed.
Closing Comments
Overall, this is a fairly simple project to build that shows you how to work with SwiftData in a simple way and shows a List in use, a model, the environment, as well as the ability to sync across devices logged into the same Apple account.
Any questions, please post below.
Leave a Reply
You must be logged in to post a comment.