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

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. 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
Nata nyala

When to save the data

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

Saving when the user taps Done

➤ Open Card.swift and create a new method in Card:

func save() {
  print("Saving data")
}
.onDisappear {
  card.save()
}
Saving data
Helawm zovu

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
Nawetb hudi

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", "Manda"
  ], 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
    if let url = FileManager.documentURL?
      .appendingPathComponent("TeamData") {
      try data.write(to: url)
    }
  } catch {
    print(error.localizedDescription)
  }
}
init() {
  Team.save()
}
.onAppear {
  print(FileManager.documentURL ?? "")
}
Team data
Dian rosa

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

Decoding

Reading the data back in is just as easy.

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)
    }
  }
}
init() {
  Team.load()
}
Loaded team data
Keuyek juok wuqo

Encoding and decoding custom types

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

extension Transform: Codable {}
Codable synthesized methods
Yucaqdi spjndixevoy kuwgijq

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()
  let image = Image(uiImage: uiImage)
  // 2
  let element = ImageElement(
    image: image, 
    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, frame
}
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")
  }
}
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)
}

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

Saving the card

With all the coding and encoding in place, you can finally fill out save().

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

Loading Cards

Skills you’ll learn in this section: file enumeration; Equatable

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.

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
  }
}
cards = defaultData ? initialCards : load()

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.

func addCard() -> Card {
  let card = Card(backgroundColor: Color.random())
  cards.append(card)
  card.save()
  return card
}
@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.

VStack {
  Button(action: {
    viewState.selectedCard = store.addCard()
    viewState.showAllCards = false
  }, label: {
    Text("Add")
  })
  CardsListView()
}
No app data
Ga elg masi

Adding a card
Ixvuzt u tubf

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.

if let index = 
  Shapes.shapes.firstIndex(where: { $0 == frame }) {
  try container.encode(index, forKey: .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.

extension AnyShape: Equatable {
}
static func == (lhs: AnyShape, rhs: AnyShape) -> Bool {
  code
}
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
if let index = 
  try container.decodeIfPresent(Int.self, forKey: .frame) {
  frame = Shapes.shapes[index]
}
Saving the frames
Jahigk vla krufub

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:

Card background colors saved
Joyn wajzmqiizf ropayf toxiy

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.

let onCommit = {
  presentationMode.wrappedValue.dismiss()
}
TextField(
  "Enter text", text: $textElement.text, onCommit: onCommit)
Text entry and added text
Lukk amcfb uqs icgot xomy

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.
© 2024 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