iOS, Objective-C, Swift, Design and Whatever Comes in Mind
‹ Back to the Blog

SwiftUI: Loading Persistent Data

In my early attempt in learning SwiftUI I stumbled upon a fairly common problem: loading data from disk and saving it after updates were made.

It seems simple at first but when thinking about I can come up with a number of hurdles one has to take:

  1. Loading data should be performed in the background. Thus the data is not immediately available to us. It can even come from a database request or via network.
  2. The UI should reload as soon as data is available.
  3. Changes the user has made must be saved so that the model layer is always persisted.
  4. However, the file system should not be asked to write a file every time the user interacts. Give them a little time until writes are made. That's called debouncing.

Let's Go!

In order to create a sample project we start by defining our very basic model:

struct Model: Codable {
    var content: [Element]
}

struct Element: Codable, Identifiable {
    let id = UUID()
    let name: String
}

Now it is a great time to care about a class that saves an object to disk and is able to read it from there. Let's call it PersistanceController. When loading data, it calls a completion handler to deliver that data:

class PersistanceController<T: Codable> {
...

func loadData(for filename: String, fileExtension: String, defaultValue: T, completion: @escaping ((T) -> Void)) {
        let fileManager = FileManager()
        let dataURL = pathToSavedDataInSharedContainer(forName: filename, fileExtension: fileExtension)
        if fileManager.fileExists(atPath: dataURL.path) {
            let decoder = PropertyListDecoder()

            queue.async(flags: .barrier) {
                guard let data = try? Data(contentsOf: dataURL), let decoded = try? decoder.decode(T.self, from: data) else {
                    DispatchQueue.main.async {
                        completion(defaultValue)
                    }
                    return
                }
                DispatchQueue.main.async {
                    completion(decoded)
                }
            }
        }
        else {
            completion(defaultValue)
        }
    }
}

For writing data we define our function as follows:

private func save(_ object: T, fileName: String, fileExtension: String = "plist", completionHandler: (() -> Void)? ) {
        let fileManager = FileManager()
        let fileURL = pathToSavedDataInSharedContainer(forName: fileName, fileExtension: fileExtension)
        if !fileManager.fileExists(atPath: fileURL.path) {
            // Directory needs to be created first.
            try? fileManager.createDirectory(atPath: applicationDataDirectory.path, withIntermediateDirectories: true, attributes: nil)
        }

        let encoder = PropertyListEncoder()
        queue.async(flags: .barrier) {
            defer {
                DispatchQueue.main.async(execute: completionHandler ?? {})
            }
            guard let data = try? encoder.encode(object) else {
                return
            }
            try? data.write(to: fileURL, options: [.atomic])
        }
    }

Those functions can potentially do whetever they want them to do as long as they save and restore the data.

To make our lives easier when it comes to storing updated data we introduce a Subscriber. New values are passed in through whatever publisher this subscriber is connected to (we'll cover that later). Errors are not handled in this simple example.

// Should be used to trigger a write request.
 var writeSubscriber: Subscribers.Sink<T, Never>!

private func setupSubscriber() {
        writeSubscriber = Subscribers.Sink(receiveCompletion: nil, receiveValue: { (value) in
            self.save(value, fileName: self.filename) {
            // No error handling here.
                print("Saved")
            }
        })
    }

Now we have

  • a way to load data from disk asynchronously. The data is received in a completion block.
  • a way to save data by passing it to a subscriber.

We continue making our lives easier by introducing a PropertyWrapper that encapsulates all of the logic above:

@propertyWrapper
class Storing<Value: Codable>: ObservableObject {
    private var value: Value?
    private let persistanceController: PersistanceController<Value>
    private let filename: String
    private let defaultValue: Value

    let objectWillChange = PassthroughSubject<Value, Never>()
    private let writePublisher = PassthroughSubject<Value, Never>()

    init(filename: String, defaultValue: Value) {
        self.filename = filename
        self.defaultValue = defaultValue
        persistanceController = PersistanceController(filename: filename)
        setupPublishers()

        persistanceController.loadData(for: filename, fileExtension: "plist", defaultValue: defaultValue) { loaded in
            print("Loaded")
            // Foreward the new data into the publisher
            self.objectWillChange.send(loaded)
            self.value = loaded
        }
    }

    private func setupPublishers() {

        // Connect write publisher to persistance controller's receiving subscriber that will write the data for us
        writePublisher.debounce(for: .seconds(1.5), scheduler: RunLoop.main).receive(subscriber: persistanceController.writeSubscriber)
    }

    var wrappedValue: Value {
        get {
            value ?? defaultValue
        }
        set {
            objectWillChange.send(newValue)
            value = newValue

            writePublisher.send(newValue)
        }
    }
}

The property wrapper is initialized with a filename and a default value. As soon as loading the file is completed the publisher will pass the new value along to its subscribers. When the wrapped value is mutated, the writePublisher is asked to send the new value.

The function setupPublishers connects the publisher with the writeSubscriber of the persistance controller. We also include a debounce so that not every mutation is written to disk immediately. Upon mutation it waits 1.5 seconds until the file system should process the new data.



Now we can

  • use the property wrapper to store and load any Codable.
  • use the wrapped value either directly or use the publisher to add a subscriber to it. Then we'll be notified about events such as mutations or the initial loading.

Now to the view

In this simple example we just want display the content in a list. Before we get to the declaration of the view we need to instantiate our model.

SwiftUI is still in beta so unfortunately we can not use the property wrapper directly as source for our list like this

// Does not compile for unkown reason:
@Storing(filename: "TrackList", defaultValue: Model(content: [])) var model: Model

also wrapping it inside another wrapper does not work:

@ObjectBinding @Storing(filename: "TrackList", defaultValue: Model(content: [])) var model: Model

Therefore we fall back to creating a model wrapper class (it needs to be a class since @ObjectBinding demands so). That wrapper receives changes from @Storing's publisher and forewards it to its own publisher:

class ModelWrapper: ObservableObject {
    @Storing(filename: "Animals", defaultValue: Model(content: [])) var model: Model
    let objectWillChange = PassthroughSubject<Model, Never>()
        init() {
            let intermediateSubsriber = Subscribers.Sink<Model, Never>(receiveCompletion: { (completion) in
            }) { (value) in
                self.objectWillChange.send(value)
            }
            _model.objectWillChange.receive(subscriber: intermediateSubsriber)
        }
}

In doing so we can finally wire up the model layer with our view:

struct ContentView : View {
    @ObservedObject var modelWrapper: ModelWrapper = .init()

    var body: some View {
        NavigationView {
            List(modelWrapper.model.content) { element in
                Text("\(element.name)")
            }
            .navigationBarTitle(Text("Animals"))
                .navigationBarItems(leading: Button(action: {

                    // We can access the property directly, everything else is done for us automatically.
                    self.modelWrapper.model.content.append( Element.generateMock())

                }, label: {
                    Text("Add more")
                })
                    , trailing: Button(action: {
                        self.modelWrapper.model.content.removeAll()
                    }, label: {
                        Text("Remove All")
                    }))
        }
    }
}

Now our list is populated by data that is loaded from disk asynchronously. When we press a button the model gets updated and the new data is written do disk automatically. When the user presses one buttons frequently it debounces the writes until there is a short brake.

Video of the sample project

The project still has a lot room for improvements but I wanted to make it simple.

If you want to take a look at the very basic project we build, you can download it here.

If you have any feedback or know how to avoid that extra ModelWrapper please let me know! 😊

"SwiftUI: Loading Persistent Data".