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

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. There are a number of ways to save data that you could choose.

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 Core Data 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 text 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 save, load and remove image files. These are in UIImageExtensions.swift.

FileManagerExtensions.swift holds a static property that contains the Documents folder URL.

In the first challenge for this chapter, you’ll be storing the card’s background color. ColorExtensions.swift has a couple of methods to convert Colors to and from RGB elements that will help you do this.

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

The saved data format

When you save the data, each card will have a JSON file with a .rwcard 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:

Data store
Data store

When your app first starts, you’ll read in all the .rwcard 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 CardDetailView 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 the 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 struct. 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 an overhead. 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 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 CardDetailView.swift and add a new modifier to content inside body:

.onDisappear {
  card.save()
}

➤ Build and run, 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.

➤ Still in CardDetailView, 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 do the save when scenePhase becomes inactive.

➤ Add a new modifier to content before the .onDisappear you added earlier:

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

onChange(of:) is called whenever scenePhase changes. If the change is to inactive, then save the card.

➤ Build and run, tap a card to open it, and exit your app by swiping up from the bottom. You should 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 find out 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", "Manda"
  ], 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
    if let url = FileManager.documentURL?
      .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 CardsView():

.onAppear {
  print(FileManager.documentURL ?? "")
}

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:

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

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 very easy to store data.

Decoding

Reading the data back in is just as easy.

➤ In Team add a new method:

static func load() {
  // 1
  if let url = FileManager.documentURL?
    .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 very 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; 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 that 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.

Codable synthesized methods
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 group, create a new Swift file called AngleExtensions.swift.

➤ Replace the code with:

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 don’t need to save the UUID, as it will get reconstructed when you load up the element. You’ll save the transform, which is now Codable. Image and AnyShape, however, are not. At the point of loading the Image, you have access to the UIImage, and it’s quite easy to save that to a file and record the filename.

➤ 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()
  let image = Image(uiImage: uiImage)
  // 2
  let element = ImageElement(
    image: image, 
    imageFilename: imageFilename)
  elements.append(element)
}

The changes from the previous code are:

  1. You now save the UIImage to a file using the provided code 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 both the loaded Image and the string filename.

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 provided method 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 ImageElement:

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

You’ll save the transform, filename and frame to disk. It’s unnecessary to store the image element’s id, as that can be generated when you load the element. You’ll also recreate the Image from the stored image file when you load the element.

➤ 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)
  // 2
  imageFilename = try container.decodeIfPresent(
    String.self, 
    forKey: .imageFilename)
  // 3
  if let imageFilename = imageFilename,
    let uiImage = UIImage.load(uuidString: imageFilename) {
    image = Image(uiImage: uiImage)
  } else {
    // 4
    image = Image("error-image")
  }
}

Going through the decoding:

  1. Decode the transform. It’s Codable, so it takes care of itself.
  2. Decode the image filename. This is an optional and, if you try and decode something that does not exist, it will throw an error. Check if it 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(imageFilename, forKey: .imageFilename)
}

Here you’re encoding the transform and the filename. frame is not yet Codable, so you’ll add that in a moment.

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
  }
}

For Card, you’ll save the id. 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. You’ll store the background color in the first challenge at the end of the chapter. You’ll also store image elements and text elements in two separate arrays.

➤ First add the decoder:

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.

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. If you’re more comfortable with for loops, then you can use those instead.

Saving the card

With all the coding and encoding in place, you can finally fill out 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).rwcard"
    if let url = FileManager.documentURL?
      .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 .rwcard 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:)
  • update(_:frame:)

You’re already calling save() when the user presses the Done button and, also, when he exits the app and the scene phase changes.

➤ Build and run in the 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 the simulator, choose the green card and add a new photo. (Don’t use the pink flowers as currently that file format does not work.) When the card adds the new element, it saves the photo to a PNG file and itself to a file with the .rwcard extension. In Finder, open this in TextEdit — you should just be able to double click it to open it.

{"id":"6D924181-ABFC-457A-A771-984E7F3805BD","imageElements":
[{"imageFilename":null,"transform":{"offset":[4,-137],"size":
[412,296],"rotation":{"degrees":-6.0000000000000009}}},
{"imageFilename":"8808F791-E832-465D-911A-A250B91A5141",
"transform":{"offset":[0,0],"size":[250,180],"rotation":
{"degrees":0}}}]}

You’ll see something like the above. This is the JSON format as described earlier. You can see that you’re saving the card id, which matches the filename and, also, an array of two imageElements. The first element will have null in the filename as it was provided by the preview data and never saved to a file. The second element will have the added photo with the name of the saved file.

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; Equatable

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 .rwcard 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
    guard let path = FileManager.documentURL?.path,
      let enumerator =
        FileManager.default.enumerator(atPath: path),
          let files = enumerator.allObjects as? [String]
    else { return cards }
    // 3
    let cardFiles = files.filter { $0.contains(".rwcard") }
    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’ll 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 .rwcard 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.

Adding a new card

You’ll need a method to add a new card. When you add this new card to cards, it will only hold the background color.

➤ Add this new method to CardStore:

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

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

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

@StateObject var store = CardStore()

Adding a button to create a new card

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.

➤ Open CardsView.swift.

➤ In body, replace CardsListView() with:

VStack {
  Button(action: {
    viewState.selectedCard = store.addCard()
    viewState.showAllCards = false
  }, label: {
    Text("Add")
  })
  CardsListView()
}

You set up a temporary button to add a card. When you tap the 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.

Also, you set viewState.selectedCard to be the newly created card and viewState.showAllCards to false, so only the new card is displayed.

➤ 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. A new .rwcard file will appear 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 underneath the Add button.

When you re-run your app, any cards you create will show up just as you created them.

Adding a card
Adding a card

Your app is in great shape now. There are still a couple of problems that you may have noticed. You’re not yet storing the card’s background color between sessions, so it reverts to the card background’s default yellow. You’re also not persisting any clip frames. Neither Color nor AnyShape conforms to Codable, and they are a little harder to persist than the previous types.

Saving the frame

AnyShape does not conform to Codable, as it’s a custom type. To save the frame, you’ll encode the index of the shape in the shapes array. When you decode, you’ll use this index to restore the frame as an AnyShape.

➤ Open CardElement.swift and locate ImageElement’s Codable extension. Add this to the end of encode(to:):

if let index = 
  Shapes.shapes.firstIndex(where: { $0 == frame }) {
  try container.encode(index, forKey: .frame)
}

Here you’re finding the first shape which is equal to your element’s frame. You’ll get an error because AnyShape doesn’t conform to Equatable, which means that you can’t compare the shape to the frame.

The Equatable protocol

Consider what equality is this case. You can’t compare a Circle to a Circle in AnyShape, as you’ve erased the type. Inside each Shape, though, is a Path, and a Path type conforms to Equatable.

➤ Open AnyShape.swift and create a new extension:

extension AnyShape: Equatable {
}

➤ Compile and click the red dot next to the compile error. Click Fix to add protocol stubs.

static func == (lhs: AnyShape, rhs: AnyShape) -> Bool {
  code
}

This required method defines the == operator, with the left hand side and right hand side as parameters. The returned Boolean indicates whether the result is equal or not.

➤ Replace the code placeholder with:

let rect = CGRect(
  origin: .zero,
  size: CGSize(width: 100, height: 100))
let lhsPath = lhs.path(in: rect)
let rhsPath = rhs.path(in: rect)
return lhsPath == rhsPath

You create the path of the two shapes in a small rectangle. The size of the rectangle doesn’t matter as long as it’s not zero. You then compare the two paths to see if they are the same.

Your app will now compile, and you can compare two AnyShapes.

➤ Open CardElement.swift where you set up the encoding. You’ll now do the decoding.

➤ At the end of init(from:) add this:

if let index = 
  try container.decodeIfPresent(Int.self, forKey: .frame) {
  frame = Shapes.shapes[index]
}

Here you decode the index, if there is one, and set up the frame using the index.

➤ Build and run and test that your frames are being saved:

Saving the frames
Saving the frames

Challenges

Challenge 1: Save the background color

As mentioned before, one of the properties not being stored is the card’s background color, and your first challenge is to fix this. Instead of making Color Codable, you’ll store the color data in CGFloats. In ColorExtensions.swift, there are two methods to help you:

  • colorComponents() separates out a Color into red, green, blue and alpha components. These are returned in an array of four CGFloats. CGFloat conforms to Codable, so you’ll be able to store the color.
  • color(components:) is a static method which initializes a Color from four CGFloats. This is commonly called a factory method, as you’re creating a new instance.

In Card.swift, encode and decode the background color using these two methods.

Before testing your solution, remove all files from the app’s Documents folder. When you change the format of the file, it becomes unreadable. When adding properties to files in an app that you’ve already released, you would have to take this into account, as you wouldn’t want to lose your users’ data. Generally you’d store a version number in your files and have a startup method that does an upgrade of files if the data is an older version.

Card background colors saved
Card background colors saved

Challenge 2: Save text data

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

  1. Create a new SwiftUI file for your text entry modal. You will need to hold a TextElement binding property sent from CardDetailView to hold the text data temporarily, just as you’ve done for your other picker modals with frame and stickerImage. This time, though, in CardDetailView, 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 file, add an environment presentationMode property as you did for your other modals and replace body contents with:

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

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 CardDetailView.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. You’ll add a new method to Card to create the TextElement, just as you did with ImageElement earlier.
  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 that 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 it needs the memory.
  • JSON format is a standard for transmitting text 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 want to.
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.