Save Custom Codable Types in AppStorage or SceneStorage


On iOS 14 we have two new property wrappers in SwiftUI: AppStorage and SceneStorage. AppStorage is usually used to persist some user specific settings and SceneStorage is designed for state restoration.


It's very easy to use these properties when we want to persist value types that they support out of the box, those are Bool, Int, Double, String, URL and Data. For example, we can save the user's chosen city in a weather app using AppStorage.


struct ContentView: View {
    @AppStorage("city") var city = ""

    var body: some View {
        TextField("Enter your city", text: $city)
    }
}


But we need to do a bit more work if we want to save a type that is not supported by AppStorage or SceneStorage by default. They both have initializers that accept a RawRepresentable where RawValue is of type Int or String. So we would need to add conformance to RawRepresentable to our custom type.


It's important to note though that we shouldn't be using AppStorage and SceneStorage as a replacement for a database, they are designed to save small pieces of data. But sometimes an app setting or a view state are represented by a custom type and this would be the use case described in this article.


As an example, we will look at a recipe app where the users can pin up to 3 recipes to the top of the list. You can get the full code for the example project from GitHub.


Recipe app screenshot Recipe app screenshot


The pinned recipes in our case are a user setting that should be persisted for the whole app, not per scene, so we will be using AppStorage. But the approach with RawRepresentable conformance can be used for SceneStorage as well.


Our custom type to be saved in AppStorage is an Array of UUIDs which is Codable by default. You might need to add Codable conformance to your type manually.


typealias PinnedRecipes = [UUID]


Now we need to add RawRepresentable conformance to our custom Codable type. Remember, that AppStorage only supports RawRepresentable where the RawValue associatedtype is of type Int or String. So the rawValue property has to return an Int or a String.


extension PinnedRecipes: RawRepresentable {
    public init?(rawValue: String) {
        guard let data = rawValue.data(using: .utf8),
            let result = try? JSONDecoder().decode(PinnedRecipes.self, from: data)
        else {
            return nil
        }
        self = result
    }

    public var rawValue: String {
        guard let data = try? JSONEncoder().encode(self),
            let result = String(data: data, encoding: .utf8)
        else {
            return "[]"
        }
        return result
    }
}


Once our type conforms to RawRepresentable we can use it with AppStorage or SceneStorage.


struct ContentView: View {
    @AppStorage("pinnedRecipes") var pinnedRecipes = PinnedRecipes()
    
    var body: some View {
        List {
            Section(header: Text("Pinned Recipes")) {
                ForEach(pinnedRecipes, id: \.self) { id in
                    ...
                }
            }
        }
    }
}


You can use this approach to save a custom struct, a dictionary or an enum with associated values.


Feel free to take the full code for the example project from GitHub and experiment.