iOS — Persist Data using SQLite.swift Library with SwiftUI example

Learn how to use SQLite database with SwiftUI and perform CRUD operations
Aug 1 2022 · 10 min read

Background

May you all be aware of the concept of data persistence and offline access to data with the database?

In software development, we often need to persist app data with some data structure. But how do we store that data efficiently?

There’s an evergreen database designed exactly for that purpose — SQLite. It is available by default on iOS.

In fact, if you’ve used Core Data before, you’ve already used SQLite. Core Data is just a layer on top of SQLite that provides a more convenient API.

In this article, we are going to use SQLite for storing the data structure, and for that, we’ll use a very popular Library Sqlite.swift.

Today’s goal

We will create a new app with a home screen that contains a list view. We will show our data from the database there.

Ultimately, we will develop a basic ToDo app with SwiftUI.

Note: We will use the UIPilot library for navigation but feel free to skip it if you don’t need it.

Our final app, at the end of this article, will look something like this.

I have divided the whole implementation into 5 parts to make understanding clear. Feel free to jump around as you like.

  1. Basic Setup
  2. Create a Database and Table
  3. Perform Insert and Read
  4. Perform Updates and Find
  5. Perform Delete

Basic Setup

Let’s first add a Task data class with all the properties we need for a basic ToDo App implementation. I’ve added the following fields,

struct Task {
   let id: Int64
   var name: String
   var date: Date
   var status: Bool
}

We will have our main app file as below. Mainly we will have 3 screens, the first for showing all the task lists, the second for inserting a task, and the last one for showing task details and updating from there.

To begin with UI, you can start by adding each screen one by one, no need to add all the screens now though, just configure the List screen for now.

import UIPilot
import SwiftUI

@main
struct SQLiteExampleApp: App {

    private let pilot: UIPilot<AppRoute>

    init() {
        pilot = .init(initial: .List)
    }

    var body: some Scene {
        WindowGroup {
            UIPilotHost(pilot) { route in
                switch route {
                case .List:
                    return AnyView(
                        TaskListView(viewModel: TaskListViewModel(pilot: pilot))
                    )
                case .Insert:
                    return AnyView(
                        TaskInsertView(viewModel: TaskInsertViewModel(pilot: pilot))
                    )
                case .Detail(let id):
                    return AnyView(
                        TaskDetailView(viewModel: TaskDetailViewModel(id: id, pilot: pilot))
                    )
                }
            }
        }
    }
}


enum AppRoute: Equatable {
    case List
    case Insert
    case Detail(id: Int64)
}

Now, let’s design the home screen, which will have a list view, in which we will show the tasks list.

And also let’s add viewModel for that screen. Initially, we will have ViewModel for the list screen only with the following properties and methods.`

class TaskListViewModel: ObservableObject {

    @Published var allTask: [Task] = []
    let appPilot: UIPilot<AppRoute>

    init(pilot: UIPilot<AppRoute>) {
        self.appPilot = pilot
    }
    
    func onAddButtonClick() {
        appPilot.push(.Insert)
    }
}

And here is our main list view screen design.

struct TaskListView: View {

    @ObservedObject var viewModel: TaskListViewModel

    var body: some View {
        ZStack {
            VStack(spacing: 30) {
                Text("Welcome !!!")
                    .font(.title.bold())

                if viewModel.allTask.isEmpty {
                    Text("You have no task.")
                } else {
                    List {
                        ForEach(viewModel.allTask, id: \.id) { task in
                            Text(task.name)
                        }
                    }
                    .listStyle(.plain)
                    .onAppear {
                        UITableView.appearance().backgroundColor = .clear
                        UITableViewCell.appearance().selectionStyle = .none
                        UITableView.appearance().showsVerticalScrollIndicator = false
                    }
                }
            }

            VStack {
                Spacer()
                HStack {
                    Spacer()
                    Button(action: {
                        viewModel.onAddButtonClick()
                    }) {
                        Image(systemName: "plus")
                            .resizable()
                            .scaledToFill()
                            .frame(width: 25, height: 25)
                            .foregroundColor(.white)
                            .padding(20)
                    }
                    .background(.blue)
                    .cornerRadius(.infinity)
                    .padding()
                }
            }
        }
        .navigationBarHidden(true)
    }
}

With the click of the bottom add button, we will open a new screen to show basic details about the task.

You can also add a temporary new view that is opening using the pilot enum .Insert just for running the app without any error.

Create a Database and Table

Before starting the implementation of the Insert screen, we will first need the database and table so that we can save added tasks.

Let’s add the SQLite library to the project. You can add it using SPM or cocoapod, but here we are going to add it with SPM.
 

Creating the database

Let’s add a class that encapsulates the connection with SQLite.

class TaskDataStore {

    static let DIR_TASK_DB = "TaskDB"
    static let STORE_NAME = "task.sqlite3"

    private let tasks = Table("tasks")

    private let id = Expression<Int64>("id")
    private let taskName = Expression<String>("taskName")
    private let date = Expression<Date>("date")
    private let status = Expression<Bool>("status")

    static let shared = TaskDataStore()

    private var db: Connection? = nil

    private init() {
        if let docDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
            let dirPath = docDir.appendingPathComponent(Self.DIR_TASK_DB)

            do {
                try FileManager.default.createDirectory(atPath: dirPath.path, withIntermediateDirectories: true, attributes: nil)
                let dbPath = dirPath.appendingPathComponent(Self.STORE_NAME).path
                db = try Connection(dbPath)
                createTable()
                print("SQLiteDataStore init successfully at: \(dbPath) ")
            } catch {
                db = nil
                print("SQLiteDataStore init error: \(error)")
            }
        } else {
            db = nil
        }
    }

    private func createTable() {
        guard let database = db else {
            return
        }
        do {
            try database.run(tasks.create { table in
                table.column(id, primaryKey: .autoincrement)
                table.column(taskName)
                table.column(date)
                table.column(status)
            })
            print("Table Created...")
        } catch {
            print(error)
        }
    }
}

Here,

1. We first create the database connection.

For that, we have to provide the path of the file manager so that the system can create an app directory at the given path and then add the database file to that created directory. This is just for the demo, feel free to change the database location as per your needs.

2. After generating a successful connection, we created a table for storing task data. We have declared all the needed properties and methods for creating a database and table in the same class as you might have noticed. We will see more details about creating a table below.

3. We have also declared a singleton object for this class to save some resources as we don’t need multiple connections with the database.

Creating table

As you might have noticed, we have used database.run function to create the table. It can be used to run any type of query with the table.

If you want to make any column a Primary Key, then specify that constraint after the column name.

Use .autoincrement to increment any id-related column automatically. Here, we have used it for the primary key column.

try database.run(tasks.create { table in
    table.column(id, primaryKey: .autoincrement)
    table.column(taskName)
    table.column(date)
    table.column(status)
})

If you have multiple tables and want to set a relationship between them then SQLite also allows us to set a foreign key for a particular column. See the example below for foreign key usage.

table.foreignKey(id, references: anotherTale, otherTable.id, update: .cascade, delete: .cascade)

That’s it.

Run the app, look at the Xcode console, and see the log that we have added with database creation. If the database is created successfully then it’ll show the database directory path and you can check out the app directory at that location.

Perform Insert and Read

Insert Task

Now we will design the next screen using that we will save a new task in the database.

I think you have already added an empty TaskInsertView, let’s add a complete design in that view.

Before that, let’s first add its ViewModel so that we can bind all the added data to the ViewModel properties.

class TaskInsertViewModel: ObservableObject {

    @Published var taskName: String = ""
    @Published var startDate: Date = Date()

    private let appPilot: UIPilot<AppRoute>

    init(pilot: UIPilot<AppRoute>) {
        self.appPilot = pilot
    }

    func onAddButtonClick() {
        
    }
}

And here’s the implementation of TaskInsertView .

struct TaskInsertView: View {

    @ObservedObject var viewModel: TaskInsertViewModel

    var body: some View {
        VStack {
            HStack(spacing: 20) {
                Text("Task Name : ")
                TextField("Task Name", text: $viewModel.taskName)
                    .textFieldStyle(.roundedBorder)
            }
            .padding()

            HStack(spacing: 20) {
                Text("Start Date  :")
                DatePicker("", selection: $viewModel.startDate)
                Spacer()
            }
            .padding()

            HStack {
                Button(action: {
                    viewModel.onAddButtonClick()
                }) {
                    Text("Add")
                        .foregroundColor(.white)
                        .font(.headline)
                        .padding(.vertical, 10)
                        .padding(.horizontal, 30)
                }
                .background(.blue)
                .cornerRadius(.infinity)
                .padding()
            }
            Spacer()
        }
    }
}

If you run the app now, it will show the task add screen.

Now, let’s add a database method to insert new data to persist the added task.

func insert(name: String, date: Date) -> Int64? {
    guard let database = db else { return nil }

    let insert = tasks.insert(self.taskName <- name,
                              self.date <- date,
                              self.status <- false)
    do {
        let rowID = try database.run(insert)
        return rowID
    } catch {
        print(error)
        return nil
    }
}

Now we can use this method whenever we want to add any task from TaskInsertView screen (OR any other UI).

Now we have to call this method when we click on the add button.

Let’s add the given change in the TaskInsertViewModel method named onAddButtonClick().

let id = TaskDataStore.shared.insert(name: taskName, date: startDate)
if id != nil {
   appPilot.pop()
}

That’s it! You can run the App.

You will notice that nothing is changing in the view after adding a new task, but no need to worry if the view is dismissing which means our task is being added successfully.

We haven’t done anything so that the added task can be shown on the main list screen. Let’s complete it.

Get all Tasks

Let’s add the following method in the data store class to get all tasks.

func getAllTasks() -> [Task] {
    var tasks: [Task] = []
    guard let database = db else { return [] }

    do {
        for task in try database.prepare(self.tasks) {
            tasks.append(Task(id: task[id], name: task[taskName], date: task[date], status: task[status]))
        }
    } catch {
        print(error)
    }
    return tasks
}

Now let’s add the below method to the TaskListViewModel.

func getTaskList() {
    allTask = TaskDataStore.shared.getAllTasks()
}

And call this function in the TaskListView as we have to refresh data with the up-to-date table whenever view will appear.

.onAppear {
    viewModel.getTaskList()
}

Now just run the app you will see all the added tasks on the main list view screen.

Perform an Update and Find

Now next step is to show the full details of a particular task.

On the click of any task, we will open another detail view and will show the detail of that selected task, for that first we will have to design that detail view.

Let’s first add its ViewModel.

class TaskDetailViewModel: ObservableObject {

    var id: Int64
    var task: Task?

    @Published var taskName: String = ""
    @Published var approxDate: Date = Date()
    @Published var status: String = "Incomplete"

    private let appPilot: UIPilot<AppRoute>

    init(id: Int64, pilot: UIPilot<AppRoute>) {
        self.id = id
        self.appPilot = pilot
        getTask()
    }

    func getTask() {
        
    }
    
    func onUpdateClick() {
    
    }
}

Let’s add the task detail view which will also have an option for updating the detail of the task.

struct TaskDetailView: View {

    @ObservedObject var viewModel: TaskDetailViewModel
    var status = ["Completed", "Incomplete"]

    var body: some View {
        VStack(spacing: 20) {
            HStack(spacing: 20) {
                Text("Task Name : ")
                TextField("Task Name", text: $viewModel.taskName)
                    .textFieldStyle(.roundedBorder)
            }

            HStack(spacing: 20) {
                Text("Approx Date :")
                DatePicker("", selection: $viewModel.approxDate)
                Spacer()
            }

            HStack {
                Text("Status        :   ")
                Picker("What is your favorite color?", selection: $viewModel.status) {
                    ForEach(status, id: \.self) {
                        Text($0)
                    }
                }
                .pickerStyle(.segmented)
                Spacer()
            }

            HStack {
                Button(action: {
                    viewModel.onUpdateClick()
                }) {
                    Text("Update")
                        .foregroundColor(.white)
                        .font(.headline)
                        .padding(.vertical, 10)
                        .padding(.horizontal, 30)
                }
                .background(.blue)
                .cornerRadius(.infinity)
                .padding()
            }
            Spacer()
        }
        .padding(.horizontal)
    }
}

Get details of the selected task

Let’s add a new method in the data store class to find the details of a particular task.

func findTask(taskId: Int64) -> Task? {
    var task: Task = Task(id: taskId, name: "", date: Date(), status: false)
    guard let database = db else { return nil }

    let filter = self.tasks.filter(id == taskId)
    do {
        for t in try database.prepare(filter) {
            task.name = t[taskName]
            task.date = t[date]
            task.status = t[status]
        }
    } catch {
        print(error)
    }
    return task
}

Now, let’s call this method in the TaskDetailViewModel to get and show details of the selected task.

Let’s update the getTask() method as given below.

func getTask() {
    task = TaskDataStore.shared.findTask(taskId: id)
    taskName = task?.name ?? ""
    approxDate = task?.date ?? Date()
    status = task!.status ? "Completed" : "Incomplete"
}

Let’s run the app. Now we can see the added details in the given fields.

Update task detail

Let’s add a new method in the data store class to Update the details of a particular task.

func update(id: Int64, name: String, date: Date = Date(), status: Bool = false) -> Bool {
    guard let database = db else { return false }

    let task = tasks.filter(self.id == id)
    do {
        let update = task.update([
            taskName <- name,
            self.date <- date,
            self.status <- status
        ])
        if try database.run(update) > 0 {
            return true
        }
    } catch {
        print(error)
    }
    return false
}

Now, let’s call this method in the TaskDetailViewModel to update the details of the given task.

Let’s update the onUpdateClick() method as given below.

func onUpdateClick() {
    let statusUpdated = TaskDataStore.shared.update(id: id, name: taskName, date: approxDate, status: status == "Completed")
    if statusUpdated {
        appPilot.pop()
    }
}

Let’s run the app. If the details are updated then you can see the new change on the list screen.

Perform Delete

Now the last remaining thing from the CRUD is to delete.

If we want to delete any task then we have to delete it from the database table as well.

Let’s add a new method in the data store class to Delete a selected task.

func delete(id: Int64) -> Bool {
    guard let database = db else {
        return false
    }
    do {
        let filter = tasks.filter(self.id == id)
        try database.run(filter.delete())
        return true
    } catch {
        print(error)
        return false
    }
}

Let’s add the following method to the TaskListViewModel.

func deleteTask(at indexSet: IndexSet) {
    let id = indexSet.map { self.allTask[$0].id }.first
    if let id = id {
        let delete = TaskDataStore.shared.delete(id: id)
        if delete {
            getTaskList()
        }
    }
}

But to do this, we have to make our list view editable to use its default delete method functionality.

Let’s add the required changes in the TaskListView.

We have to change the code of ForEach block as given below.

ForEach(viewModel.allTask, id: \.id) { task in
    Text(task.name)
        .onTapGesture {
            viewModel.appPilot.push(.Detail(id: task.id))
        }
    }
    .onDelete(perform: viewModel.deleteTask(at:))

Let’s run the app and just swipe the table row from right to left and you will see the delete option and on the click of that button, the row will be deleted.

Everything is done.

You can get the final project from the given GitHub repository.

Conclusion

Well, that’s it for today, hope you learned something new!

We explored the very basics of SQLite today, there’s only so much you can do with it but this will get you started and will guide you on how to find what you are looking for.

As always, feedback and suggestions are very much appreciated! Please leave them in the comment section below.


Code, Build, Repeat.
Stay updated with the latest trends and tutorials in Android, iOS, and web development.
amisha-i image
Amisha Italiya
iOS developer @canopas | Sharing knowledge of iOS development
amisha-i image
Amisha Italiya
iOS developer @canopas | Sharing knowledge of iOS development
canopas-logo
We build products that customers can't help but love!
Get in touch
background-image

Get started today

Let's build the next
big thing!

Let's improve your business's digital strategy and implement robust mobile apps to achieve your business objectives. Schedule Your Free Consultation Now.

Get Free Consultation
footer
Follow us on
2024 Canopas Software LLP. All rights reserved.