Chapters

Hide chapters

SwiftUI Apprentice

Third Edition · iOS 18 · Swift 5.9 · Xcode 16.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

19. Saving Files
Written by Caroline Begbie

You’ve set up most of your user interface, and it would be nice at this stage to have the card data persist between app sessions. You can choose between a number of different ways to save data.

You’ve already looked at UserDefaults and property list (plist) files in Section 1. These are more suitable for simple data structures, whereas, when you save your card, you’ll be saving images and sub-arrays of elements. While SwiftData could handle this, another way is to save the data to files using the JSON format. One advantage of JSON is that you can easily examine the file in a text editor and check that you’re saving everything correctly.

This chapter will cover saving JSON files to your app’s Documents folder by encoding and decoding the JSON representation of your cards.

The Starter Project

To assist you with saving UIImages to disk, the starter project contains methods in a UIImage extension to resize an image and to save, load and remove image files. These are in UIImageExtensions.swift.

If you’re continuing on from the previous chapter with your own code, make sure you copy this file into your project.

Instead of using previews, in this chapter you’ll build and run your app in Simulator so that you can inspect the Documents folder.

The Saved Data Format

When you save the data, each card will have a JSON file with a .card extension. This file will contain the list of elements that make up the card. You’ll save the images separately. The data store on disk will look like:

[image data] [text data] } Card-id.card card data { Image-id Image-id
Data store

When your app first starts, you’ll read in all the .card files in the Documents folder and show them in a scroll view. When the user taps a selected card, you’ll process the card’s elements and load the relevant image files.

When to Save the Data

Skills you’ll learn in this section: when to save data; ScenePhase

There are two ways you can proceed, and each has its pros and cons.

You can choose to save the card file every time you change anything, such as adding, moving or deleting elements. This means that your data on disk is always up-to-date. The downside is that your saving is spread out all over your app.

Alternatively, you could choose to save when you really need to:

  1. When SingleCardView disappears, which happens when the user taps Done.
  2. When the app becomes inactive through the user switching apps or an external event such as a phone call.

The downside of this method is that if your app crashes before you’ve done the save, then the last few changes the user made might not be recorded. You’ll also need to remember when testing that the app doesn’t save in Simulator until you press Done.

In this app, you’ll choose a hybrid approach. You’ll perform the first method of saving whenever you create or delete card data. This is primarily because of saving the image element’s UIImage. You’ll save the UIImage when you choose it from the Photos or Stickers modal, and you’ll store the file id in the ImageElement. To maintain data integrity, it’s a good idea to store the ImageElement at the same time as the UIImage.

However, moving and resizing elements happens regularly, and saving every time can be quite inefficient. To save the transform data, you’ll choose the second method: saving when the user taps Done or leaves the app.

Saving When the User Taps Done

➤ Open the starter project. In the Model folder, open Card.swift and create a new method in Card:

func save() {
  print("Saving data")
}

You’ll come back to this method to perform the saving later in this chapter.

➤ Open SingleCardView.swift and add a new modifier to CardDetailView(card:) inside body:

.onDisappear {
  card.save()
}

➤ Build and run the app, tap a card, then tap Done. You’ll see “Saving data” appear in the console.

Saving data
Saving data

Using ScenePhase to Check Operational State

When you exit the app, surprisingly, the view does not perform onDisappear(_:), so the card won’t get saved. However, you can check what state your app is in through the environment.

➤ Open SingleCardView.swift and add a new environment property:

@Environment(\.scenePhase) private var scenePhase

scenePhase is a useful member of EnvironmentValues. It’s an enumeration of three possible values:

  • active: the scene is in the foreground.
  • inactive: the scene should pause.
  • background: the scene is not visible in the UI.

You’ll save when scenePhase becomes inactive.

➤ Add a new modifier to CardDetailView(card:):

.onChange(of: scenePhase) { _, newScenePhase in
  if newScenePhase == .inactive {
    card.save()
  }
}

.onChange(of:initial:_:) is called whenever scenePhase changes. If the new value is inactive then save the card.

➤ Build and run the app, tap a card to open it and exit your app by swiping up from the bottom. You’ll see the console message “Saving data”.

Saving data
Saving data

➤ Return to the app in the simulator. It will resume inside the card where you left it. There is no way to simulate a phone call on the simulator, but you can activate Siri to test external events. Choose Device ➤ Siri and, once again, you’ll see the console message “Saving data”.

You’ve now implemented the skeleton for the saving part of your app. The rest of the chapter will take you through encoding and decoding data so you can perform save().

JSON Files

Skills you’ll learn in this section: the JSON format

JSON is an acronym for JavaScript Object Notation. JSON data is formatted like this:

{
  "identifier1": [data1, data2, data3],
  "identifier2": data4
}

Each data item can be a nested chunk of JSON.

To explore how easy it is save simple data to JSON files, you’ll create a temporary structure and save it.

Codable

Skills you’ll learn in this section: Encodable; Decodable

The Codable protocol is a type alias for Decodable & Encodable. When you conform your structures to Codable, you conform to both these protocols. As its name suggests, you use Codable to encode and decode data to and from external files.

➤ Open CardsApp.swift and add this code to the end of the file:

struct Team: Codable {
  let names: [String]
  let count: Int
}

let teamData = Team(
  names: [
  "Richard", "Libranner", "Caroline", "Audrey", "Ray"
  ], count: 5)

After you’ve seen how Codable works, you’ll delete this code and apply your knowledge to the more complex data in your app.

This structure contains straightforward data of types that JSON supports — an array of Strings and an Int. Team conforms to Codable and makes Team a type that can encode and decode itself.

Encoding

➤ In Team, create a new method:

static func save() {
  do {
  // 1
    let encoder = JSONEncoder()
    encoder.outputFormatting = .prettyPrinted
    // 2
    let data = try encoder.encode(teamData)
    // 3
    let url = URL.documentsDirectory
      .appendingPathComponent("TeamData")
    try data.write(to: url)
} catch {
    print(error.localizedDescription)
  }
}

Going through this code:

  1. Initialize the JSON encoder. prettyPrinted means that the encoded data will be easier for you to read.
  2. Encode the data from teamData to a byte buffer of type Data.
  3. Write the data to a file called TeamData in the Documents folder.

➤ In CardsApp, create a temporary initializer:

init() {
  Team.save()
}

This will save the team data at the very start of the app so that you can examine it.

➤ In body, add a new modifier to CardsListView():

.onAppear {
  print(URL.documentsDirectory)
}

You print out the URL of the Documents folder so you can find the file you’ve saved.

➤ Build and run the app. Highlight the Documents URL that shows up in the debug console, then right-click it and choose Services ➤ Show in Finder. Drag the parent folder to your Favorites sidebar as you’ll be visiting this folder often while you’re testing.

Team data
Team data

➤ In Finder, right-click TeamData and open the file in TextEdit:

{
  "count" : 5,
  "names" : [
    "Richard",
    "Libranner",
    "Caroline",
    "Audrey",
    "Ray"
  ]
}

This is your structure data stored in JSON format. The identifiers are the names you used in the structure. As you can see, using Codable, it’s easy to store data.

Decoding

Reading the data back in is just as easy.

➤ In Team add a new method:

static func load() {
  // 1
  let url = URL.documentsDirectory
    .appendingPathComponent("TeamData")
  do {
  // 2
    let data = try Data(contentsOf: url)
    // 3
    let decoder = JSONDecoder()
    // 4
    let team = try decoder.decode(Team.self, from: data)
    print(team)
  } catch {
    print(error.localizedDescription)
  }
}

Going through this code:

  1. Get the URL.
  2. Read the data from the URL into a Data type.
  3. This time you’re decoding, so you initialize a JSON decoder.
  4. Decode the data into an instance of Team and print it to the console so that you can see what you’ve decoded.

➤ In CardsApp, change init() to:

init() {
  Team.load()
}

➤ Build and run, and see the new instance of Team loaded from TeamData printed out in the console before the Documents URL.

Loaded team data
Loaded team data

You can see that the theory of saving and loading data using Codable is simple. But naturally, with real life data, there are always complications.

Encoding and Decoding Custom Types

Skills you’ll learn in this section: encoding; decoding; resolving Color values; compactMap(_:)

Data types that you want to store must conform to Codable. If you check the developer documentation for the properties contained by Team, which are String and Int, you’ll see they both conform to Decodable and Encodable.

Custom types which store only Codable types present no problem. But how about one of your custom types that contain types that do not conform to Codable?

Before continuing, remove the sample Team code you created.

➤ In CardsApp.swift, remove init(), all of Team and teamData.

➤ Open Transform.swift and add this new extension:

extension Transform: Codable {}

You get a compile error: “Type Transform does not conform to protocol Decodable”.

Transform contains two data types: CGSize and Angle. When you check the documentation, you’ll find that CGSize conforms to Encodable and Decodable, whereas Angle does not.

When you conform your custom type to Codable, there are two required methods: init(from:) and encode(to:).

When all the types in your custom type conform to Codable, then all you have to do is add Codable conformance to your custom type, and Codable will automatically synthesize (create) the initializer and encoder methods.

Custom Type Stored Data encode (to:) init (from:) data data data Custom Type data data data data data data
Codable synthesized methods

When the structure contains types that don’t conform to Codable, you must implement the two synthesized methods yourself.

➤ In the Extensions folder, create a new empty file called AngleExtensions.swift.

➤ Add this code:

import SwiftUI

extension Angle: Codable {
  public init(from decoder: Decoder) throws {
    self.init()
  }

  public func encode(to encoder: Encoder) throws {
  }
}

You conform Angle to Codable and provide the two required methods. Because all the types used by Transform are now Codable, your code will now compile. However, the encoder and decoder methods you just created aren’t doing anything useful. You’ll have to tell the coders how to encode and decode every property that you want saved and loaded.

To do this, you create an enumeration that conforms to CodingKey, listing all the properties you want saved.

➤ Add this to the Angle extension:

enum CodingKeys: CodingKey {
  case degrees
}

You list only the properties that you want to save and restore. radians is another Angle property, but Angle can construct that internally from degrees, so you don’t need to store it.

➤ Add this to encode(to:):

var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(degrees, forKey: .degrees)

You create an encoder container using CodingKeys. Then you encode degrees, which is of type Double. This is a Codable type, so the container can encode it.

Decoding is similar.

➤ Replace the contents of init(from:) with:

let container = try decoder.container(keyedBy: CodingKeys.self)
let degrees = try container
  .decode(Double.self, forKey: .degrees)
self.init(degrees: degrees)

You create a decoder container to decode the data. As degrees is a Double, you decode a Double type. Then, you can initialize the Angle from the decoded degrees.

With Angle taken care of, and CGSize already conforming to Codable, Transform will now be able to synthesize the encoding and decoding methods and encode and decode itself, so your app will now compile.

You’re eventually going to save a Card, so all types in the data hierarchy will need to to be Codable. Going up from Transform in your data structure hierarchy, the next structure that you’ll tackle is ImageElement.

Encoding ImageElement

➤ Open CardElement.swift and take a look at ImageElement.

When saving an image element, you’ll save transform, uiImage and frameIndex. You don’t need to save the UUID, as it will get reconstructed when you initialize the element. transform and frameIndex conform to Codable, however UIImage does not.

You could save out the UIImage data into the card data, but it’s good practice to record binary files separately and save the binary file name with the card data.

➤ Add a new property to ImageElement:

var imageFilename: String?

This will hold the name of the saved image file, which will be a UUID string.

➤ Open Card.swift and replace addElement(uiImage:) with:

mutating func addElement(uiImage: UIImage) {
// 1
  let imageFilename = uiImage.save()
  // 2
  let element = ImageElement(
    uiImage: uiImage,
    imageFilename: imageFilename)
  elements.append(element)
}

The changes from the previous code are:

  1. You now save the UIImage to a file using the code provided in UIImageExtensions.swift. uiImage.save() saves the PNG data to disk and returns a UUID string as the filename. Before saving, save() resizes large images, as you don’t need to store the full resolution for the card.
  2. You create the new element with the string filename and the original uiImage.

You’ll also need to remove the image file from disk when the user deletes the element.

➤ In remove(_:), add this to the top of the method:

if let element = element as? ImageElement {
  UIImage.remove(name: element.imageFilename)
}

You check that the element is an ImageElement and use the method provided in UIImageExtensions.swift to remove the file from disk.

➤ Back in CardElement.swift, add a new extension after ImageElement:

extension ImageElement: Codable {
}

When adding a second initializer to the main definition of a structure, you lose the default initializer and have to recreate it yourself. However, adding initializers to extensions doesn’t have this effect. When you conform ImageElement to Codable, you provide the decoding initializer init(from:). By adding the initializer to this extension, you keep both the default initializer and the new decoding one.

➤ Add the CodingKey enumeration containing the properties to save to the ImageElement extension:

enum CodingKeys: CodingKey {
  case transform, imageFilename, frameIndex
}

You’ll save the transform, filename and frame index to disk.

➤ Add the decoder:

init(from decoder: Decoder) throws {
  let container = try decoder
    .container(keyedBy: CodingKeys.self)
  // 1
  transform = try container
    .decode(Transform.self, forKey: .transform)
  frameIndex = try container
    .decodeIfPresent(Int.self, forKey: .frameIndex)
  // 2
  imageFilename = try container.decodeIfPresent(
    String.self,
    forKey: .imageFilename)
  // 3
  if let imageFilename {
    uiImage = UIImage.load(uuidString: imageFilename)
  } else {
    // 4
    uiImage = UIImage.error
  }
}

Going through the decoding:

  1. Decode the transform and frame index. They are Codable, so they take care of themselves.
  2. When decoding optionals, such as frameIndex and imageFilename, if you decode something that doesn’t exist, the decoder will throw an error. Check whether the data exists using decodeIfPresent(_:forKey:).
  3. If the filename is present, load the image using the filename.
  4. If there’s an error loading the image, use the error image in Assets.xcassets.

➤ Add the encoder to the ImageElement Codable extension:

func encode(to encoder: Encoder) throws {
  var container = encoder.container(keyedBy: CodingKeys.self)
  try container.encode(transform, forKey: .transform)
  try container.encode(frameIndex, forKey: .frameIndex)
  try container.encode(imageFilename, forKey: .imageFilename)
}

Here you’re encoding the transform, the frame index and the image filename.

Decoding and Encoding the Card

➤ Open Card.swift and add a new extension with the list of properties to save:

extension Card: Codable {
  enum CodingKeys: CodingKey {
    case id, backgroundColor, imageElements, textElements
  }
}

Ultimately these are the properties you’ll save for Card.

  • id is easy as you’ll save a string value for the UUID. This will be the name of the JSON file that you’ll store all the data in, so it’s important to keep track of the id to ensure data integrity.
  • backgroundColor is more complicated because SwiftUI colors need to be resolved into Floats before storing.
  • You’ll store imageElements and textElements in two separate arrays.

➤ First add the decoder in the extension:

init(from decoder: Decoder) throws {
  let container = try decoder
    .container(keyedBy: CodingKeys.self)
  // 1
  let id = try container.decode(String.self, forKey: .id)
  self.id = UUID(uuidString: id) ?? UUID()
  // 2
  elements += try container
    .decode([ImageElement].self, forKey: .imageElements)
}

Going through the decoder:

  1. Decode the saved id string and restore id from the UUID string.
  2. Load the array of image elements. You use the += operator to add to any elements that may already be there, just in case you load the text elements first. You’ll load the text elements in the challenge at the end of the chapter.

As you’re restoring id, you’ll need to make it a var.

➤ In Card, change let id = UUID() to:

var id = UUID()

➤ Add the encoder to Card’s Codable extension:

func encode(to encoder: Encoder) throws {
  var container = encoder.container(keyedBy: CodingKeys.self)
  try container.encode(id.uuidString, forKey: .id)
  let imageElements: [ImageElement] =
    elements.compactMap { $0 as? ImageElement }
  try container.encode(imageElements, forKey: .imageElements)
}

Here you encode the id as a UUID string. You also extract all the image elements from elements using compactMap(_:)

Swift Dive: compactMap(_:)

compactMap(_:) returns an array with all the non-nil elements that match the closure. $0 represents each element.

When code is more complex than the above, you can replace the closure with:

let imageElements: [ImageElement] =
  elements.compactMap { element in
  element as? ImageElement
}

This replaces the non-descriptive $0 with element.

The code is equivalent to:

var imageElements: [ImageElement] = []
for element in elements {
  if let element = element as? ImageElement {
    imageElements.append(element)
  }
}

The biggest advantage of using compactMap(_:) is that imageElements is a constant. This is safer because you can’t accidentally add data to it at a later time. It’s also less code and more readable once you’re accustomed to using array methods such as map(_:) and filter(_:). When necessary, you can compose them them together to create arrays from complex operations.

SwiftUI Colors

Currently the card’s background color is a SwiftUI Color. This isn’t optimal, as a Color is an abstract value that is resolved only when it is displayed in a view, and depends on whether the device is using Dark or Light Mode.

Some system colors
Some system colors

You can see all the system colors in Apple’s Human Interface Design Guide: https://developer.apple.com/design/human-interface-guidelines/color#Specifications

You could choose to store RGB color values that don’t adapt, but for this project you’ll resolve the SwiftUI colors to red, green, blue and alpha Float values and store those. Color contains a structure Color.Resolved, which translates RGBA values to a SwiftUI Color.

➤ In encode(to:), add this code:

let environment = EnvironmentValues()
let resolvedColor = backgroundColor.resolve(
  in: environment)
try container.encode(
  resolvedColor,
  forKey: .backgroundColor)

As mentioned earlier, SwiftUI resolves Color depending on the environment. Here you create a default environment and store the Color.Resolved structure. This structure contains only Floats, so it already conforms to Codable.

➤ In init(from:), add this code:

let resolvedColor = try container.decode(
  Color.Resolved.self,
  forKey: .backgroundColor)
backgroundColor = Color(resolvedColor)

You retrieve the resolved color from the JSON file and store it as a SwiftUI Color.

Saving the Card

With most of the encoding and decoding in place, you can finally implement save().

➤ Still in Card.swift, replace save() with:

func save() {
  do {
  // 1
    let encoder = JSONEncoder()
    // 2
    let data = try encoder.encode(self)
    // 3
    let filename = "\(id).card"
    let url = URL.documentsDirectory
      .appendingPathComponent(filename)
    // 4
    try data.write(to: url)
  } catch {
    print(error.localizedDescription)
  }
}

To save the data, you:

  1. Set up the JSON encoder.
  2. Set up a Data property. This is a buffer that will hold any kind of byte data and is what you will write to disk. Fill the data buffer with the encoded Card.
  3. The filename will be the card id plus a .card extension.
  4. Write the data to the file.

Perform this method whenever there are changes to the card.

➤ Add:

save()

to the end of:

  • remove(_:)
  • addElement(uiImage:)

These are the methods that update the image file, so you do an additional save to protect data integrity. You already call save() when the user presses the Done button and also when the app scene phase changes.

➤ Build and run in Simulator.

➤ Open the Documents folder in Finder. The folder path prints out in the console, but you should have the folder in your Favorites sidebar.

➤ In Simulator, choose the yellow card and add a new photo. When the card adds the new element, it saves the photo to a PNG file and itself to a file with the .card extension. In Finder, open the .card file in TextEdit — you should just be able to double click it to open it.

{"backgroundColor":[1,0.8,0,1],"id":"A70CC367-C7C1-416B-B205-8830617D27C6","imageElements":[{"transform":{"size":[250,180],"rotation":{"degrees":0},"offset":[27,-140]},"imageFilename":null,"frameIndex":null},{"imageFilename":null,"transform":{"rotation":{"degrees":0},"size":[380,270],"offset":[-80,25]},"frameIndex":null},{"imageFilename":null,"frameIndex":null,"transform":{"offset":[80,205],"rotation":{"degrees":0},"size":[250,180]}},{"frameIndex":null,"imageFilename":"ECD17614-BBC2-4ACE-B2D6-F71057EF1F16.png","transform":{"offset":[0,0],"rotation":{"degrees":0},"size":[250,180]}}]}

You’ll see something like the above. This is the JSON format as described earlier. You can see that you’re saving the card’s background color and id, which matches the filename. You also save an array of four imageElements. The first three elements will have null in the filename as they were provided by the preview data and never saved to a file. The last element will contain the name of the saved image file in imageFilename.

If you want to make the output more human readable, in save(), after initializing encoder, you can add:

encoder.outputFormatting = .prettyPrinted

Loading Cards

Skills you’ll learn in this section: file enumeration

Now that you’ve saved a card, you’ll start the app by loading them.

File Enumeration

To list the cards, you’ll iterate through all the files with an extension of .card and load them into the cards array.

➤ Open CardStore.swift and create a new extension with the method to load the files:

extension CardStore {
  // 1
  func load() -> [Card] {
    var cards: [Card] = []
    // 2
    let path = URL.documentsDirectory.path
    guard
      let enumerator = FileManager.default
        .enumerator(atPath: path),
      let files = enumerator.allObjects as? [String]
    else { return cards }
    // 3
    let cardFiles = files.filter { $0.contains(".card") }
    for cardFile in cardFiles {
      do {
        // 4
        let path = path + "/" + cardFile
        let data =
          try Data(contentsOf: URL(fileURLWithPath: path))
        // 5
        let decoder = JSONDecoder()
        let card = try decoder.decode(Card.self, from: data)
        cards.append(card)
      } catch {
        print("Error: ", error.localizedDescription)
      }
    }
    return cards
  }
}

Going through the code:

  1. You return an array of Cards from load(). These will be all the cards in the Documents folder.
  2. Set up the path for the Documents folder and enumerate all the files and folders inside this folder.
  3. Filter the files so that you only hold files with the .card extension. These are the Card files.
  4. Read each file into a Data variable.
  5. Decode each Card from the Data variable. You’ve done all the hard work of making all the properties used by Card and its subtypes Codable, so you can then simply add the decoded Card to the array you’re building.

➤ Replace the implementation of init(defaultData:) with:

cards = defaultData ? initialCards : load()

Instead of using the default data, you can choose to load the cards from disk.

➤ In CardsApp.swift, initialize store without the default data:

@StateObject var store = CardStore()

➤ Build and run the app and check out your saved data.

If there are no file data errors, you’ll see this result:

Loading your data
Loading your data

Remember that you created the card using default asset images, which hadn’t been saved to disk. You’ll see error images replacing those, but you’ll see the image that you added to the card inside the app, which was duly saved.

Creating new Cards

Without the default data, you’ll need some way of adding cards. You’ll create an Add button that you’ll enhance in the following chapter.

First, you’ll need a method to add a new card.

➤ Open CardStore.swift and add this new method to CardStore:

func addCard() -> Card {
  let card = Card(backgroundColor: Color.random())
  cards.append(card)
  card.save()
  return card
}

You create a new card with a random background color, add it to the array of cards and save it to disk.

➤ Open CardsListView.swift and, in body, embed list in a VStack.

➤ At the end of the VStack, so that it shows up under list, add the new button:

Button("Add") {
  selectedCard = store.addCard()
}

When you tap the Add button, you call your new addCard() method in store. This adds a new Card to the store’s cards array and saves the card file to disk.

By changing selectedCard, you trigger fullScreenCover(item:), which displays the new card.

➤ Open your app’s Documents folder in Finder and remove all the files from the folder. This will reset your app’s data.

➤ Build and run your app.

No app data
No app data

➤ Tap Add to add a new card. The background color is random.

A new .card file appears in your app’s Documents folder. Add a couple of photos and stickers to the card. These will get saved right away. Move them around and tap Done to save the transforms. Your new card will show in the list of cards. When you re-run your app, any cards will appear just as you created them.

Adding elements to the card
Adding elements to the card

Your app is in great shape now. You’ve implement all the CRUD operations and you’re saving the data between sessions.

Challenge

This is a super-challenging challenge that will test your knowledge of the previous chapters too. You’re going to save text elements into the .card file. Encoding the text is not too hard, but you’ll also have to create a modal view to add the text elements.

Because you’re changing what’s being encoded and decoded, you’ll need to delete the contents of your app’s Documents folder before testing.

  1. Create a new SwiftUI View file for your text entry modal. You will need to hold a TextElement binding property sent from CardToolbar to hold the text data temporarily, just as you’ve done for your other picker modals with frameIndex and stickerImage. This time, though, in CardToolbar, instantiate the state property and don’t make textElement an optional. You can check whether text is empty with if textElement.text.isEmpty.

  2. In your new modal view file, add an environment dismiss property as you did for your other modals and replace body contents with:

let onCommit = {
  dismiss()
}
TextField(
  "Enter text", text: $textElement.text, onCommit: onCommit)
.padding(20)

The text field will show a placeholder and update the text String with the user’s input. When the user presses Return, the modal will close.

  1. In CardToolbar.swift, change sheet(item:) to add the text picker modal just as you did the other modals. In onDisappear(_:), if the text is not empty, add the new text element to the card.

  2. Make TextElement Codable so that you save and restore the text with the card.

  3. In Card’s Codable extension, make sure that you encode and decode the text elements with the image elements.

Text entry and added text
Text entry and added text

This looks like a substantial challenge, but each step is one that you have done before, so you shouldn’t have any trouble. Learning how to add features to an existing app is an important skill. If you do have any difficulties, then take a look at the project in this chapter’s challenge folder.

When you finish this challenge, give yourself a big pat on the back, as you’ve now created an app that has a complex UI and persists data each time you run the app. This is the meat and vegetables of app development. The following chapters cover making your app look gorgeous and round off the meal with an exotic dessert.

Key Points

  • Saving data is the most important feature of an app. Almost all apps save some kind of data, and you should ensure that you save it reliably and consistently. Make it as flexible as you can, so you can add more features to your app later.
  • ScenePhase is useful to determine what state your app is in. Don’t try doing extensive operations when your app is inactive or in the background as the operating system can kill your app at any time if it needs the memory.
  • JSON format is a standard for transmitting data over the internet. It’s easy to read and, when you provide encoders and decoders, you can store almost anything in a JSON file.
  • Codable encompasses both decoding and encoding. You can extend this task and format your data any way you like.
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.