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.
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.
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.
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.
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.
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.
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.
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.
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 ConsultationGet 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