How to preserve state in reference types in swiftUI

I have a SwiftUI app with a class that conforms to ObservableObject that contains state that I would like to preserve between application launches. How do I do this in swiftUI? I need something similar to @SceneStorage but for reference types.

Below is an example showing what I am trying to accomplish. This app shows statistics for a specific network port. I would like to have the StreamStats parameters preserved. I can save the lastPort value using the @SceneStorage property wrapper but this is just to demonstrate the intent.

Note: Although this example does not show it, the port member in StreamStats could be changed by code in the class itself. It must not only be reflected correctly the the GUI, but also be saved as part of the state.

import SwiftUI

@main
struct SceneStorageTest: App {
  var body: some Scene {
    WindowGroup("Port Statistics", id: "statsView") {
      ContentView()
    }
  }
}

class StreamStats: ObservableObject {
  @Published public var port: Int = 60000
  
  init() {
    startListening()
  }
  
  public func startListening() {
    print("Gathering stats for port \(port)")
  }
}

struct ContentView: View {
  @SceneStorage("lastPort") var lastPort: Int = 0
  @StateObject var streamStats = StreamStats()
  
  @Environment(\.openWindow) private var openWindow
  
  var body: some View {
    VStack {
      Text("Last port: \(lastPort)")
      TextField("port", value: $streamStats.port, format: .number)
      Button("Open") {
        lastPort = streamStats.port
        streamStats.startListening()
      }
      
      Divider()
      
      Button("New Port Statistics Window") {
        openWindow(id: "statsView")
      }
    }.padding()
  }
}

Accepted Reply

Thanks to apple support for helping me out with this one.

The correct answer is in the WWDC22 The SwiftUI cookbook for navigation video, right at the end.

For completeness, the following fixes the example given in the original question:

import SwiftUI
import Combine

@main
struct SceneStorageTest: App {
  var body: some Scene {
    WindowGroup("Port Statistics", id: "statsView") {
      ContentView()
    }
  }
}

class StreamStats: ObservableObject, Encodable {
  @Published public var port: Int = 60000
  
  public var jsonData: Data? {
    get {
      try? JSONEncoder().encode(self)
    }
    set {
      guard let data = newValue,
            let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
      else { return }
      port = json["port"] as! Int
    }
  }
  
  enum CodingKeys: String, CodingKey {
    case port
  }
  
  init() {
    startListening()
  }
  
  public func startListening() {
    print("Gathering stats for port \(port)")
  }
  
  var objectWillChangeSequence: AsyncPublisher<Publishers.Buffer<ObservableObjectPublisher>> {
    objectWillChange
      .buffer(size: 1, prefetch: .byRequest, whenFull: .dropOldest)
      .values
  }
  
  func encode(to encoder: Encoder) throws {
    var container = encoder.container(keyedBy: CodingKeys.self)
    try container.encode(port, forKey: .port)
  }
}

struct ContentView: View {
  @StateObject var streamStats = StreamStats()
  @SceneStorage("streamStatsData") var streamStatsData: Data?
  
  @Environment(\.openWindow) private var openWindow
  
  var body: some View {
    VStack {
      TextField("port", value: $streamStats.port, format: .number)
      Button("Open") {
        streamStats.startListening()
      }
      
      Divider()
      
      Button("New Port Statistics Window") {
        openWindow(id: "statsView")
      }
    }
    .padding()
    .task {
      if let data = streamStatsData {
        print("Loading data.")
        streamStats.jsonData = data
      }
      
      for await _ in streamStats.objectWillChangeSequence {
        streamStatsData = streamStats.jsonData
      }
    }
  }
}

Replies

Did you try to save state in userDefaults ?

  • No, I did not. If I understand correctly, that will save it for the object but there is no way to link it to the scene - or am I missing something? Can I use UserDefaults and link it to the scene?

Add a Comment

I quickly played with UserDefaults but as expected it gets saved in Preferences .plist file. I did see keys with names like "AppWindow-X" [1]. If I could get the number of a window in a scene that would uniquely identify it, then I could use that to store the settings specific for the window but I found no way of getting a window reference.

For interest sake, I also tried to find where the @SceneStorage data is stored to see if it gave any clues but I could not find it.

Any suggestions will be appreciated.

[1] Where X is the window number.

Thanks to apple support for helping me out with this one.

The correct answer is in the WWDC22 The SwiftUI cookbook for navigation video, right at the end.

For completeness, the following fixes the example given in the original question:

import SwiftUI
import Combine

@main
struct SceneStorageTest: App {
  var body: some Scene {
    WindowGroup("Port Statistics", id: "statsView") {
      ContentView()
    }
  }
}

class StreamStats: ObservableObject, Encodable {
  @Published public var port: Int = 60000
  
  public var jsonData: Data? {
    get {
      try? JSONEncoder().encode(self)
    }
    set {
      guard let data = newValue,
            let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
      else { return }
      port = json["port"] as! Int
    }
  }
  
  enum CodingKeys: String, CodingKey {
    case port
  }
  
  init() {
    startListening()
  }
  
  public func startListening() {
    print("Gathering stats for port \(port)")
  }
  
  var objectWillChangeSequence: AsyncPublisher<Publishers.Buffer<ObservableObjectPublisher>> {
    objectWillChange
      .buffer(size: 1, prefetch: .byRequest, whenFull: .dropOldest)
      .values
  }
  
  func encode(to encoder: Encoder) throws {
    var container = encoder.container(keyedBy: CodingKeys.self)
    try container.encode(port, forKey: .port)
  }
}

struct ContentView: View {
  @StateObject var streamStats = StreamStats()
  @SceneStorage("streamStatsData") var streamStatsData: Data?
  
  @Environment(\.openWindow) private var openWindow
  
  var body: some View {
    VStack {
      TextField("port", value: $streamStats.port, format: .number)
      Button("Open") {
        streamStats.startListening()
      }
      
      Divider()
      
      Button("New Port Statistics Window") {
        openWindow(id: "statsView")
      }
    }
    .padding()
    .task {
      if let data = streamStatsData {
        print("Loading data.")
        streamStats.jsonData = data
      }
      
      for await _ in streamStats.objectWillChangeSequence {
        streamStatsData = streamStats.jsonData
      }
    }
  }
}