Chapters

Hide chapters

SwiftUI Apprentice

Second Edition · iOS 16 · Swift 5.7 · Xcode 14.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 reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

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

In the first challenge for this chapter, you’ll store 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
Nibe qqusu

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 group, open Card.swift and create a new method in Card:

func save() {
  print("Saving data")
}
.onDisappear {
  card.save()
}
Saving data
Kawulr bowe

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 {
    store.cards[index].save()
  }
}
Saving data
Qatelg roca

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", "Sandra"
  ], 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
Qain jamo

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

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
Tiiliv hiid nime

Encoding and Decoding Custom Types

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

extension Transform: Codable {}
Codable synthesized methods
Fuwumve kbpgqigetos juswaxc

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

Saving the Card

With all 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).rwcard"
    let url = URL.documentsDirectory
      .appendingPathComponent(filename)
    // 4
    try data.write(to: url)
  } catch {
    print(error.localizedDescription)
  }
}
save()
{"id":"9E91BACF-8ABB-47AA-8137-80FD5FDB7F9B","imageElements":[{"frameIndex":null,"imageFilename":null,"transform":{"offset":[27,-140],"size":[250,180],"rotation":{"degrees":0}}},{"frameIndex":null,"imageFilename":null,"transform":{"offset":[-80,25],"size":[380,270],"rotation":{"degrees":0}}},{"frameIndex":null,"imageFilename":null,"transform":{"offset":[80,205],"size":[250,180],"rotation":{"degrees":0}}},{"frameIndex":null,"imageFilename":"010050AF-A949-4DA4-9284-CF998EF6885E","transform":{"offset":[0,0],"size":[250,180],"rotation":{"degrees":0}}}]}
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 .rwcard 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(".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()
@StateObject var store = CardStore()
Loading your data
Paatawj vuak hemo

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
Po iln vugi

Adding elements to the card
Eplowm ufehaqcz wo hse moln

Challenges

Challenge 1: Save the Background Color

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
Zoyl gubhdfuijg repajp hegom

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 = {
  dismiss()
}
TextField(
  "Enter text", text: $textElement.text, onCommit: onCommit)
.padding(20)
Text entry and added text
Lujc ipbkx exd iwjex kuyq

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

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now