Building a Simple Time Tracker With SwiftUI — Part 2
Last week we made the minimum viable product (MVP) for our time tracker app. This week we’ll make the second iteration and allow the user of the app to add their own projects and save the time tracked to UserDefaults so that it will persist between sessions. Find part one here, and the source code here. The source code for this article can be found in the branch post/part-2.
The goal for the second iterations is to have the user input their own projects, be able to track time for those projects and have the projects persist between sessions. To make this happen, we need to do some refactoring from part one. First we will delete hard coded ProjectViews in our ContentView and have them loaded from an array. Our
ContentView.swift should look like this:
Now we are loading a new
ProjectView() for each of the projects in the projects array. As you can see, we are creating a variable called projects (
var projects) that is an array of Project objects (the
: [Project]part). Then we are giving it two values to start with (this makes testing easier). This doesn’t change anything we see if we run the project on the mac or on the simulator, since we are initialising the array with the same values. Now the views are no longer hard coded and our code scales better.
Getting User Input
Since we want to allow the user to save their own projects and for simplicity we will be initializing the time-variable with a 0 when a new project is created, we only need one text field for the project name, and a button to save the project. So let’s add that.
Now we have a text field and the contents of that text field is bound to the projectName variable. When we hit the save button, it will append a new project to the projects array, sort the array alphabetically, and empty the text field (so we can add another project). Yes, there are problems with this code. We can add a project with empty name. We can add multiple projects with the same name (this will break the timer functionality as we are using the project name as the unique id in the ForEach loop). Put a pin on this, we will be cleaning up the code in another part later. But for now we’re are focusing on rapid iterations and getting the app to run with all the needed functionality. Testing, refactoring and proofing for user error will come later.
Saving Values to UserDefaults
The next piece of functionality we need for this iteration is saving the values to UserDefaults. This isn’t as straight forward for us since we are using a custom struct for our projects. UserDefaults is a big subject and I recommend that diving deeper. Paul Hudson has a great article on UserDefaults, you should read that. To make the code more readable in the
ContentView.swift I’m creating a file called
Utilities.swift . This is a practice I use in almost all of my projects, since I often have helper functions that I use throughout the project. If this was a bigger project, these helper functions would be divided into multiple files, grouped by their relevance (math in one, formatting in another, etc.).
We cannot save custom objects into UserDefaults, but we can convert them to JSON and save them in that form. It’s pretty easy too with Codable. All we need to do is encode our array of projects into JSON, save it into UserDefaults and decode it to an array of projects when we load it from UserDefaults.
For this to work we need to make our Project struct conform to Codable to be able to encode and decode it. Codable would be a long article on it’s own, so I will just give you the basics on what we’re working on here. Paul Hudson has a great article on Codable.
We have two functions: one to save and one to load. We use the built in JSONEncoder and JSONDecoder to get our projects array into JSON and our JSON back into a projects array.
You could make both of these helper functions easily reusable by giving the key in when calling the function and changing the return type, but since UserDefaults are currently used only to save and load our projects, I’ve hard coded our key “Projects” in the functions to avoid any issues caused by typos.
If you have a lot of data saved to UserDefaults, I recommend making variables for the strings that you use for keys. This allows Xcode to warn you if you’ve made a typo.
The only thing left is to call
saveProjects() when making a new project, stopping the timer, and later when deleting a project. Saving when making new project is easy, we just call
saveProjects(projects) when the user clicks the save button. But when we try to do the same in
ProjectView.swift we notice that our projects array is out of scope. We could pass a copy of the array when creating a
ProjectView but that brings issues on having a single source of truth. But if we use @State and @Binding we will always have a single source of truth for our projects array. We are already using @State in ContentView, so all we need to do is add the @Binding to
But there is still one problem here. Since we don’t have objects of projects in our UserDefaults, but a single JSON, we can’t edit a single field (like the time). We need to delete that project entirely and save it again. To do that, we get the index of the project (the place of that project in the array of projects), delete the entry in that index, and then add a new project with the same name but new time. For this to work, the Project needs to conform to Equatable. So now our button looks like this:
And our Project struct looks like this with the added conformances to Equatable and Codable:
Loading Values From UserDefaults
Now we have our values saved into UserDefaults, but we are not loading them at any point. That means that our default values are still the only ones used, and us saving the projects to UserDefaults is useless. We need to load the values from UserDefaults when starting the app. A very easy way to do that is using the
.onAppear() . This function fires when the View is appearing on screen. Great for animations, and for loading initial data when we launch the app.
Formatting the Shown Time
Currently we show the time in seconds. This is not ideal since most humans cannot calculate on the spot how much time 254 060 seconds is (it is 70 hours, 34 minutes and 20 seconds). We need to format our integer of seconds to be shown in hours, minutes and seconds. For this we will add a couple of functions in our
Separating Hours, Minutes and Seconds
The first step for us is to calculate hours, minutes and seconds from our integer of seconds.
Basic math to separate those values: you divide seconds with 3600 (the amount of seconds in one hour) to get the total hours. Then you get the remainder of that calculation to get the seconds that don’t add up to even hours, divide that by 60 (amount of seconds in a minute) to get the minutes. Then finally you take the remainder of minutes to get the left over seconds. Simple enough. It is also a private function, since we will only be using it inside our
formatTime() function that we will be writing next.
Now we have the time separated in to hours, minutes and seconds, we could go the lazy way and just format them by hand. Saving them in variables and having the Text view be
Text("\(hours):\(minutes):\(seconds)") which would work just fine, but has two issues. First issue: the integers don’t conform to the double number format (00, 12, 58…), they only show the necessary numbers. So if we were to show one hour, two minutes and three seconds, it would show like this:
1:2:3 . Secondly, it will always show hours. Personally, I’d like to hide the hours if there are none, and show them only when necessary.
Here’s what that function does: it takes an integer of seconds and uses our
secondsToHoursMinutesSeconds() function and assigns the values from the returned tuple to
let hours ,
let minutes , and
let seconds . Yes, that’s how easy it is to assign multiple variables in Swift, and one of the ways tuples can simplify your code. Then we have basic if else statements checking if minutes and seconds are single digits and uses string interpolation to add a “0” in front of the values that are under 10. For hours we don’t have the same check, since a value of 1:12:01 is a valid representation of time. Instead we check if we have hours and show the hours if they exist, or ignore the hours if there are none.
All we have to do is to use that function for our Text view showing the seconds for each project. So change that line in
Text("\(formattedTime(seconds: project.time + seconds))")
Now our app looks something like this:
That’s it for our current iteration. In later parts we’ll clean up our code, allow the user to delete projects and do some UI work and make our desktop, iPad and iPhone apps look better.
Looking forward to next week!
Building a Simple Time Tracker With SwiftUI — Part 1
In this series of articles I will be walking you through my process of building a time tracker app for iOS and macOS…