Managing data in SwiftUI using JSON and @State

7 min read #Swift #SwiftUI
Managing data in SwiftUI using JSON and @State

When creating an iOS or Mac app, one of the first things you need to think about is how you're going to store your data. It will be the most important part of your architecture. After all, all that an app does is display and manipulate data in one way or another. But when you start searching for solutions you will stumble on quite complex things such as Core Data and Realm and, while you probably should get acquainted with both of them, for small-to-medium apps there's a simpler way: JSON.

Create your data structures

First, let's fire up that XCode and either open an existing SwiftUI app or create one. Now let's create a new file in your project and maybe name it DataStructures.swift, if that's cool by you. You can name it whatever you want really, I just like very verbose names for things. In this file we will hold our data structures, which Swift will need in order to match against data coming from JSON.

Now, depending on what you want to make, your data structure will differ, but for the sake of this article I'll go with a classic to-do item structure, like so:

struct TodoItem: Codable, Hashable, Identifiable {
    var id: String = UUID().uuidString
    var name: String = ""
    var created: Date = Date()
}

Now, this little data structure will hold data of an individual to-do item, where it will automatically get a UUID as its id property and the current date as its created property, so that we can create new to-do items by just calling TodoItem(name: "Buy groceries"). Heck, if you want to create a to-do item where the name is also empty (as is the most likeliest case), then you can get by with just calling TodoItem(). That's it.

Since you'd probably like to keep the database at least somewhat flexible and not only keep TodoItems in it, let's create a new data structure called Database, like so:

struct Database: Codable {
    var todos: [TodoItem] = []
}

Now, whenever you want to add new things to the database, you can just add new things to the Database struct and you're good to go.

Lets create an actual app

With the above you could create a very simple, basic application like so:

struct ContentView: View {
    @State var todos: [TodoItem] = []
    
    func add() {
        self.todos.append(TodoItem(name: "Buy groceries"))
    }
    
    func addButtonView() -> some View {
    	return Button(action: self.add, label: {
            Text("Add")
        })
    }
    
    var body: some View {
    	NavigationView {
            List {
                ForEach(todos, id: \.id) { todo in
                    Text(todo.name)
                }
            }
            
            .navigationBarTitle("To-dos")
            .navigationBarItems(trailing: addButtonView())
        }
    }
}

And what this application will do is create a list of todos, where you can add new to-do items to the list by tapping the "Add" button in the navigation bar. Notice however the text in each to-do will be static, which means you can't edit it. This is because, while the whole collection of todos is directly mutable, each TodoItem in it is not directly mutable, so the only way to update a specific to-do item is to mutate the entire todos variable.

But worry not, we can fix this by creating a custom Binding for the to-do item name, so instead of doing Text(todo.name) we can actually use a TextField instead, like so:

List {
    ForEach(todos, id: \.id) { todo in
        TextField("To-do ...", text: Binding(get: { todo.name }, set: {
            var todos = self.todos
            let todoIndex = todos.firstIndex { $0.id == todo.id }
            todos[todoIndex!].name = $0
            self.todos = todos
        }))
    }
}

This will allow us to actually edit the to-do item's name property, and what it does is when the text is being set (e.g. you change something in the TextField), it will make a copy of the todos variable into a local todos variable instead. Then it will find the index of the to-do item we are dealing with, update its name, and then update the entire global todos variable with the updated version of that very same variable that we cloned to local scope before so that we could edit it. In effect, we now have very basic state management working.

Reading and writing data to file

We have a basic app working and you can create new todo-items and change their names, but once you close the app and open it again you'll see that the data has disappeared. It makes sense; after all we are keeping all of the data in memory and not actually storing it anywhere in a persistent way. This is the place where we meet JSON and a glorious empty file.

Let's start by creating a new file in your project and name it, for example, DataProvider.swift. In it we will create a class that will manage our data flow from and to the actual JSON file. Let's start with this:

import Foundation

class DataProvider {
    // Get the URL to the documents directory
    // To be used to access the database file.
    let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).last!
    
    // Set the database file name.
    let dataFile = "db.json"
}

As you can see, it has two global variables in the class for our disposal. The first of which will get us the location of your app' documents directory and the second will be the name of our database file. You can name it whatever you want, of course.

Now, before we can do any actual data management here, we need to make sure that every time we use the DataProvider class, it would check if the db.json file exists and, if it doesn't, then it would create it with an empty instance of the Database struct we created earlier in this post. To make this happen, we need to create a init method, like this:

init() {
    guard NSData(contentsOf: documentsDirectory.appendingPathComponent(self.dataFile)) != nil else {
        let data = Database()
        let json = data.convertToString!
        try? json.write(to: documentsDirectory.appendingPathComponent(self.dataFile), atomically: true, encoding: .utf8)
        return
    }
}

Once you put that into the DataProvider class you'll see an error, something like "Value of type Database has no member 'convertToString!". And yes, your editor is right. You see, in order to convert your data structure called Database into a JSON string, we need to create an extension for Encodable. You can do so by adding the following bit to the end of the DataProvider.swift file:

extension Encodable {
    var convertToString: String? {
        let jsonEncoder = JSONEncoder()
        jsonEncoder.outputFormatting = .prettyPrinted
        
        do {
            let jsonData = try jsonEncoder.encode(self)
            return String(data: jsonData, encoding: .utf8)
        } catch {
            return nil
        }
    }
}

Once this is done the error should disappear and we should now have a DataProvider class that on each usage checks if the db.json file exists and, if it doesn't, it will create it with an empty instance of Database.

Reading TodoItems from file

So, now that we have the foundation for everything built, we can retrieve our precious TodoItem from our JSON file. First, let's create a generic method in the DataProvider class that reads the entire database from that file so that we could later use that as the basis for retrieving specific items:

public func read() -> Database {
    let fileContents = try? String(contentsOf: documentsDirectory.appendingPathComponent(self.dataFile), encoding: .utf8)
    let decoder = JSONDecoder()
    let fileContentsData = try? decoder.decode(Database.self, from: Data(fileContents!.utf8))

    return fileContentsData ?? Database()
}

The read method here will return the Database instance it finds in the JSON file. If however the instance doesn't match, like when it for some unbeknownst reason is corrupt, then it will return a brand new instance of Database instead, so that things go without hiccups, always. Once we have implemented the read method, we can now implement a more specific, getTodos method:

public func getTodos() -> [TodoItem] {
    return self.read().todos
}

We can now read todos from file by calling DataProvider().getTodos().

Writing TodoItems to file

But reading is just one side of the coin; the other side of that very same coin is writing to file. For that we can create another generic method in the DataProvider class that just overwrites the entire JSON file with an instance of Database, let's call it write:

public func write(_ db: Database) {
    DispatchQueue.global(qos: .background).async {
        let json = db.convertToString!
        try? json.write(to: self.documentsDirectory.appendingPathComponent("db.json"), atomically: true, encoding: .utf8)
    }
}

You see, write works in the background so that the main process of your app would be uninterrupted from its heavy lifting and thus the performance would not degrade during each write to the JSON file. We can use this write method now to create a yet another method in the DataProvider class that would update our TodoItems specifically, like so:

public func updateTodoItems(_ todos: [TodoItem]) {
    DispatchQueue.global(qos: .background).async {
        var data = self.read()
        data.todos = todos
        self.write(data)
    }
}

This method can be used by calling DataProvider().updateTodoItems({todo-items-go-here}), it will then get the entire Database from file, overwrite its todos property and write the entire thing back to file.

Putting it all together

Now that we have our very basic app logic and we've made our DataProvider for getting and setting data persistently, we can use it to make sure our app never loses any data. In the ContentView that we have created, where we currently define the @State of todos like this:

@State var todos: [TodoItem] = []

you can now, instead of that, write it like this:

@State var todos: [Todoitem] = DataProvider().getTodos()

You see, now instead of defaulting to an empty collection, we'll be getting the initial data from our JSON file instead. And in order to write our newly created TodoItems to our JSON file, we can add an observer for when the todos State changes and, when it does, write it to our JSON file. For that you can simply use the  .onChange SwiftUI modifier to the NavigationView, like so:

.onChange(of: todos, perform: DataProvider().updateTodoItems)

This will trigger the perform part when the todos variable changes. Remember we created a Binding which updates the todos when you edit a to-do item? We also change todos when you create a new to-do item from the navigation bar. Those are the two places that trigger this .onChange modifier.

So as a result, you should now have a DataStructures.swift file, wherein live your data ... well, structures. And you should have a DataProvider.swift file in what you have everything related to putting data to the JSON file and getting data from the JSON file. That, combined with your ContentView, will give you a pretty solid understanding of how to manage data in iOS or Mac using JSON and @State. And to top it all off, you just built a iOS app!