Using Swift data for data persistence in iOS 17 UIKit
In WWDC 2023 Apple introduced a new way to add data persistence to iOS apps using Swift data. Swift data makes it easier to add data persistence to application through declarative code and it is much simpler than CoreData. It works great with SwiftUI it can also be used with UIKit.
Requirements
- Xcode 15 Beta with iOS 17 SDK
Note: At the time of writing this blog swift data is still in beta and supports iOS 17+ apps
We are going to build a simple To-do list app which has data persistence using Swift data and perform CRUD operation on the data.
Defining our Schema
We can define our schema using Model
macro. This macro converts a Swift class into a stored model that’s managed by SwiftData. It is simple as adding the Model macro to existing Swift data model and the schema is generated. Create a new swift file and add the following code.
import Foundation
import SwiftData
@Model
class TodoModel{
@Attribute(.unique) var id: String
var taskname: String
var time: Double
init(id: String, taskname: String,time: Double) {
self.id = id
self.taskname = taskname
self.time = time
}
}
Here we create a data model for our Todo app. Each task will have a task name, time stamp(to sort the task based on time they are added) and an unique identifier which is a string. Adding Model
macro to Swift class will transform the stored properties of the class to persisted properties. It can simple swift types like Int, String etc. And also complex types like Codable, Struct,Enums and collection types. We can influence how Swift data build your schema using meta data property. Here we have used Attribute
meta-data with unique to define that each task will have a unique string value.
Model Container and Model Context
Create a new Swift file and add the following code.
import Foundation
import SwiftData
class DatabaseService{
static var shared = DatabaseService()
var container: ModelContainer?
var context: ModelContext?
init(){
do{
container = try ModelContainer(for: [TodoModel.self])
if let container{
context = ModelContext(container)
}
}
catch{
print(error)
}
}
}
Here we create a singleton class named DatabaseService which will have methods to perform CRUD operations. First we create a static instance for the class. Then we create two properties of type ModelContainer
and ModelContext
respecitively. The model container is used to manage the app schema and app storage configuration. The ModelContext
enables you to fetch, insert, and delete models, and save any changes to disk.
Saving Data
To save a data in Swift data add the following code inside DatabaseService
class.
func saveTask(taskName: String?){
guard let taskName else{return }
if let context{
let taskToBeSaved = TodoModel(id: UUID().uuidString, taskname: taskName, time: Date().timeIntervalSince1970)
context.insert(object: taskToBeSaved)
}
}
Here the function takes a String parameter which is the name of the task to be saved. Then we use it to create an object for our data model/schema with an unique id string generated using UUID and a UNIX time stamp. Finally we save the data using insert()
method of the ModelContext object.
Reading Data
Inside DatabaseService class add the following method.
func fetchTasks(onCompletion:@escaping([TodoModel]?,Error?)->(Void)){
let descriptor = FetchDescriptor<TodoModel>(sortBy: [SortDescriptor<TodoModel>(\.time)])
if let context{
do{
let data = try context.fetch(descriptor)
onCompletion(data,nil)
}
catch{
onCompletion(nil,error)
}
}
}
Here we read the data by creating an object of type FetchDescriptor
which can be seen as a Swift friendly version of NSFetchDescriptor
using in CoreData. This describes the criteria, sort order, and any additional configuration to use when performing a fetch operation. It takes an array of type SortDescriptor
as a parameter which is a serializable description of how to sort numerics and strings. In our case we need to fetch our data in sorted in ascending order based on UINIX time stamp. Then we perform the read operation using fetch()
method of the ModelContext
this method can throw an exception so we surround them in do-try-catch block.
Update Data
To perform update operation add the following code in DatabaseService class
func updateTask(task: TodoModel,newTaskName: String){
let taskToBeUpdated = task
taskToBeUpdated.taskname = newTaskName
}
Here we just take the Task object which is to be updated with a new task name and modify the task name thats all. Swift data will automatically update the underlying data.
Delete Data
To perform delete operation add the following code in Database service class.
func deleteTask(task: TodoModel){
let taskToBeDeleted = task
if let context{
context.delete(taskToBeDeleted)
}
}
Here we just call delete()
method of ModelContext
and pass the task to be deleted, which will automatically delete the data from the persistence storage.
Creating App UI
In Main.story board create an UI similar to the one shown below.
In ViewController.swift file create the IBOutlets and implement the delegate and datasource methods for the tableview. Then add a right bar button item to the navigation bar and a selector method to which calls the insert method of the database service.
func configureUI(){
title = "Swift Data Demo"
navigationController?.navigationBar.prefersLargeTitles = true
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Add", style: .plain, target: self, action: #selector(addItem))
}
@objc func addItem(){
let avc = UIAlertController(title: "Info", message: "Add new item", preferredStyle: .alert)
avc.addTextField()
avc.addAction(UIAlertAction(title: "Ok", style: .default, handler: { action in
if let taskName = avc.textFields?.first?.text{
DatabaseService.shared.saveTask(taskName: taskName)
self.fetchData()
}
}))
self.present(avc, animated: true, completion: nil)
}
Then we create another method to fetch the data from database serviceand reload the table. We then call that method inside viewDidLoad()
as our app has only single screen.
func fetchData(){
DatabaseService.shared.fetchTasks { data , error in
if let error{
print(error)
}
if let data{
self.tasks = data
DispatchQueue.main.async {
self.taskTableView.reloadData()
}
}
}
}
Then we define swipe action for the tableview rows using tableView(_:editActionsForRowAt:)
method.
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
let delete = UITableViewRowAction(style: .default, title: "Delete") { _, indexpath in
DatabaseService.shared.deleteTask(task: self.tasks[indexpath.row])
self.fetchData()
}
let update = UITableViewRowAction(style: .default, title: "Update") { _, indexpath in
let avc = UIAlertController(title: "Info", message: "Add new item", preferredStyle: .alert)
avc.addTextField()
avc.addAction(UIAlertAction(title: "Ok", style: .default, handler: { action in
if let textfield = avc.textFields?.first?.text{
DatabaseService.shared.updateTask(task: self.tasks[indexpath.row], newTaskName: textfield)
self.fetchData()
}
}))
self.present(avc, animated: true, completion: nil)
}
update.backgroundColor = UIColor.green
return [delete,update]
}
Here we create two swipe actions with their respective actions of Delete and Update. After Updating and Deleting the data from datasource we fetch the updated data and again reload the tableview.
Here is the entire ViewController.swift code
import UIKit
import SwiftData
class ViewController: UIViewController {
@IBOutlet weak var taskTableView: UITableView!
var container: ModelContainer?
var context: ModelContext?
var tasks = [TodoModel]()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
taskTableView.delegate = self
taskTableView.dataSource = self
taskTableView.register(UITableViewCell.self,forCellReuseIdentifier: "CELL")
configureUI()
self.fetchData()
}
func configureUI(){
title = "Swift Data Demo"
navigationController?.navigationBar.prefersLargeTitles = true
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Add", style: .plain, target: self, action: #selector(addItem))
}
func fetchData(){
DatabaseService.shared.fetchTasks { data , error in
if let error{
print(error)
}
if let data{
self.tasks = data
DispatchQueue.main.async {
self.taskTableView.reloadData()
}
}
}
}
@objc func addItem(){
let avc = UIAlertController(title: "Info", message: "Add new item", preferredStyle: .alert)
avc.addTextField()
avc.addAction(UIAlertAction(title: "Ok", style: .default, handler: { action in
if let taskName = avc.textFields?.first?.text{
DatabaseService.shared.saveTask(taskName: taskName)
self.fetchData()
}
}))
self.present(avc, animated: true, completion: nil)
}
}
extension ViewController: UITableViewDelegate, UITableViewDataSource{
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.tasks.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "CELL", for: indexPath)
cell.textLabel?.text = "\(self.tasks[indexPath.row].taskname)"
return cell
}
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
let delete = UITableViewRowAction(style: .default, title: "Delete") { _, indexpath in
DatabaseService.shared.deleteTask(task: self.tasks[indexpath.row])
self.fetchData()
}
let update = UITableViewRowAction(style: .default, title: "Update") { _, indexpath in
let avc = UIAlertController(title: "Info", message: "Add new item", preferredStyle: .alert)
avc.addTextField()
avc.addAction(UIAlertAction(title: "Ok", style: .default, handler: { action in
if let textfield = avc.textFields?.first?.text{
DatabaseService.shared.updateTask(task: self.tasks[indexpath.row], newTaskName: textfield)
self.fetchData()
}
}))
self.present(avc, animated: true, completion: nil)
}
update.backgroundColor = UIColor.green
return [delete,update]
}
}
The project repo can be found here