Chapters

Hide chapters

SwiftUI Apprentice

First Edition · iOS 14 · Swift 5.4 · Xcode 12.5

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

9. 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, a database such as SQLite or Realm, or Core Data. Since you’ve learned so much about property list files already, in this chapter, you’ll save the history data to one.

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 for 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.

➤ 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.

Filtering the debug console

You may find that your debug console is filled with Apple errors, and it can be quite difficult to see your own print statements there.

➤ Enter part of the print output that you expect to see, in this case History, into Filter at the bottom right of the console:

Filter debug console
Filter debug console

Your log will show up on its own:

Completed exercise log
Completed exercise log

To hide the left panel in the debug console, you can choose the icon at the bottom right. This allows you to read your debug messages more easily.

If you have a number of print statements that you wish to see, you can prefix them with particular characters, such as >>>.

print(">>>", today)
print(">>> Inserting \(exerciseName)")

You can then enter >>> into Filter and your logs will show up on their own. Remember to clear your filter when you’re through. It can be frustrating when you forget to add >>>, and you filter out your own debugging logs.

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 tap in the gutter to the left of let today = Date() in addDoneExercise(_:) and click. 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. Later in the chapter, you’ll set up HistoryStore so that you’re sure that the store will initialize only once.

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

➤ Open HIITFitApp.swift and paste the modifier to ContentView:

WindowGroup {
  ContentView()
    .environmentObject(HistoryStore())
  ...
}

➤ 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 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
  case urlFailure
}

This is a list of possible 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 will always raise 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.

try…catch

When calling a method that throws, you use try. If you don’t need to handle any errors specifically, you can call the method with try? as, for example, try? load(). This will convert an error result to nil and execution continues. To handle an error from a throwing method, you use the expression do { try ... } catch { }.

➤ Add this to the end of init():

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

You call the throwing method and, if there’s an error, the catch block executes.

➤ Build and run and, in the debug console, you’ll see your printed error: Error: loadFailure. (Remember to clear your debug console filter if you have one.)

Throwing initializers

You can also throw errors when initializing an object. If your loading of the history data fails, you could either report a catastrophic error and crash the app or, preferably, you could report an error but continue with no history and an empty exerciseDays.

➤ Change the signature of init() to:

init(withChecking: Bool) throws {

You’ll try to create a HistoryStore using this initializer, but fall back to the default initializer if necessary.

➤ Create a new default initializer:

init() {}

This is your fall-back initializer, which won’t call any loading code.

➤ In init(withChecking:), change print("Error:", error) to:

throw error

This will pass back the error to the object that initializes HistoryStore.

➤ Open HIITFitApp.swift. This is where you initialize HistoryStore and place it into the environment.

➤ Add a new property to HIITFitApp:

@StateObject private var historyStore: HistoryStore

So far you’ve used @State for mutable values. You should only use @State properties for temporary items, as they will disappear when the view is deleted. @StateObject will create an observable object which won’t disappear when the view does.

@State, being so transient, is incompatible with reference objects and, as HistoryStore is a class, @StateObject is the right choice here.

Note: In case you’re confused about all the property wrappers you’ve used so far, you will review them in Chapter 11, “Understanding Property Wrappers”.

Earlier in the chapter, you moved the initialization of HistoryStore from ContentView to HIITFitApp, but noted that the store must initialize only once. @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.

As you want to instantiate historyStore using a throwing method, you’ll use an initializer.

➤ In HIITFitApp, create a new initializer:

init() {
  let historyStore: HistoryStore
  do {
    historyStore = try HistoryStore(withChecking: true)
  } catch {
    print("Could not load history data")
    historyStore = HistoryStore()
  }
}

When ContentView first initializes, you try loading the history. If there is no error, then historyStore will contain the loaded history data. If the try fails, then you print out an error message and use HistoryStore’s default initializer. HistoryStore.init() can’t possibly fail, but will load with empty history data.

You still have to assign the local historyStore to the state object.

➤ Add this to the end of init():

_historyStore = StateObject(wrappedValue: historyStore)

As the name suggests, a property wrapper wraps an underlying value or object. You use the StateObject(wrappedValue:) initializer to set the wrapped value of the state object and use an underscore prefix to assign the initialized state object to historyStore.

➤ Change .environmentObject(HistoryStore()) to:

.environmentObject(historyStore)

Here you use the state object instead of creating HistoryStore, when setting up the environment object.

➤ Build and run and, because load() still throws an error, you’ll see your error in the debug console: Could not load history data.

Alerts

Skills you’ll learn in this section: Alert view

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 HIITFitApp.swift and add a new property to HIITFitApp:

@State private var showAlert = false

This toggle will control whether you show the alert.

➤ In the catch block in init(), update showAlert:

showAlert = true

➤ Add a new modifier to ContentView:

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

When showAlert 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. Because HistoryStore’s initializer fails, you set showAlert to true, which causes your Alert to show.

History Alert
History Alert

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

Now that your testing of error checking is complete, 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. You can find Swift Apprentice at: https://bit.ly/2MuhHu0.

Saving history

Skills you’ll learn in this section: FileManager

You’ll first save your history data to disk and then, come back to filling out load() using the saved data.

➤ Add a new method to HistoryStore to create the URL where you will save the data:

func getURL() -> URL? {
  // 1
  guard let documentsURL = FileManager.default.urls(
    for: .documentDirectory, in: .userDomainMask).first else {
    // 2
    return nil
  }
  // 3
  return documentsURL.appendingPathComponent("history.plist")
}

This method returns an optional URL. The calling method can then decide what to do if the result of this method is nil.

Going through the code:

  1. 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. Here you check that FileManager.default.urls(for:in:).first is not nil and, if it isn’t nil, assign it to documentsURL.
  2. You always provide an else branch with guard where you specify how to leave the method when the guard conditional test fails. Generally you return from the method, but you could also use fatalError(_:file:line:) to crash the app.
  3. 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.

➤ Add a new throwing method to HistoryStore:

func save() throws {
  guard let dataURL = getURL() else {
    throw FileError.urlFailure
  }
}

You set up your URL. If getURL() returns nil, you throw an error and save() stops execution.

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 this to save() after the previous code:

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.

Closures

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

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 previous code from var plistData: [[Any]] = [] to the end 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

Swift Dive: filter(_:) and reduce(_:_:)

There are other common methods that convert one array to another.

With filter(_:) you can filter one array to another array, as for example:

let oneToThree = [4, 2, 0, 9, 3, 1].filter {
  Range(1...3).contains($0)   // same as $0 >= 1 && $0 <= 3
}

The closure takes each element of the array and returns a value of true if the integer is between one and three. When the closure result is true, the element is added to the new array. After completing this code, oneToThree contains [2, 3, 1].

reduce(_:) combines all the elements in an array into one value. For example:

let result = [4, 2, 0].reduce(0) { runningTotal, value in
  runningTotal + value
}

You call reduce(_:_:) with a starting value. Although you can substitute $0 and $1 for the parameters here, the code reads better with explicitly named parameters. The first parameter is the running total, and you add the second parameter to the first, resulting in a single value. After this code result will contain 6.

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 an error, 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, add this code to load():

// 1
guard let dataURL = getURL() else {
  throw FileError.urlFailure
}

do {
  // 2
  let data = try Data(contentsOf: dataURL)
  // 3
  let plistData = try PropertyListSerialization.propertyList(
    from: data,
    options: [],
    format: nil)
  // 4
  let convertedPlistData = plistData as? [[Any]] ?? []
  // 5
  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. First set up the URL just as you did with saving the file.

  2. 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 as your challenge for this chapter.

  3. Convert the property list format into a format that your app can read.

  4. 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 ??.

  5. 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

Challenge

Challenge: Ignore the Error

➤ Delete history.plist in Finder, and build and run your app. Your loading error appears because load() fails.

Load error
Load error

You’re not first checking to see whether history.plist exists. If it doesn’t, Data(contentsOf:) throws an error.

Your challenge is to ignore the error, as it’s most likely that in this case the error is that the file doesn’t exist. Remember that you can use try? to discard an error. When you’ve completed your mission, your app should load data from history.plist if it exists and take no action if it doesn’t.

You can find the answer to this challenge in load() in the challenges directory for this chapter.

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. A common paradigm is to pass a closure as a completion handler to be executed when an operation completes. 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.
© 2024 Kodeco Inc.