A SwiftUI Introduction #3: A simple To Do List

Perceval Archimbaud
Shadow Tech Blog
Published in
8 min readSep 7, 2023

Based on what we discussed in the previous articles, I would like to conclude with a simple yet functional app. This will allow us to see how everything we have learned takes shape in a real app.

If you haven’t read the previous part of the series, you can find it here:
A SwiftUI Introduction #2: Data Flow in SwiftUI.

App functionalities

For our To Do List app, we want to be able to:

  • Create a new To Do
  • Delete an existing To Do
  • Display the list of all To Dos

We will not handle a “done” state for items, but you could try to add that functionality yourself if you would like.

Setup the project

Start by downloading the Playground app for you Mac or iPad. Once ready, open the Playground app, create a new App, and open it.

As you can see, Apple generates two files for us to start with:

  • ContentView.swift
  • MyApp.swift

In MyApp.swift, a structure is defined, inheriting from App. Above that, you will see a @main decorator. This should remind you of @State: the decorator used to indicate to SwiftUI that a variable is a component State.

Here, @main indicates the compiler that this MyApp structure is the entry point of the app. It defines the root Scene with its body property, and this root Scene defines its root View with its constructor.

@main
struct MyApp: App {
var body: some Scene { // The body property return the root Scene.
WindowGroup { // The root Scene initialized with its root View.
ContentView() // The root View.
}
}
}

We will not need to update this file for our app; everything will take place into the root View, defined in the ContentView.swift file.

The ContentView

The provided ContentView view contains some code that you should be able to understand by now. But, just to be sure, I will detail what happens in the body:

VStack { // The VStack here allows us to arrange the sub views vertically
// We display an image, described by a system name. SwiftUI will search
// SF Symbols* to find and display it.
Image(systemName: "globe")
.imageScale(.large) // We use a modifier to scale up the image
// Define its color with the accent color of the app
.foregroundColor(.accentColor)
Text("Hello, world!") // We display a text below the image
}

SF Symbols is an Apple library that provides thousands of icons. You can use it to quickly find well-designed and free icons for your apps. Learn more on it here: Apple’s SF Symbols.

The accent color of your app can be updated in the app settings in Playgrounds. By default, Playground defines a randomly selected color as accent color at the creation of an app.

Display our app title

In the app settings, you will be able to define a name for your app. Put whatever you want in there; I will go with “Tadazzz”! And since it is a great name, I want my users to see it.

Delete the content of the VStack of the ContentView, and replace it with:

HStack {
Image(systemName: "note.text")
Text("Tadazzz")
}
.font(.largeTitle)
.fontWeight(.bold)
.padding()

As you can see, I used an HStack to display an icon and my app title side by side.

An app window showing the app title written with large font.
Our app so far: a title

Create a list of To Dos

We have a nice title, which is great, but we are building a To-Do List, and “list” is the important term here.

Since we will be updating this list a bit later, we will make it a State.

Insert this before the body variable:

@State var todos: [String] = [
"Example #1",
"Example #2"
]

Then add the following just below our app title in the VStack.

List {
ForEach(todos, id: \.self) { todo in
Text(todo)
}
}

We are using two SwiftUI components here:

  • The List: it allows you to display its child components as a list, providing you with some interesting modifiers we will use later.
  • The ForEach: this one allows you to loop over some array, and to define a way to display the items.

We provide a key path as id property to the ForEach component, allowing SwiftUI to identify each item and refresh the UI properly. Since String is an Equatable type (you can use == operator to check the equality of two values), we simply provide \.self as key path (meaning we use the String itself, and not a property of it).

The id property is mandatory here because String does not conform to the Identifiable protocol (which requires that the class or structure have an id property).

It might be a lot to take in, and you can choose to trust me on this one, or dive deeper with the documentation.

An app window containing a large title on top and a list of to do items below.
The app now displays a list.

So! We now have an app displaying a To-Do List and a title. It’s still a bit light, but we are making progress.

Add a TextField to add new To-Dos

To allow users to create new To-Dos, we will add a TextField and a Button.

The TextField will be filled by users to define the To-Do content, and the Button will be use to insert the To-Do in the todos array.

To control and retrieve the String written by our user, we will need a new State variable. Add the following below the todos State:

@State var newTodo: String = ""

We also will need a method to handle the To-Do creation. Add this below the body declaration.

func createTodo() {
todos.append(newTodo)
newTodo = ""
}

When this method is called, we append the content of our State newTodo to our list of todos, then we reset it to an empty String. This way, our TextField will be emptied after the To-Do is created.

Finally, add the following below the app title to update the UI:

HStack {
TextField("Add something to do...", text: $newTodo)
Button(action: createTodo, label: {
Image(systemName: "plus.app.fill")
.imageScale(.large)
})
// Disable the button if the TextField is empty
.disabled(newTodo.isEmpty)
}
.padding()

As you can see above, we provide the TextField with a placeholder text and a Binding to our newTodo. Our Button will receive our method and an Image as a label.

You should now be able to create any To-Do you want. And since the List is scrollable, this is a behaviour you will not have to implement yourself!

Three app window showing the filling a of text field to create a new to do.
The new field in action

This is beginning to look nice. The last thing to do (even if our app is not quite ready to help us here 😏), is to allow users to delete completed To-Do items.

Add the delete action

In the same way we defined a method for handling the To-Do creation, add this method below the createTodo() one:

func deleteTodo(_ indexSet: IndexSet) {
todos.remove(atOffsets: indexSet)
}

This time, our method takes an argument of type IndexSet. This is needed to be able to pass this method to the modifier:

onDelete(perform: Optional<(IndexSet) -> Void>)

This modifier takes an optional callback function, which takes an IndexSet as argument. It’s our job to execute the deletion of the To-Do when the callback is called.

We can now apply the modifier to the ForEach inside our List:

ForEach(todos, id: \.self) { todo in
Text(todo)
}
.onDelete(perform: deleteTodo) // <- Add this
An app window showing a list of to do items, with an action button to delete the last one.
The delete action on a to do item.

I think we have just checked the last box of our list! We can now create, delete and visualise our To-Do items.

Review of the entire code

We’ve seen that we have only two files in our project, and one of them is left untouched since its generation by Playground.

MyApp.swift

import SwiftUI

@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

ContentView.swift

import SwiftUI

struct ContentView: View {
@State var todos: [String] = [
"Example #1",
"Example #2"
]
@State var newTodo: String = ""

var body: some View {
VStack {
HStack {
Image(systemName: "note.text")
Text("Tadazzz")
}
.font(.largeTitle)
.fontWeight(.bold)
.padding()
HStack {
TextField("Add something to do...", text: $newTodo)
Button(action: createTodo, label: {
Image(systemName: "plus.app.fill")
.imageScale(.large)
})
.disabled(newTodo.isEmpty)
}
.padding()
List {
ForEach(todos, id: \.self) { todo in
Text(todo)
}
.onDelete(perform: deleteTodo)
}
}
}

func createTodo() {
todos.append(newTodo)
newTodo = ""
}

func deleteTodo(_ indexSet: IndexSet) {
todos.remove(atOffsets: indexSet)
}
}

And that is how little code you need to have a fully functional app with SwiftUI. Two files, totaling 55 lines of code. We could have done this in fewer lines of code, and in a single file, but it would have been less clean, so it is not worth it.

Final words

I hope that you have enjoyed this SwiftUI Introduction series and that you will continue to experiment with this framework. There is a lot more that we could have done for this app, and I invite you to play with the code to add new functionalities to it. Here are some things you could try to implement:

  • Add another list action to allow user to update any of the To-Dos in the list.
  • Discover structures and allow the user to check or uncheck any To-Do in the list.
  • Adapt the design to be optimised for more devices. By default the app is able to run on every Apple devices, but the proposed UI is not really appropriate for an Apple Watch for example.
  • Find a way to save To-Do items between app runs.
  • And so much more! Let your imagination guide you.

Want to learn more?

Apple provides some interactive articles to help you find your way with SwiftUI: Apple’s SwiftUI Tutorials.

I also recommend the great YouTube channel Swiftful Thinking, which deep dives into SwiftUI, with hundreds of well-made videos.

All the articles of the series:

--

--