Categories
Generics iOS Swift SwiftUI

Loading async data for SwiftUI views

Sometimes we need to invoke an async function for fetching data before presenting a SwiftUI view. Therefore, a common flow is showing a spinner while the data is being fetched and then showing the main view. Moreover, if an error occurs, we show a failure view with a retry button. Let’s dive in how to build such view in a generic way.

As said before, our container view, let’s call it ContentPrepareView (similar naming to Apple’s ContentUnavailableView), has three distinct states: loading, failure, and success (named as “content” in the enum).

extension ContentPrepareView {
enum ViewContent {
case loading
case content
case failure(Error)
}
}

We’ll go for a fully generic implementation where each of the view state corresponds to a view builder. This gives as flexibility if in some places we want to use custom loading views or different failure view. But on the other hand, most of the time we just want to use a common loading and failure views, that is why we set default values for loading and failure view builders (see below). In addition to view builders, we need an async throwing task closure which handles the data fetching/preparation. If we put it all together, then the ContentPrepareView becomes this:

struct ContentPrepareView<Content, Failure, Loading>: View where Content: View, Failure: View, Loading: View {
@State private var viewContent: ViewContent = .loading
@ViewBuilder let content: () -> Content
@ViewBuilder let failure: (Error, @escaping () async -> Void) -> Failure
@ViewBuilder let loading: () -> Loading
let task: () async throws -> Void
init(content: @escaping () -> Content,
failure: @escaping (Error, @escaping () async -> Void) -> Failure = { FailureView(error: $0, retryTask: $1) },
loading: @escaping () -> Loading = { ProgressView() },
task: @escaping () async throws -> Void) {
self.content = content
self.failure = failure
self.loading = loading
self.task = task
}
var body: some View {
Group {
switch viewContent {
case .content:
content()
case .failure(let error):
failure(error, loadTask)
case .loading:
loading()
}
}
.onLoad(perform: loadTask)
}
// redacted
}

Since loading, failure and success views can be any kind of views, then our view needs to be a generic view. The body of the view has a switch-case for creating a view for the current view state. One thing to note here is that the onLoad view modifier is a custom one, and the idea is that it makes sure that the content preparation work only runs once per view life-time (onAppear() or task() can run multiple times). The reasoning is that we want to have an experience where we show the loading spinner only when the view is presented the first time, not when it appears again. The loadTask function is async and has responsibility of running the passed in async task closure and updating the current view state.

struct ContentPrepareView<Content, Failure, Loading>: View where Content: View, Failure: View, Loading: View {
// redacted
@MainActor func loadTask() async {
do {
viewContent = .loading
try await task()
viewContent = .content
}
catch {
viewContent = .failure(error)
}
}
}
view raw LoadTask.swift hosted with ❤ by GitHub

In this example we used a custom FailureView and it is a small view wrapping Apple’s ContentUnavailableView. It sets a label, description and handles the creation of the retry button.

struct FailureView: View {
let error: Error
let retryTask: () async -> Void
var body: some View {
ContentUnavailableView(label: {
Label("Failed to load", systemImage: "exclamationmark.circle.fill")
}, description: {
Text(error.localizedDescription)
}, actions: {
Button(action: {
Task { await retryTask() }
}, label: {
Text("Retry")
})
})
}
}

Here is an example how to use the final ContentPrepareView. For demo purposes, it fails the first load and allows succeeding the second.

struct ContentView: View {
// Demo: first load leads to an error
@State private var showsError = true
var body: some View {
ContentPrepareView {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
} task: {
try await Task.sleep(nanoseconds: 3_000_000_000)
// Demo: Retrying a task leads to success
guard showsError else { return }
showsError = false
throw LoadingError.example
}
}
}
enum LoadingError: LocalizedError {
case example
var errorDescription: String? {
"The connection to Internet is unavailable"
}
}
view raw Usage.swift hosted with ❤ by GitHub

ContentPrepareViewExample (GitHub, Xcode 15.0.1)

If this was helpful, please let me know on Mastodon@toomasvahter or Twitter @toomasvahter. Feel free to subscribe to RSS feed. Thank you for reading.

Categories
iOS Swift UIKit

Creating persistent data store on iOS

Storing data persistently on iOS is something what is needed quite often. In this post, we are going to look into how to build a persistent data store and how to store image data.

Initialising the persistent data store

Persistent data store is an object managing a folder on disk. It allows writing and reading data asynchronously.
Firstly, we need to create a folder where to store all the files. As every instance of the data store should manage its own folder, we will add an argument name to the initialiser. Then we can create a folder in user’s documents folder with that name. As writing and reading data is an expensive operation, we are going to offload the work to a concurrent DispatchQueue. Concurrent dispatch queue allows us to read multiple files at the same time (more about it a bit later).

final class PersistentDataStore {
let name: String
private let dataStoreURL: URL
private let queue: DispatchQueue
init(name: String) throws {
self.name = name
queue = DispatchQueue(label: "com.augmentedcode.persistentdatastore", qos: .userInitiated, attributes: .concurrent, autoreleaseFrequency: .workItem)
let documentsURL = try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
dataStoreURL = documentsURL.appendingPathComponent(name, isDirectory: true)
try FileManager.default.createDirectory(at: dataStoreURL, withIntermediateDirectories: true, attributes: nil)
}
}

Storing data asynchronously

Method for storing data on disk consists of closure, identifier and completion handler. This allows us to create a closure what transforms object to data. For example, it could transform UIImage to Data. Secondly, this transformation, possibly slow operation, can be offloaded to the same thread writing the data into a file. Using closure gives us a flexible API what we can extend with convenience methods.

typealias Identifier = String
enum Result {
case failed(Error)
case noData
case success(Identifier)
}
func storeData(_ dataProvider: @escaping () -> (Data?), identifier: Identifier = UUID().uuidString, completionHandler block: @escaping (Result) -> ()) {
queue.async(flags: .barrier) {
let url = self.url(forIdentifier: identifier)
guard let data = dataProvider(), !data.isEmpty else {
DispatchQueue.main.async {
block(.noData)
}
return
}
do {
try data.write(to: url, options: .atomic)
DispatchQueue.main.async {
block(.success(identifier))
}
}
catch {
DispatchQueue.main.async {
block(.failed(error))
}
}
}
}
// Example (adding data to data store with unique identifier):
persistentStore.storeData({ () -> (Data?) in
return image.jpegData(compressionQuality: 1.0)
}) { (result) in
switch result {
case .success(let identifier):
print("Stored data successfully with identifier \(identifier).")
case .noData:
print("No data to store.")
case .failed(let error):
print("Failed storing data with error \(error)")
}
}

Identifier is internally used as a filename and default implementation creates unique identifier. Therefore, when data store consumer would like to replace the current file, it can supply an identifier, otherwise new file is created.
Completion handler contains a Result enum type. Result enum consists of three cases: success, transformation failure and data writing failure. Success’ associated value is identifier, failure contains error object and transformation failure is equal to noData.
Important to note here is that the work item has barrier specified. Barrier means that when DispatchQueue starts to handle the work item, it will wait until all the previous work items have finished running. Meaning, we will never try to update a file on disk when some other request is busy reading it.

Loading data asynchronously

Load data is generic method allowing the data transformation closure to return a specific type (e.g. transforming Data to UIImage). Shortly, load data reads file from disk and transforms it into a different type. As transformation can be a lengthy task, it is yet again running on the background thread and will not cause any hiccups in the UI.

func loadData<T>(forIdentifier identifier: Identifier, dataTransformer: @escaping (Data) -> (T?), completionHandler block: @escaping (T?) -> ()) {
queue.async {
let url = self.url(forIdentifier: identifier)
guard FileManager.default.fileExists(atPath: url.path) else {
DispatchQueue.main.async {
block(nil)
}
return
}
do {
let data = try Data(contentsOf: url, options: .mappedIfSafe)
let object = dataTransformer(data)
DispatchQueue.main.async {
block(object)
}
}
catch {
print("Failed reading data at URL \(url).")
DispatchQueue.main.async {
block(nil)
}
}
}
}
// Example
persistentStore.loadData(forIdentifier: "my_identifier", dataTransformer: { UIImage(data: $0) }) { (image) in
guard let image = image else {
print("Failed loading image.")
return
}
print(image)
}

Removing data asynchronously

Removing a single file or all of the files is pretty straight-forward. As we are modifying files on disk, we will use barrier again and then FileManager’s removeItem(at:) together with contentsOfDirectory(at:includingPropertiesForKeys:options:).

func removeData(forIdentifier identifier: Identifier) {
queue.async(flags: .barrier) {
let url = self.url(forIdentifier: identifier)
guard FileManager.default.fileExists(atPath: url.path) else { return }
do {
try FileManager.default.removeItem(at: url)
}
catch {
print("Failed removing file at URL \(url) with error \(error).")
}
}
}
func removeAll() {
queue.async(flags: .barrier) {
do {
let urls = try FileManager.default.contentsOfDirectory(at: self.dataStoreURL, includingPropertiesForKeys: nil, options: [])
try urls.forEach({ try FileManager.default.removeItem(at: $0) })
}
catch {
print("Failed removing all files with error \(error).")
}
}
}

Extension for storing images

It is easy to extend the PersistentDataStore with convenience methods for storing a specific type of data. This allows us to hide the technical details of transforming image to data and vice-versa. Moreover, calling the method gets easier to read as data transformation closure is not visible anymore.

extension PersistentDataStore {
func loadImage(forIdentifier identifier: Identifier, completionHandler block: @escaping (UIImage?) -> (Void)) {
loadData(forIdentifier: identifier, dataTransformer: { UIImage(data: $0) }, completionHandler: block)
}
func storeImage(_ image: UIImage, identifier: String = UUID().uuidString, completionHandler handler: @escaping (Result) -> ()) {
storeData({ image.jpegData(compressionQuality: 1.0) }, identifier: identifier, completionHandler: handler)
}
}
// Examples:
persistentStore.storeImage(image) { (result) in
print(result)
}
persistentStore.loadImage(forIdentifier: "my_identifier") { (image) -> (Void) in
guard let image = image else {
print("Failed loading image.")
return
}
print(image)
}

Summary

We created a persistent data store what is performant and has a flexible API. API can be extended easily to support any other data transformation. In addition, it uses thread-safe techniques for making sure data never gets corrupted.

Playground

PersistentDataStore (GitHub) Xcode 10, Swift 4.2

References

DispatchQueues (Apple)
dispatch_barrier_async (Apple)