Chapters

Hide chapters

SwiftUI Apprentice

Second Edition · iOS 16 · Swift 5.7 · Xcode 14.2

Section I: Your First App: HIITFit

Section 1: 12 chapters
Show chapters Hide chapters

Section II: Your Second App: Cards

Section 2: 9 chapters
Show chapters Hide chapters

8. Saving History Data
Written by Caroline Begbie

@AppStorage is excellent for storing lightweight data such as settings and other app initialization. You can store other app data in property list files, in a database such as SQLite or Realm, or in Core Data. Since you’ve learned so much about property list files already, you’ll save the history data to one in this chapter.

The saving and loading code itself is quite brief, but when dealing with data, you should always be aware that errors might occur. As you would expect, Swift has comprehensive error handling so that, if anything goes wrong, your app can recover gracefully.

In this chapter, you’ll learn about error checking techniques as well as saving and loading from a property list file. Specifically, you’ll learn about:

  • Optionals: nil values are not allowed in Swift unless you define the property type as Optional.
  • Debugging: You’ll fix a bug by stepping through the code using breakpoints.
  • Error Handling: You’ll throw and catch some errors, which is just as much fun as it sounds. You’ll also alert the user when there is a problem.
  • Closures: These are blocks of code that you can pass as parameters or use as completion handlers.
  • Serialization: Last but not least, you’ll translate your history data into a format that can be stored.

Adding the Completed Exercise to History

➤ Continue with your project from the previous chapter, or open the project in this chapter’s starter folder.

➤ Open HistoryStore.swift and examine addDoneExercise(_:). This is where you save the exercise to exerciseDays when your user taps Done.

Currently, on initializing HistoryStore, you create a fake exerciseDays array. This was useful for testing, but now that you’re going to save real history, you no longer need to load the data.

➤ In init(), comment out createDevData().

➤ Build and run your app. Start an exercise and tap Done to save the history. Your app performs addDoneExercise(_:) and crashes with Fatal error: Index out of range.

Xcode highlights the offending line in your code:

if today.isSameDay(as: exerciseDays[0].date) {

This line assumes that exerciseDays is never empty. If it’s empty, then trying to access an array element at index zero is out of range. When users start the app for the first time, their history will always be empty. A better way is to use optional checking.

Using Optionals

Skills you’ll learn in this section: optionals; unwrapping; forced unwrapping; filtering the debug console

Swift Dive: Optionals

In the previous chapter, to remove a key from Preview’s UserDefaults, you needed to assign nil to ratings.

So you defined ratings as an optional String type by adding ? to the type:

@AppStorage("ratings") static var ratings: String?

ratings here can either hold a string value or nil.

You may have learned that Booleans can be either true or false. But an optional Boolean can hold nil, giving you a third alternative.

Swift Tip: Optional is actually an enumeration with two cases: some(Wrapped) and none, where some has a generic value of type Wrapped and none has no value.

Checking for nil can be useful to prevent errors. At compile time, Xcode prevents Swift properties from containing nil unless you’ve defined them as optional. At run time, you can check that exerciseDays is not empty by checking the value of the optional first:

if exerciseDays.first != nil {
  if today.isSameDay(as: exerciseDays[0].date) {
    ...
  }
}

When first is nil, the array is empty, but if first is not nil, then it’s safe to access index 0 in the array. This is true, because exerciseDays doesn’t accept nil values. You can have arrays with nil values by declaring them like this:

var myArray: [ExerciseDay?] = []

The more common way of checking for nil is to use:

if let newProperty = optionalProperty {
  // code executes if optionalProperty is non-nil
}

This places a non-optional unwrapped result into newProperty. Unwrapped here means that newProperty is assigned the contents of optionalProperty as long as optionalProperty is not nil.

Note: When the test is a simple one, you can shorten the test and assignment from if let varName = varName {...} to if let varName {...}.

➤ Change if today.isSameDay(as: exerciseDays[0].date) { to:

if let firstDate = exerciseDays.first?.date {

if let tells the compiler that whatever follows could result in nil. The property first? with the added ? means that first is an optional and can contain nil.

If exerciseDays is empty, then first? will be nil and your app won’t perform the conditional block, otherwise firstDate will contain the unwrapped first element in exerciseDays.

Swift Dive: Forced Unwrapping

If you’re really sure your data is non-nil, then you can use an exclamation mark ! on an optional. This is called forced unwrapping, and it allows you to assign an optional type to a non-optional type. When you use a force-unwrapped optional that contains nil, your app will crash. For example:

let optionalDay: ExerciseDay? = exerciseDays.first
let forceUnwrappedDay: ExerciseDay = exerciseDays.first!
let errorDay: ExerciseDay = exerciseDays.first
  • optionalDay is of type ExerciseDay? and allows nil when exerciseDays is empty.
  • forceUnwrappedDay is not optional and could cause a runtime error if exerciseDays is empty and you force-unwrap first.
  • errorDay causes a compile error because you are trying to put an optional which could contain nil into a property that can’t contain nil.

Unless you’re really certain that the value will never contain nil, don’t use exclamation marks to force-unwrap it!

Multiple Conditionals

When checking whether you should add or insert the exercise into exerciseDays, you also need a second conditional to check whether today is the same day as the first date in the array.

➤ Change if let firstDate = exerciseDays.first?.date { to:

if let firstDate = exerciseDays.first?.date,
  today.isSameDay(as: firstDate) {

You can stack up conditionals, separating them with a comma. Your second conditional evaluates the Boolean condition. If firstDate is not nil, and today is the same day as firstDate, then the code block executes.

➤ At the end of addDoneExercise(_:), add:

print("History: ", exerciseDays)

This will print the contents of exerciseDays to the debug console after adding or inserting history.

➤ Build and run, complete an exercise and tap Done.

Your app doesn’t crash, and your completed exercise prints out in the console.

Completed exercise log
Completed exercise log

Note: If you have trouble finding your print commands in the console, you can filter on words that you expect to see, such as in this case History.

Debugging HistoryStore

Skills you’ll learn in this section: breakpoints

Even though the contents of exerciseDays appears correct at the end of addDoneExercise(_:), if you tap History, your history data is blank. This is a real-life frustrating situation where you’re pretty sure you’ve done everything correctly, but the history data refuses to stay put.

Time to put your debugging hat on.

The first and often most difficult debugging step is to find where the bug occurs and be able to reproduce it consistently. Start from the beginning and proceed patiently. Document what should happen and what actually happens.

➤ Build and run, complete an exercise and tap Done. The contents of exerciseDays print out correctly in the debug console. Tap History and the view is empty, when it should show the contents of exerciseDays. This error happens every time, so you can be confident at being able to reproduce it.

Error Reproduction
Error Reproduction

An Introduction to Breakpoints

When you place breakpoints in your app, Xcode pauses execution and allows you to examine the state of variables and, then, step through code.

➤ Still running the app, with the first exercise done, in Xcode click the line number to the left of let today = Date() in addDoneExercise(_:). This adds a breakpoint at that line.

Breakpoint
Breakpoint

➤ Without stopping your app, complete a second exercise and tap Done.

When execution reaches addDoneExercise(_:), it finds the breakpoint and pauses. The Debug navigator shows the state of the CPU, memory and current thread operations. The debug console shows a prompt — (lldb) — allowing you to interactively debug.

Execution paused
Execution paused

Above the debug console, you have icons to control execution:

Icons to control execution
Icons to control execution

  1. Deactivate breakpoints: Turns on and off all your breakpoints.
  2. Continue program execution: Continues executing your app until it reaches another active breakpoint.
  3. Step over: If the next line to execute includes a method call, stop again after that method completes.
  4. Step into/out: If your code calls a method, you can step into the method and continue stepping through it. If you step over a method, it will still be executed, but execution won’t be paused after every instruction.

➤ Click Step over to step over to the next instruction. today is now instantiated and contains a value.

➤ In the debug console, remove any filters, and at the (lldb) prompt, enter:

po today
po exerciseDays

po prints out in the debug console the contents of today and exerciseDays:

Printing out contents of variables
Printing out contents of variables

In this way, you can examine the contents of any variable in the current scope.

Even though exerciseDays should have data from the previous exercise, it now contains zero elements. Somewhere between tapping Done on two exercises, exerciseDays is getting reset.

➤ Step over each instruction and examine the variables to make sure they make sense to you. When you’ve finished, drag the breakpoint out of the gutter to remove it.

The next step in your debugging operation is to find the source of truth for exerciseDays and when that source of truth gets initialized. You don’t have to look very far in this case, as exerciseDays is owned by HistoryStore.

➤ At the end of init() add:

print("Initializing HistoryStore")

➤ Build and run, and reproduce your error by performing an exercise and tapping Done. In the debug console, filter on History.

Your console should look like this:

Initializing HistoryStore
Initializing HistoryStore

Now you can see why exerciseDays is empty after performing an exercise. Something is reinitializing HistoryStore!

➤ Open ContentView.swift. This is where you initialize HistoryStore in an environment object modifier on TabView.

You may remember from the end of the previous chapter that @SceneStorage reinitializes TabView when it stores selectedTab. The redraw re-executes environmentObject(HistoryStore()) and incorrectly initializes HistoryStore with all its data.

You’ve now successfully debugged why your history data is empty. All you have to do now is decide what to do about it.

This first step to fix this is to move the initialization of HistoryStore up a level in the view hierarchy.

➤ Remove .environmentObject(HistoryStore()) from ContentView’s body.

@StateObject

@State, being so transient, is incompatible with reference objects and, as HistoryStore is a class, @StateObject is the right choice here. @StateObject is a read-only property wrapper. You get one chance to initialize it, and you can’t change the property once you set it.

➤ Open HIITFitApp.swift and add a new property to HIITFitApp:

@StateObject private var historyStore = HistoryStore()

➤ In body, add this modifier to ContentView().

.environmentObject(historyStore)

You place the store into the environment. In case you’re confused about all the property wrappers you’ve used so far, you’ll review them in Chapter 11, “Managing Data With Property Wrappers”.

➤ Build and run, perform all four exercises, tapping Done after each, and check your history:

Successful history store
Successful history store

Congratulations! You fixed your first bug! You can now remove all your print statements from HistoryStore.swift with pride and a sense of achievement.

Now you can continue on and save your history so that it doesn’t reset every time you restart your app.

Swift Error Checking

Skills you’ll learn in this section: throwing and catching errors

Saving and loading data is serious business, and if any errors occur, you’ll need to know about them. There isn’t a lot you can do about file system errors, but you can let your users know that there has been an error, and they need to take some action.

➤ Open HistoryStore.swift and add a new enumeration to HistoryStore:

enum FileError: Error {
  case loadFailure
  case saveFailure
}

These are the two errors that you’ll check for.

To create a method that raises an error, you mark it with throws and add a throw statement.

➤ Add this new method to HistoryStore:

func load() throws {
  throw FileError.loadFailure
}

Here, you’ll read the history data from a file on disk. Currently, this method always raises an error, but you’ll come back to it later when you add the loading code. When you throw an error, the method returns immediately and doesn’t execute any following code. It’s the caller that should handle the error, not the throwing method.

Swift Dive: do…catch

To handle an error from a throwing method, you use the expression:

 do {
   try methodThatThrows()
} catch {
  // take action on error
}

If you don’t need to handle any errors specifically, instead of enclosing the try in a do..catch statement, you can call the method with try?. For example, try? load(), converts an error result to nil and execution continues.

If you have several errors, you can add a pattern to catch. For example:

do {
  try load()
} catch FileError.loadFailure {
  // load failed
} catch {
  // any other error
}

Call your load method from HistoryStore’s initializer.

➤ Add this at the end of init():

do {
  try load()
} catch {
  print("Error:", error)
}

load() throws, so you embed try in a do...catch. If there’s an error, the catch block executes.

➤ Build and run. Currently load() always throws, so in the debug console, you’ll see your printed error: Error: loadFailure. (Remember to clear the debug console Filter if you don’t see the error.)

Alerts

Skills you’ll learn in this section: Alert view

If loading the history data fails, you could either report a catastrophic error and crash the app or, preferably, you could report an error and continue with no history.

When you release your app, your users won’t be able to see print statements, so you’ll have to provide them with more visible communication. When you want to give the user a choice of actions, you can use an ActionSheet but, for simple notifications, an Alert is perfect. An Alert pops up with a title and a message and pauses app execution until the user taps OK.

An alert
An alert

➤ Open HistoryStore.swift and add a new property to HistoryStore:

@Published var loadingError = false

When loadingError is true, you’ll show an alert in the view.

➤ In init(), replace print("Error", error) with:

loadingError = true

➤ Open HIITFitApp.swift, and in body, add a new modifier to ContentView:

.alert(isPresented: $historyStore.loadingError) {
  Alert(
    title: Text("History"),
    message: Text(
      """
      Unfortunately we can't load your past history.
      Email support:
        support@xyz.com
      """))
}

When loadingError is true, you show an Alert view with the supplied Text title and message. Surround the string with three """ to format your string on multiple lines.

➤ Build and run. Instead of seeing the failure message in the console, you see an alert.

History Alert
History Alert

➤ Tap OK. Alert resets historyStore.loadingError and your app continues with empty history data.

➤ Now that you’ve tested error checking, open HistoryStore.swift and remove throw FileError.loadFailure from load().

Note: You can find out more about error handling in our Swift Apprentice book, which has an entire chapter on the subject.

Saving History

Skills you’ll learn in this section: closures; map(_:); transforming arrays

You need to have some history data stored in order to load it. So, you’re now going to implement saving data and then come back and complete load().

➤ Add a new property to HistoryStore to describe the URL:

var dataURL: URL {
  URL.documentsDirectory
    .appendingPathComponent("history.plist")
}

You add the file name to the documents path. This gives you the full URL of the file to which you’ll write the history data.

You’ll save the history data to a property list (plist) file. As mentioned in the previous chapter, the root of a property list file can be a dictionary or an array. Dictionaries are useful when you have a number of discrete values that you can reference by key. But in the case of history, you have an array of ExerciseDay to store, so your root will be an array.

Property list files can only store a few standard types, and ExerciseDay, being a custom type, is not one of them. In Chapter 19, “Saving Files”, you’ll learn about Codable and how to save custom types to files but, for now, the easy way is to separate out each ExerciseDay element into an array of Any and append this to the array that you will save to disk.

➤ Add a new throwing method to HistoryStore:

func save() throws {
  var plistData: [[Any]] = []
  for exerciseDay in exerciseDays {
    plistData.append(([
      exerciseDay.id.uuidString,
      exerciseDay.date,
      exerciseDay.exercises
    ]))
  }
}

For each element in the loop, you construct an array with a String, a Date and a [String]. You can’t store multiple types in an Array, so you create an array of type [Any] and append this element to plistData.

plistData is a type [[Any]]. This is a two dimensional array, which is an array that contains an array. After saving two elements, plistData will look like this:

An array of type [[Any]]
An array of type [[Any]]

The for loop maps exerciseDays to plistData. In other words, the loop transforms one set of data to another set of data. As this happens so often in code, Swift provides map(_:), an optimized method on Array, for this transforming of data.

Swift Dive: Closures

map(_:) takes a closure as a parameter so, before continuing, you’ll learn how to use closures. You’ve already used them many times, as SwiftUI uses them extensively.

A closure is simply a block of code between two curly braces. Closures can look complicated, but if you recognize how to put a closure together, you’ll find that you use them often, just as SwiftUI does. Notice a closure’s similarity to a function: Functions are closures — blocks of code — with names.

A closure
A closure

The closure is the part between the two curly braces {...}. In the example above, you assign the closure to a variable addition.

The signature of addition is (Int, Int) -> Int and declares that you will pass in two integers and return one integer.

It’s important to recognize that when you assign a closure to a variable, the closure code doesn’t execute. The variable addition contains the code return a + b, not the actual result.

To perform the closure code, you execute it with its parameters:

Closure result
Closure result

You pass in 1 and 2 as the two integer parameters and receive back an integer:

Closure signature
Closure signature

Another example:

let aClosure: () -> String = { "Hello world" }

This closure takes in no parameters and returns a string.

Your current task is to convert each ExerciseDay element to an element of type [Any].

This is the closure that would perform this conversion for a single ExerciseDay element:

let result: (ExerciseDay) -> [Any] = { exerciseDay in
  [
    exerciseDay.id.uuidString,
    exerciseDay.date,
    exerciseDay.exercises
  ]
}

result is of type (ExerciseDay) -> [Any]. The closure takes in a parameter exerciseDay and combines the ExerciseDay properties into an array of type [Any].

Using map(_:) to Transform Data

Similar to a for loop, map(_:) goes through each element individually, transforms the data to a new element and then combines them all into a single array.

You could send result to map which returns an array of the results:

let plistData: [[Any]] = exerciseDays.map(result)

map(_:) takes the closure result, executes it for every element in exerciseDays and returns an array of the results.

Rather than separating out into a closure variable, it’s more common to declare the map operation together with the closure.

➤ Replace the contents of save() with:

let plistData = exerciseDays.map { exerciseDay in
  [
    exerciseDay.id.uuidString,
    exerciseDay.date,
    exerciseDay.exercises
  ]
}

The full declaration of Array.map(_:) is:

func map<T>(
  _ transform: (Self.Element) throws -> T) rethrows -> [T]
  • If map(_:) finds any errors, it will throw.
  • T is a generic type. You’ll discover more about generics in Section 2, but here T is equivalent to [Any].
  • transform‘s signature is (Self.element) -> T. You’ll recognize this as the signature of a closure to which you pass a single element of ExerciseDay and return an array of type [Any].

This is how your code matches map(_:):

Deconstructing map(_:)
Deconstructing map(_:)

This code gives exactly the same result as the previous for loop. Option-click plistData, and you’ll see that its type is [[Any]], just as before.

Type of plistData
Type of plistData

One advantage of using map(_:) rather than dynamically appending to an array in a for loop, is that you declare plistData as a constant with let. This is some extra safety, so that you know that you won’t accidentally change plistData further down the line.

An Alternative Construct

When you have a simple transformation, and you don’t need to spell out all the parameters in full, you can use $0, $1, $2, $... as replacements for multiple parameter names.

➤ Replace the previous code with:

let plistData = exerciseDays.map {
  [$0.id.uuidString, $0.date, $0.exercises]
}

Here you have one input parameter, which you can replace with $0. When using $0, you don’t specify the parameter name after the first curly brace {.

Again, this code gives exactly the same result. Option-click plistData, and you’ll see that its type is still [[Any]].

Type of plistData
Type of plistData

Property List Serialization

Skills you’ll learn in this section: property list serialization

Writing Data to a Property List File

You now have your history data in an array with only simple data types that a property list can recognize. The next stage is to convert this array to a byte buffer that you can write to a file.

➤ Add this code to the end of save():

do {
  // 1
  let data = try PropertyListSerialization.data(
    fromPropertyList: plistData,
    format: .binary,
    options: .zero)
  // 2
  try data.write(to: dataURL, options: .atomic)
} catch {
  // 3
  throw FileError.saveFailure
}

Going through the code:

  1. You convert your history data to a serialized property list format. The result is a Data type, which is a buffer of bytes.
  2. You write to disk using the URL you formatted earlier.
  3. The conversion and writing may throw errors, which you catch by throwing an error.

➤ Call save() from the end of addDoneExercise(_:):

do {
  try save()
} catch {
  fatalError(error.localizedDescription)
}

If there’s an error in saving, you crash the app, printing out the string description of your error. This isn’t a great way to ship your app, and you may want to change it later.

➤ Build and run and do an exercise. Tap Done and your history file will save.

➤ In Finder, go to your app’s Documents directory, and you’ll see history.plist. Double click the file to open this file in Xcode.

Saved history property list file
Saved history property list file

See how the property list file matches with your data:

  • Root: The property list array you saved in plistData. This is an array of type [[Any]].
  • Item 0: The first element in exerciseDays. This is an array of type [Any].
  • Item 0: The id converted to String format.
  • Item 1: The date of the exercise
  • Item 2: The array of exercises that you have performed and tapped Done to save. In this example, the user has exercised on one day with two exercises: Sun Salute and Burpee.

Reading Data From a Property List File

You’re successfully writing some history, so you can now load it back in each time the app starts.

➤ In HistoryStore.swift, replace load() with this code:

func load() throws {
  do {
    // 1
    let data = try Data(contentsOf: dataURL)
    // 2
    let plistData = try PropertyListSerialization.propertyList(
      from: data,
      options: [],
      format: nil)
    // 3
    let convertedPlistData = plistData as? [[Any]] ?? []
    // 4
    exerciseDays = convertedPlistData.map {
      ExerciseDay(
        date: $0[1] as? Date ?? Date(),
        exercises: $0[2] as? [String] ?? [])
    }
  } catch {
    throw FileError.loadFailure
  }
}

Loading is very similar to saving, but with some type checking to ensure that your data conforms to the types you are expecting.

Going through the code:

  1. Read the data file into a byte buffer. This buffer is in the property list format. If history.plist doesn’t exist on disk, Data(contentsOf:) will throw an error. Throwing an error is not correct in this case, as there will be no history when your user first launches your app. You’ll fix this error at the end of this chapter.
  2. Convert the property list format into a format that your app can read.
  3. When you serialize from a property list, the result is always of type Any. To cast to another type, you use the type cast operator as?. This will return nil if the type cast fails. Because you wrote history.plist yourself, you can be pretty sure about the contents, and you can cast plistData from type Any to the [[Any]] type that you serialized out to file. If for some reason history.plist isn’t of type [[Any]], you provide a fall-back of an empty array using the nil coalescing operator ??.
  4. With convertedPlistData cast to the expected type of [[Any]], you use map(_:) to convert each element of [Any] back to ExerciseDay. You also ensure that the data is of the expected type and provide fall-backs if necessary.

➤ Build and run, and tap History. The history you saved out to your property list file will load in the modal.

Saved history
Saved history

Ignoring the Loading Error

➤ Delete history.plist in Finder, and build and run your app.

Because the file doesn’t exist any more, your loading error appears because load() fails on try Data(contentsOf:).

Load error
Load error

Before throwing an error, you should first check whether history.plist exists. On first run of your app, the plist file will never exist, so you should ignore the loading error.

➤ In HistoryStore.swift, in load(), remove:

let data = try Data(contentsOf: dataURL)

➤ Before do, add this:

guard let data = try? Data(contentsOf: dataURL) else {
  return
}

try? returns nil if the operation fails. Using guard, you can jump out of a method if a condition is not met. guard let is similar to if let in that you assign an optional to a non-optional variable and check it isn’t nil. You always provide an else branch with guard, where you specify what to do when the guard conditional test fails. Generally you return from the method, but you could instead use fatalError(_:file:line:) to crash the app.

➤ Build and run the app. The loading error has gone and you can start recording your exercises.

Final result
Final result

Key Points

  • Optionals are properties that can contain nil. Optionals make your code more secure, as the compiler won’t allow you to assign nil to non-optional properties. You can use guard let to unwrap an optional or exit the current method if the optional contains nil.
  • Don’t force-unwrap optionals by marking them with an !. It is tempting to use an ! when assigning optionals to a new property because you think the property will never contain nil. Instead, try and keep your code safe by assigning a fall-back value with the nil coalescing operator ??. For example: let atLeastOne = oldValue ?? 1.
  • Use breakpoints to halt execution and step through code to confirm that it’s working correctly and that variables contain the values you expect.
  • Use throw to throw errors in methods marked by throws.
  • If you need to handle errors, call methods marked by throws with do { try ... } catch { ... }. catch will only be performed if the try fails. If you don’t need to handle errors, you can call the method with let result = try? method(). result will contain nil if there is an error.
  • Use @StateObject to hold your data store. Your app will only initialize a state object once.
  • Closures are chunks of code that you can pass around just as you would any other object. You can assign them to variables or provide them as parameters to methods. Array has a number of methods requiring closures to transform its elements into a new array.
  • PropertyListSerialization is just one way of saving data to disk. You could also use JSON, or Core Data, which manages objects and their persistence.
Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.