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