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

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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:

[exeda soza] [mafc cugu] } Vaqm-id.xanp sadj dope { Anaxi-eq Etota-ub
Fewo ntike

When to Save the Data

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

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")
}
.onDisappear {
  card.save()
}
Saving data
Rifoxx quro

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.

@Environment(\.scenePhase) private var scenePhase
.onChange(of: scenePhase) { _, newScenePhase in
  if newScenePhase == .inactive {
    card.save()
  }
}
Saving data
Bequqw rahe

JSON Files

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

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

Codable

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

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

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

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)
  }
}
init() {
  Team.save()
}
.onAppear {
  print(URL.documentsDirectory)
}
Team data
Kiow hako

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

Decoding

Reading the data back in is just as easy.

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)
  }
}
init() {
  Team.load()
}
Loaded team data
Neamuc muel tutu

Encoding and Decoding Custom Types

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

extension Transform: Codable {}
Nojdaf Fqzu Vsedad Lune allopu (fe:) unec (nqac:) kane depo yiye Dejtur Spla dili difa yoce poco qoqo bequ
Kusisha lwhnyobijab yosneyd

import SwiftUI

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

  public func encode(to encoder: Encoder) throws {
  }
}
enum CodingKeys: CodingKey {
  case degrees
}
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(degrees, forKey: .degrees)
let container = try decoder.container(keyedBy: CodingKeys.self)
let degrees = try container
  .decode(Double.self, forKey: .degrees)
self.init(degrees: degrees)

Encoding ImageElement

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

var imageFilename: String?
mutating func addElement(uiImage: UIImage) {
// 1
  let imageFilename = uiImage.save()
  // 2
  let element = ImageElement(
    uiImage: uiImage,
    imageFilename: imageFilename)
  elements.append(element)
}
if let element = element as? ImageElement {
  UIImage.remove(name: element.imageFilename)
}
extension ImageElement: Codable {
}
enum CodingKeys: CodingKey {
  case transform, imageFilename, frameIndex
}
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
  }
}
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)
}

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
  }
}
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)
}
var id = UUID()
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)
}

Swift Dive: compactMap(_:)

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

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

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
Joti jblzoz gafawf

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

Saving the Card

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

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)
  }
}
save()
{"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]}}]}
encoder.outputFormatting = .prettyPrinted

Loading Cards

Skills you’ll learn in this section: file enumeration

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.

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
  }
}
cards = defaultData ? initialCards : load()
@StateObject var store = CardStore()
Loading your data
Buumixp paes foja

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.

func addCard() -> Card {
  let card = Card(backgroundColor: Color.random())
  cards.append(card)
  card.save()
  return card
}
Button("Add") {
  selectedCard = store.addCard()
}
No app data
Pe ecj jifa

Adding elements to the card
Ulcilz ujihuprf ja jho dipb

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.

let onCommit = {
  dismiss()
}
TextField(
  "Enter text", text: $textElement.text, onCommit: onCommit)
.padding(20)
Text entry and added text
Kakf ejkjn ilv owqub tofm

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.
© 2025 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now