swiftui

How do I add a loaded object to the environment?


I've learned how to read/write application data from/to a file in my iOS application. I've also learned how to put data in the @Environment for use my various view objects. I just can't seem to figure out how to link those together. I have some app data (e.g. some configuration values):

@Observable class Config: Codable {
    var someConfigVal: Bool = false
}

And have defined a "store" to read and write this info.

@Observable class ConfigStore {
    var configuration: Config = Config()
    
    private static func fileURL() throws -> URL {
        try FileManager.default.url(
            for: .documentDirectory,
            in: .userDomainMask,
            appropriateFor: nil,
            create: false
        )
        .appendingPathComponent("config.data")
    }
    
    func load() async throws {
        let task = Task<Config, Error> {
            let fileURL = try Self.fileURL()

            guard let data = try? Data(contentsOf: fileURL) else {
                return Config()
            }
            let storedConfig = try JSONDecoder().decode(Config.self, from: data)
            
            print("loaded", storedConfig.someConfigVal)
            return storedConfig
        }
        
        let appConfig = try await task.value

        self.configuration = appConfig
        print("load", self.configuration.someConfigVal)
    }
    
    func save(appConfig: Config) async throws {
        print("Some val on save", appConfig.someConfigVal)
        let task = Task {
            let data = try JSONEncoder().encode(appConfig)
            let outfile = try Self.fileURL()
            
            try data.write(to: outfile)
        }
        
        _ = try await task.value
    }
}

extension EnvironmentValues {
    var configurationStore: ConfigStore {
        get { self[ConfigStoreKey.self] }
        set { self[ConfigStoreKey.self] = newValue }
    }
}

private struct ConfigStoreKey: EnvironmentKey {
    static let defaultValue: ConfigStore = ConfigStore()
}

I'm currently creating this store as a @State object in the main application object along with the async code to do the file storage management.

@main
struct StoreTestApp: App {
    @State private var configStore = ConfigStore()
    
    var body: some Scene {
        WindowGroup {
            ContentView(configStore: configStore) {
                Task {
                    print("In save task", configStore.configuration.someConfigVal)
                    do {
                        try await configStore.save(appConfig: configStore.configuration)
                    } catch {
                        fatalError(error.localizedDescription)
                    }
                }
            }
                .task {
                    do {
                        try await configStore.load()
                    } catch {
                        fatalError(error.localizedDescription)
                    }
                }
//            .environment(configStore)
        }
    }
}

For the UI, I've created a simple tabbed view: 1 to show the value and 1 to edit it.

struct ContentView: View {
    @Environment(\.scenePhase) private var scenePhase
//    @Environment(\.configurationStore) private var configStore

    var configStore: ConfigStore
    
    let saveAction: () -> Void
    
    var body: some View {
        TabView {
            ShowView()
                .tabItem {
                    Label("Show", systemImage: "magnifyingglass")
                }
            ParentEditView()
                .tabItem {
                    Label("Edit", systemImage: "pencil")
                }
        }
        .onChange(of: scenePhase) { _, newPhase in
            if newPhase == .inactive {
                print("Scene is inactive", configStore.configuration.someConfigVal)
                saveAction()
            }
        }
        .environment(configStore)
    }
}

struct ShowView: View {
    @Environment(\.configurationStore) private var configStore

    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text(String(configStore.configuration.someConfigVal))
        }
        .padding()
    }
}

struct ParentEditView: View {
    @Environment(\.configurationStore) private var configStore

    var body: some View {
        EditView(configStore: configStore)
    }
}

struct EditView: View {
    @Bindable var configStore: ConfigStore
    
    var body: some View {
        Toggle("Some val", isOn: $configStore.configuration.someConfigVal)
            .onChange(of: configStore.configuration.someConfigVal, { print(configStore.configuration.someConfigVal) })
    }
}

When I run the app in the simulator, change the value to true and click the "Home" button, I get the following in the output window:

loaded false
load false
true
Scene is inactive false
In save task false
Some val on save false

Any ideas where I am going wrong or what I should do differently? I'm kind of invested in the @Observable functionality at this point (lots of classes built with it), but if changing that's the only to "solve" this, I'll do it but please try to work with me on that. Thanks!


Solution

  • To be able to read your config.data file and use @Observable class Config: Codable to decode the json data in your ConfigStore func load(), you need to have:

    @Observable class Config: Codable {
        var someConfigVal: Bool = false
        
        enum CodingKeys: String, CodingKey {
            case _someConfigVal = "someConfigVal"   //<-- here
        }
    }
    

    This is because the @Observable macro changes the property name to _someConfigVal for use by SwiftUI.

    EDIT-1:

    instead of loading @Observable class using the environment object, try using the normal passing of @Observable class to Views using @Environment(ConfigStore.self) private var configStore.

    With the changes as shown in this example code, all works well for me.

    @Observable class Config: Codable {
        var someConfigVal: Bool = false
        
        enum CodingKeys: String, CodingKey {
            case _someConfigVal = "someConfigVal"   //<-- here
        }
    }
    
    @Observable class ConfigStore {
        var configuration: Config = Config()
        
        private static func fileURL() throws -> URL {
            try FileManager.default.url(
                for: .documentDirectory,
                in: .userDomainMask,
                appropriateFor: nil,
                create: false
            )
            .appendingPathComponent("config.data")
        }
    
        // why you want to use Task I don't know.
        func load() async throws {
            let task = Task<Config, Error> {
                let fileURL = try Self.fileURL()
                guard let data = try? Data(contentsOf: fileURL) else {
                    return Config()
                }
                let storedConfig = try JSONDecoder().decode(Config.self, from: data)
                return storedConfig
            }
            let appConfig = try await task.value
            self.configuration = appConfig
            print("----> load storedConfig: ", self.configuration.someConfigVal)
        }
        
        // --- here no Task, no async throws
        func save() {
            do {
                print("----> saving: ", configuration.someConfigVal)
                let data = try JSONEncoder().encode(configuration)  //<-- here
                let outfile = try Self.fileURL()
                try data.write(to: outfile)
            } catch {
                print(error)
            }
        }
    }
    
    struct ContentView: View {
        @Environment(\.scenePhase) private var scenePhase
        @Environment(ConfigStore.self) private var configStore // <--- here
     
        let saveAction: () -> Void
    
        var body: some View {
            TabView() {
                ShowView()
                    .tabItem {
                        Label("Show", systemImage: "magnifyingglass")
                    }
                ParentEditView()
                    .tabItem {
                        Label("Edit", systemImage: "pencil")
                    }
            }
            .onChange(of: scenePhase) { _, newPhase in
                if newPhase == .inactive || newPhase == .background {  // <--- for testing
                    print("--> Scene is inactive or background: ", configStore.configuration.someConfigVal)
                    saveAction()
                }
            }
        }
    }
    
    struct ShowView: View {
        @Environment(ConfigStore.self) private var configStore  // <--- here
    
        var body: some View {
            VStack {
                Image(systemName: "globe")
                    .imageScale(.large)
                    .foregroundStyle(.tint)
                Text(String(configStore.configuration.someConfigVal))
            }
            .padding()
        }
    }
    
    struct ParentEditView: View {
        @Environment(ConfigStore.self) private var configStore  // <--- here
    
        var body: some View {
            EditView()
        }
    }
    
    struct EditView: View {
        @Environment(ConfigStore.self) private var configStore  // <--- here
    
        var body: some View {
            @Bindable var configStore = configStore  // <--- here
            Toggle("Some val", isOn: $configStore.configuration.someConfigVal)
                .onChange(of: configStore.configuration.someConfigVal, {
                    print("-----> EditView: \(configStore.configuration.someConfigVal)")
                })
        }
    }
    
    @main
    struct StoreTestApp: App {
        @State private var configStore = ConfigStore()
        
        var body: some Scene {
            WindowGroup {
                ContentView() {
                    print("=======> App saving: \(configStore.configuration.someConfigVal)")
                    configStore.save()
                }
                .task {
                    do {
                        try await configStore.load()
                    } catch {
                        print(error)
                    }
                }
                .environment(configStore) // <-- here
            }
        }
    }
    

    Note the content of my test congig.data is just this json data {"someConfigVal":false}