State Restoration With Swift Structs

How do you implement state preservation and restoration when your view controller model is a Swift struct that does not support NSCoding? My notes on how to wrap the struct in a dictionary with some help from Sourcery.

State Preservation and Restoration

Apple added state preservation and restoration back in iOS 6. Devices back then had limited resources so the system would often kill your backgrounded App. Having your view controllers save and restore their state across terminations makes this less visible to the user.

Modern iOS devices are more powerful than they were 6-7 years ago, but it’s still user-friendly to support state preservation and restoration. See this old post for a recap of the approach. A quick summary:

A view controller with extra state to save/restore does so by implementing encodeRestorableState(with:) and decodeRestorableState(with:).

Using a Swift Value Type As The Model

Let’s look at a simple example of a view controller which has the job of displaying details about a country. I am using a Swift struct for the country as follows:

struct Country {
  var name: String
  var capital: String?
  var continent: String
  var currency: String?
  var area: Float
  var population: Int
  var visited: Bool

There are also a couple of computed properties to get formatted versions of the population and area suitable for display:

  var formattedPopulation: String {
    return NumberFormatter.localizedString(from: population as NSNumber, number: .decimal)
  }

  var formattedArea: String {
    return NumberFormatter.localizedString(from: area as NSNumber, number: .decimal)
  }
}

Note: We don’t need to bother saving the computed properties.

Our view controller has a single property for its model, a Country value:

class CountryViewController: UIViewController {
  var country: Country? {
    didSet {
      configureView()
    }
  }

  // ...
}

To make this view controller support state restoration we need to save and restore the country model. If my model was a class supporting NSCoding I could add the two methods to the view controller like this:

override func encodeRestorableState(with coder: NSCoder) {
    if let country = country {
        coder.encode(country, forKey: "country")
    }
    super.encodeRestorableState(with: coder)
}

override func decodeRestorableState(with coder: NSCoder) {
    country = coder.decodeObject(forKey: "country") as? Country
    super.decodeRestorableState(with: coder)
}

Unfortunately, this crashes when run. Country does not support NSCoding, so it’s missing the required methods. Even worse we cannot make Country adopt NSCoding as that requires it to be a class.

There are a few workarounds we could try. A common approach is to use a new class to wrap the struct. We can then make the class adopt NSCoding:

class CountryWrapper: NSObject, NSCoding {
  var country: Country?

  init(country: Country) {
    self.country = country
  }

  func encode(with aCoder: NSCoder) {
    if let country = country {
      aCoder.encode(country.name, forKey: "name")
      ...
    }
  }

  required convenience init?(coder aDecoder: NSCoder) {
    guard let name = aDecoder.decodeObject(forKey: "name") as? String else {
      return nil
    }
    // ...
    let country = Country(name: name, ...)
    self.init(country: country)
  }
}

There’s also a case for making our Country type a class and have it adopt NSCoding directly. For the purposes of this post I’m going to stick with the struct. The approach I’m going to use is to wrap the struct in a dictionary. A Swift Dictionary is bridged to an NSDictionary which already supports NSCoding. I want my struct to have two new methods that convert to and from a dictionary:

func wrapped() -> Dictionary<String, Any>
init?(wrapped: Dictionary<String, Any>)

Using Sourcery To Create The Boilerplate

Writing the two methods is not difficult, but it’s repetitive. A tool like Sourcery can help us create and maintain such boilerplate code. Assuming you have Sourcery installed, we can add an annotation to any struct that we want to wrap:

// sourcery: Wrappable
struct Country: { ... }

Then we create a Wrappable stencil that Sourcery uses to create the code for each annotated struct it finds in our source code:

// Wrappable.stencil
import Foundation
{% for type in types.structs|annotated:"Wrappable" %}

extension {{ type.name }} {
  func wrapped() -> Dictionary<String, Any> {
    var dictionary = [String: Any]()
{% for var in type.storedVariables %}
    dictionary["{{ var.name }}"] = {{ var.name }}
{% endfor %}
    return dictionary
  }

  init?(wrapped: Dictionary<String, Any>) {
{% for var in type.storedVariables %}
    guard let {{ var.name }} = wrapped["{{ var.name }}"] as? {{ var.typeName }} else {
      return nil
    }
{% endfor %}
    self.init({% for var in type.storedVariables %}{{ var.name }}: {{var.name }}{% if not forloop.last %}, {% endif %}{% endfor %})
  }
}
{% endfor %}

Some quick notes on this stencil:

Running sourcery with this stencil generates the Swift file:

// Wrappable.generated.swift
import Foundation

extension Country {
  func wrapped() -> Dictionary<String, Any> {
    var dictionary = [String: Any]()
    dictionary["name"] = name
    dictionary["capital"] = capital
    dictionary["continent"] = continent
    dictionary["currency"] = currency
    dictionary["area"] = area
    dictionary["population"] = population
    dictionary["visited"] = visited
    return dictionary
  }

  init?(wrapped: Dictionary<String, Any>) {
    guard let name = wrapped["name"] as? String else {
      return nil
    }
    guard let capital = wrapped["capital"] as? String? else {
      return nil
    }
    guard let continent = wrapped["continent"] as? String else {
      return nil
    }
    guard let currency = wrapped["currency"] as? String? else {
      return nil
    }
    guard let area = wrapped["area"] as? Float else {
      return nil
    }
    guard let population = wrapped["population"] as? Int else {
      return nil
    }
    guard let visited = wrapped["visited"] as? Bool else {
      return nil
    }
    self.init(name: name, capital: capital, continent: continent, currency: currency, area: area, population: population, visited: visited)
  }
}

Don’t forget to add this generated file to the Xcode project.

Putting It All Together

The last thing we need to do is fix our view controller. If it has a country model to save we first call our generated method to wrap it in a dictionary and then encode:

override func encodeRestorableState(with coder: NSCoder) {
  if let country = country {
    let wrapped = country.wrapped()
    coder.encode(wrapped, forKey: "country")
  }
  super.encodeRestorableState(with: coder)
}

We do the reverse, unwrapping the dictionary, to restore the country:

override func decodeRestorableState(with coder: NSCoder) {
  if let wrapped = coder.decodeObject(forKey: "country") as? Dictionary<String, Any> {
    country = Country(wrapped: wrapped)
  }
  super.decodeRestorableState(with: coder)
}

That’s it! The first time takes some effort, but now I have a sourcery template the next time will be easy.

Alternate Approach With Codable

I like the simplicity of this alternate approach suggested by @_lksz_. If you can make the model struct Codable you can convert it to/from a Data type which, via NSData, supports NSCoding:

extension Country: Codable { ... }

The state preservation and restoration in the view controller is then as follows:

override func encodeRestorableState(with coder: NSCoder) {
  if let country = country,
    let data = try? JSONEncoder().encode(country) {
    coder.encode(data, forKey: "country")
  }
  super.encodeRestorableState(with: coder)
}

override func decodeRestorableState(with coder: NSCoder) {
  if let data = coder.decodeObject(forKey: "country") as? Data,
    let country = try? JSONDecoder().decode(Country.self, from: data) {
    self.country = country
  }
  super.decodeRestorableState(with: coder)
}

This removes the need to generate any boilerplate with Sourcery.

Read More

If you decide to use the approach of wrapping the struct in a class that implements NSCoding I found this article useful:

For help on installing and using Sourcery:

For a quick overview of Sourcery by Gabrielle Earnshaw at iOSCon 2019:

An old post (written for iOS 6), that now looks ancient, but is still relevant if you want a recap on state preservation and restoration:

Never Miss A Post

Sign up to get my iOS posts and news direct to your inbox and I'll also send you my free iOS Size Classes Cheat Sheet

    Unsubscribe at any time. See Privacy Policy.

    Archives Categories