DEV Community

loading...

Handling persistent data in SwiftUI 2.0 with JSON

arnavmotwani profile image Arnav Motwani ・5 min read

Let me preface this post with the note that while json is an amazing way to store persistent data, core data should be your way to go. However core data isn't very beginner friendly so I recommend you try your best at core data before implementing json. Personally I like this article for core data.

Now that we have gotten that out of the way, let's get started. First, let's look at the flow of data in the app.
dataflow

If you don't know, your bundle is the directory which holds all your code and assets (what you see in your Xcode file inspector) I recommend putting a json file with two entries as examples in your bundle. You can see what I mean in my app Livre. So all you do is add a json file to your project (which I called list.json). Notes that you can leave the json file as an empty array if you don't want examples.

Here's and example of what list.json should look like:

[
    {
        "id" : "C8B046E9-70F5-40D4-B19A-40B3E0E0877B",
        "valueOne" : "Example one title",
        "valueTwo" : "Example one subtitle"
    },
    {
        "id" : "2E27CA7C-ED1A-48C2-9B01-A122038EB67A",
        "valueOne" : "Example two title",
        "valueTwo" : "Example two subtitle"
    }
]
Enter fullscreen mode Exit fullscreen mode

I want you to look and my dictionary keys. In json we usually write keys in snake_case (like value_one) but I have used camelCase (like valueOne) because swift doesn't can't use snake_case for variable name. Usually developers overcome this by using coding keys however for simplicity sake just use camelCase.

Also notice the long id's, that's a UUID. UUID is the best way to create id's and handle id's, you'll see why later.

Okay now we have an json file in our bundle that will serve as examples for the user. We want to present these items to the user in a list and we want the user to be able to modify this list.

Heres how we'll do that. First we'll create a struct to model our data. For smaller json dictionaries we can do it manually but there are services like quicktype that can automate the process of creating models. Our models are just a user defined datatype that can store each value in the json array.

Once again our json array looks like this

[
    {
        "id" : "C8B046E9-70F5-40D4-B19A-40B3E0E0877B",
        "valueOne" : "Example one title",
        "valueTwo" : "Example one subtitle"
    },
    {
        "id" : "2E27CA7C-ED1A-48C2-9B01-A122038EB67A",
        "valueOne" : "Example two title",
        "valueTwo" : "Example two subtitle"
    }
]
Enter fullscreen mode Exit fullscreen mode

so our model will look like this:

struct jsonModel: Hashable, Codable, Identifiable {
    var id: UUID
    var valueOne: String
    var valueTwo: String
}
Enter fullscreen mode Exit fullscreen mode

The model struct has to conform to codable so it can be converted to json and back. It also needs to conform to identifiable and hashable to make showing it in a list easier. Note that identifiable requires an id field.

Alright now we got a model as well as our json file in our bundle, its time to decode the json into an array of the models.

extension Bundle {
    static func load<T: Decodable>(_ filename: String) -> T {

        let readURL = Bundle.main.url(forResource: filename, withExtension: "json")! //Example json file in our bundle
        let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! // Initializing the url for the location where we store our data in filemanager

        let jsonURL = documentDirectory // appending the file name to the url
            .appendingPathComponent(filename)
            .appendingPathExtension("json")

        // The following condition copies the example file in our bundle to the correct location if it isnt present
        if !FileManager.default.fileExists(atPath: jsonURL.path) {
            try? FileManager.default.copyItem(at: readURL, to: jsonURL)
        }

        // returning the parsed data
        return try! JSONDecoder().decode(T.self, from: Data(contentsOf: jsonURL))
    }
}
Enter fullscreen mode Exit fullscreen mode

This function is pretty simple, first we share the location of the json example in the bundle and the location in file manager where we want our json file to be stored. Then we check if the file exists in the file manager, if it doesn't we copy the example there. You might be thinking why we don't just edit the json file in the bundle to make things simple, well the bundle is read only so you can't. Now we just decode the json with JSONDecoder(). Note that I added this function as an extension of the bundle, this serves no purpose but as only the bundle needs it, its a good idea to keep it there.

Finally we create an ObservableObject that views can view for data.

Heres what it looks like

class jsonData: ObservableObject {
    @Published var jsonArray : [jsonModel] // The Published wrapper marks this value as a source of truth for the view

    init() {
        self.jsonArray = Bundle.load("list") // Initailizing the array from a json file
    }

    // function to write the json data into the file manager
    func writeJSON() {
        let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
        let jsonURL = documentDirectory
            .appendingPathComponent("list")
            .appendingPathExtension("json")
        try? JSONEncoder().encode(jsonArray).write(to: jsonURL, options: .atomic)
    }
}
Enter fullscreen mode Exit fullscreen mode

Pretty simple. All we have is an array of our models called jsonArray and a function called writeJSON to write the array back to file manager. The array uses the Published property wrapper to define it as a source of truth (a variable which will cause view to update when the variable's value changes.

Now there are a couple avenues to go down: Do you want to use observable, state, or environment objects. I'm not going to go into the differences but your which one you use may change on based on your requirements. That being said, if there are going to be many views that read and edit the the data, an environment object is you best bet and it's what i'm going to use.

All you do is pass an instance of the observable object to the root view and then it is available for all children of that view.

Like this:

@main
struct jsonExampleApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView().environmentObject(jsonData()) // Creating an instance of jsonData that we pass to the content view
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Here my root view is my content view so I directly passed an instance of my data to it.

Then you can display the data by calling the object from the environment with the EnviromentObject property wrapper in the root view or any child view. Here's my example implementation

struct ContentView: View {
    @EnvironmentObject var jsonData: jsonData

    var body: some View {
        NavigationView {
            Form {
                ForEach(jsonData.jsonArray) { jsonItem in
                    Menu(content: {
                        Button(action: {
                            jsonData.jsonArray.removeAll(where: { $0.id == jsonItem.id })
                            jsonData.writeJSON()
                        }, label: {
                            Label("Delete", systemImage: "trash")
                        })
                    }, label: {
                        VStack(alignment: .leading) {
                            Text(jsonItem.valueOne)
                                .font(.title2)
                                .bold()
                                .foregroundColor(.primary)
                            Text(jsonItem.valueTwo)
                                .foregroundColor(.primary)
                        }
                    })
                }
            }
            .navigationTitle("Json example")
            .navigationBarItems(trailing:NavigationLink(destination: NewItem(),label: {Image(systemName: "plus").imageScale(.large)}))
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Note that because my model conformed to identifiable, I didn't need to set an id in the ForEach.

struct NewItem: View {

    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>
    @EnvironmentObject var jsonData: jsonData

    @State private var valueOne: String = ""
    @State private var valueTwo: String = ""

    var body: some View {
        Form {
            Section {
                TextField("valueOne", text: $valueOne)
                TextField("valueTwo", text: $valueTwo)
            }

            Section {
                HStack {
                    Spacer()
                    Button("Add item", action: {
                        jsonData.jsonArray.append(jsonModel(id: UUID(), valueOne: valueOne, valueTwo: valueTwo))
                        jsonData.writeJSON()

                        self.presentationMode.wrappedValue.dismiss()
                    })
                    Spacer()
                }
            }
        }
        .navigationTitle("New item")
    }
}
Enter fullscreen mode Exit fullscreen mode

Note the button's action, it appends the new item, writes the data and dismisses the view. But notice UUID(), any time you need a unique id you just call that and swift will generate a unique identifier for you.

And that's it, how you use json in swiftui. You can see an example project on my GitHub repository. For any questions dm me on twitter or leave an issue on the repo.

Discussion (0)

pic
Editor guide