Turbo Native iOS Offline Mode using SQLITE - Part 1
This tutorial follows on from my last blog post on bringing your turbo-ios app offline . There are various ways to present data offline for your app, but we will stick with Sqlite for this tutorial series. If you are interested in more, let me know.
The first thing we do is get all the boilerplate code set up. Our rails app is going to be a simple To-List app.
Things have changed a lot in Turbo Native iOS, and getting the app set up is so much faster thanks to the work of Joe Masilotti. Note: This code is for demo and brevity. If you are a copy/pasta kinda person, be sure to tweak the AppDatabase methods and look to use something like Combine with MVVM pattern, as it will make life much easier.
Source code is here.
Generating Rails app
The first thing we will do is generate our Rails app, which you can find here.
First, you want to create the app.
rails new turbo_native_offline --database=postgresql
rake db:create
Create a simple ToDo scaffold.
rails g scaffold Todo title:string complete:boolean
Then migrate
rails db:migrate
Finally, we just need to do 3 more changes.
First, we create the following controller in
# app/controllers/turbo/ios/configurations_controller.rb
module Turbo
module Ios
class ConfigurationsController < ApplicationController
def ios_v1
render json: {
settings: {},
rules: [
{
patterns: [
"/new$",
"/edit$"
],
properties: {
context: "modal"
}
}
]
}
end
end
end
end
Our corresponding routes.rb file
Rails.application.routes.draw do
namespace :turbo do
namespace :ios do
resources :configurations, only: [] do
get :ios_v1, on: :collection
end
end
end
resources :todos
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
# Defines the root path route ("/")
root "todos#index"
end
Shoutout to Joe for inspiring this versioning in his workshop.
Finally, we will apply a default style called simplecss.
<!DOCTYPE html>
<html>
<head>
<title>Server</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<link rel="stylesheet" href="https://cdn.simplecss.org/simple.css", date-turbo-track='reload'>
<%= javascript_importmap_tags %>
</head>
<body>
<%= yield %>
</body>
</html>
Now, you can run the server.
rails s
Turbo Swift First Steps
Note that we are using the Turbo Navigator library with Turbo here, but that will be upstreamed to Turbo iOS soon.
First, we create our new iOS app. Open Xcode, Click “Create New Project” > “App”.
For InterFace, Select StoryBoard.
Call it TurboToDo if you lack imagination at the moment.
Next, we need to install our two libraries. Turbo and Turbo Navigator.
Click File > Add Package Dependencies and paste each library’s Github url.
Now, we will setup the app to use Turbo straight away. Replace SceneDelegate with the following:
import Turbo
import TurboNavigator
import UIKit
let baseURL = URL(string: "http://localhost:3000")!
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private lazy var turboNavigator = TurboNavigator(delegate: self, pathConfiguration: pathConfiguration)
private lazy var pathConfiguration = PathConfiguration(sources: [
.server(baseURL.appendingPathComponent("/turbo/ios/configurations/ios_v1.json"))
])
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
self.window = UIWindow(windowScene: windowScene)
self.window?.makeKeyAndVisible()
self.window?.rootViewController = self.turboNavigator.rootViewController
self.turboNavigator.route(baseURL)
}
}
extension SceneDelegate: TurboNavigationDelegate {}
Assuming you are running the Rails server, you should be able to build the app and see that it works.
We have the foundation in place to build upon. Let’s start by adding a native screen.
Native Screen
Now that we have everything let’s get a native screen up and running.
In my previous tutorials, we had to do a lot of work to render a native screen. Thankfully, this is now much easier, thanks to TurboNavigator.
Let’s first create a dummy route that is only available on our Turbo Native app.
In todos/index
:
<% if turbo_native_app? %>
<%= link_to "Native todo", '/native_todos' %>
<% end %>
Note This is just for demo purposes. In a real app, you should use CSS, but we need to take shortcuts for brevity.
Before we go back to our iOS codebase, let’s add the following to the JSON output in app/controllers/turbo/ios/configurations_controller.rb
{
patterns: [
"/native_todos$"
],
properties: {
"view-controller": "todos"
}
...
Note the ViewController property. This bit of code makes it similar to how we render native screens in Android.
Back to our iOS codebase. Let’s create the corresponding View Controller called ToViewController
.
import Foundation
import UIKit
import TurboNavigator
import SwiftUI
class ToDoViewController: UIHostingController<TodoIndex>, PathConfigurationIdentifiable {
static var pathConfigurationIdentifier: String { "todos" }
init() {
super.init(rootView: TodoIndex())
}
@MainActor required dynamic init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
Note the static var pathConfigurationIdentifier: String { "todos" }
.
We now need to create the SwiftUI view called ToDoIndex.
import SwiftUI
struct TodoIndex: View {
var body: some View {
Text("Hello World")
}
}
#Preview {
TodoIndex()
}
Finally, let’s wire up TurboNavigator to react to that route and render a native screen.
// SceneDelagate
extension SceneDelegate: TurboNavigationDelegate {
func handle(proposal: VisitProposal) -> ProposalResult {
if proposal.viewController == ToDoViewController.pathConfigurationIdentifier {
return .acceptCustom(ToDoViewController())
} else {
return .accept
}
}
}
Rerun the app, and clicking on that link will now render a native screen.
It’s incredible how far we have come compared to what I was doing before.
Less code is better code.
Setting Up GRDB
As discussed in the previous post, we will use the GRDB, a swift library for Sqlite, for our local storage solution.
Unlike Rails, we have to setup a lot of conversions ourself, the following code is only going to get you up and running but if you are going to use this in a production app, you will have to make it more robust.
First things first, we have to add GRDB and GRDBQuery to our app.
Next, we setup a AppDatabase file which I nest under Models.
Luckily, this is a well-documented library so that we can browse through the many demo applications as well as the documentation.
Create a file under Models called AppDatabase.
import Foundation
import GRDB
import os.log
struct AppDatabase {
init(_ dbWriter: any DatabaseWriter) throws {
self.dbWriter = dbWriter
try migrator.migrate(dbWriter)
}
private let dbWriter: any DatabaseWriter
}
extension AppDatabase {
private static let sqlLogger = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "SQL")
public static func makeConfiguration(_ base: Configuration = Configuration()) -> Configuration {
var config = base
if ProcessInfo.processInfo.environment["SQL_TRACE"] != nil {
config.prepareDatabase { db in
db.trace {
os_log("%{public}@", log: sqlLogger, type: .debug, String(describing: $0))
}
}
}
#if DEBUG
config.publicStatementArguments = true
#endif
return config
}
}
// MARK: - Database Migrations
extension AppDatabase {
private var migrator: DatabaseMigrator {
var migrator = DatabaseMigrator()
#if DEBUG
migrator.eraseDatabaseOnSchemaChange = true
#endif
migrator.registerMigration("createToDo") { db in
try db.create(table: "todo") { t in
t.autoIncrementedPrimaryKey("id")
t.column("title", .text).notNull()
t.column("complete", .boolean)
t.column("createdAt", .datetime)
t.column("updatedAt", .datetime)
}
}
return migrator
}
}
extension AppDatabase {
/// A validation error that prevents some players from being saved into
/// the database.
enum ValidationError: LocalizedError {
case missingName
var errorDescription: String? {
switch self {
case .missingName:
return "Please provide a title."
}
}
}
}
extension AppDatabase {
var reader: DatabaseReader {
dbWriter
}
}
Next, we need a singleton connection to the database. Create a file called Persistance.
import Foundation
import GRDB
extension AppDatabase {
static let shared = makeShared()
private static func makeShared() -> AppDatabase {
do {
let fileManager = FileManager.default
let appSupportURL = try fileManager.url(
for: .applicationSupportDirectory, in .userDomainMask,
appropriateFor: nil, create: true)
let directoryURL = appSupportURL.appendingPathComponent("Database", isDirectory: true)
if CommandLine.arguments.contains("-reset") {
try? fileManager.removeItem(at: directoryURL)
}
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
let databaseURL = directoryURL.appendingPathComponent("db.sqlite")
NSLog("Database stored at \(databaseURL.path)")
let dbPool = try DatabasePool(
path: databaseURL.path,
// Use default AppDatabase configuration
configuration: AppDatabase.makeConfiguration())
let appDatabase = try AppDatabase(dbPool)
return appDatabase
} catch {
fatalError("Unresolved error \(error)")
}
}
static func empty() -> AppDatabase {
let dbQueue = try! DatabaseQueue(configuration: AppDatabase.makeConfiguration())
return try! AppDatabase(dbQueue)
}
static func random() -> AppDatabase {
let appDatabase = empty()
return appDatabase
}
}
Phew, boilerplate done, let’s start adding and reading ToDo’s from our database.
Our ToDo CRUD actions
With everything ready, we can start creating the CRUD interface. We need to create a model that conforms to the protocols provided by GRDB. Once we create a model, we can modify our ToDoIndexView to have the CRUD actions of Create, Read, Update and Destroy.
First, create a file called Todo.swift
import Foundation
import GRDB
struct Todo: Identifiable, Codable, TableRecord, PersistableRecord, FetchableRecord {
var id: Int64?
var title: String
var complete: Bool
var createdAt: Date
var updatedAt: Date
}
extension Todo: MutablePersistableRecord {
mutating func didInsert(_ inserted: InsertionSuccess) {
id = inserted.rowID
}
}
Create and Read
Let’s modify our AppDataBase
file to have both a create and read method.
extension AppDatabase {
func createRandomToDo() throws {
let todo = Todo(id: nil, title: UUID().uuidString, complete: false, createdAt: Date(), updatedAt: Date())
try dbWriter.write { db in
do {
try todo.save(db)
} catch {
print(error.localizedDescription)
}
}
}
func getToDos() throws -> [Todo] {
var todos = [Todo]()
try dbWriter.write { db in
do {
todos = try Todo.fetchAll(db)
} catch {
print(error.localizedDescription)
}
}
return todos
}
// more to come
}
Now, back to our ToDoIndexView
, let’s add the following:
struct TodoIndex: View {
var appDatabase: AppDatabase = AppDatabase.shared
@State private var todos: [Todo] = []
var body: some View {
List {
ForEach(todos, id: \.id) { todo in
Text("ID: \(todo.id ?? 0) \(todo.title)")
}
}
.onAppear {
getToDos()
}
.toolbar(id: "Actions") {
ToolbarItem(id: "Add", placement: .primaryAction) {
Button {
createRandomToDo()
} label: {
Image(systemName: "plus")
}
}
}
}
func createRandomToDo() {
do {
try appDatabase.createRandomToDo()
} catch {
print(error.localizedDescription)
}
getToDos()
}
func getToDos() {
do {
todos = try appDatabase.getToDos()
} catch {
print(error.localizedDescription)
}
}
}
#Preview {
TodoIndex()
}
You should now be able to build and run the code. If you press the “plus” icon, a new ToDo is added, which has a random UUID.
You’ll note that we call getToDos()
after each create action.
Note: This is for brevity. In a production app, you’ll want to use combine or some sort of database observer to update the views.
Delete
We need to add the following to our AppDatabase file for our delete action.
func deleteToDo(_ todo: Todo) throws {
try dbWriter.write { db in
_ = try Todo.deleteAll(db, ids: [todo.id])
}
}
In our view, we add onDelete to the ForEach loop followed by a delete method.
List {
ForEach(todos, id: \.id) { todo in
Text("ID: \(todo.id ?? 0) \(todo.title)")
}
.onDelete(perform: deleteToDo)
}
Our delete method is as follows:
func deleteToDo(at offsets: IndexSet) {
for offset in offsets {
let todo = todos[offset]
do {
try appDatabase.deleteToDo(todo)
} catch {
print(error.localizedDescription)
}
}
getToDos()
}
Rebuild the app, and you’ll see that you can swipe to delete.
If you look at the console in Xcode, it should give you the file path for Sqlite, you can open that using a tool like TablePlus.
Create ToDo
We will now create an actual Todo instead of a random one.
First things first, we create a new SwiftUI View.
File > New File > SwiftUI
Call it NewToDoView
import SwiftUI
struct NewTodoView: View {
var appDatabase: AppDatabase = AppDatabase.shared
@Binding var isPresented: Bool
@State private var title = ""
var body: some View {
Form {
Section("Create ToDo") {
TextField("Title", text: $title)
}
Section {
Button {
Task {
let todo = Todo(title: title, complete: false, createdAt: Date(), updatedAt: Date())
do {
try appDatabase.createToDo(todo)
} catch {
print(error.localizedDescription)
}
isPresented = false
}
} label: {
HStack {
Spacer()
Text("Save")
Spacer()
}
}
}
}
}
}
#Preview {
NewTodoView(isPresented: .constant(false))
}
You must add a @State
wrapper property to TodoIndexView
.
struct TodoIndex: View {
@State private var showingNewSheet = false
.....
Then, display the view you created whenever the user presses the “plus” button.
.toolbar(id: "Actions") {
ToolbarItem(id: "Add", placement: .primaryAction) {
Button {
showingNewSheet.toggle()
} label: {
Label("Add", systemImage: "plus")
}
}
}
.sheet(isPresented: $showingNewSheet, onDismiss: getToDos, content: {
NewTodoView(isPresented: $showingNewSheet)
})
Finally, we just add our create a method to the appDatabase.
func createToDo(_ todo: Todo) throws {
try dbWriter.write { db in
do {
try todo.save(db)
} catch {
print(error.localizedDescription)
}
}
}
Update ToDo
To complete our CRUD actions, we just need to be able to update our ToDo
First, we edit our TodoIndexView
by adding two new @State variables.
@State private var showingEditSheet = false
@State private var selectedTodo: Todo?
Next, we want to trigger a edit action when we swipe on a todo.
ForEach(todos, id: \.id) { todo in
Text("ID: \(todo.id ?? 0) \(todo.title)")
.swipeActions {
Button {
selectedTodo = todo
showingEditSheet.toggle()
} label: {
Label("Edit", systemImage: "pencil")
}
....
Finally, we display our edit sheet.
.sheet(isPresented: $showingEditSheet, onDismiss: getToDos, content: {
if let todo = selectedTodo {
EditTodoView(isPresented: $showingEditSheet, todo: todo)
}
})
Then we create our edit sheet.
import SwiftUI
struct EditTodoView: View {
var appDatabase: AppDatabase = AppDatabase.shared
@Binding var isPresented: Bool
@State var todo: Todo
var body: some View {
Form {
Section("Create ToDo") {
TextField("Title", text: $todo.title)
}
Section {
Button {
Task {
do {
try appDatabase.updateToDo(&todo)
} catch {
print(error.localizedDescription)
}
isPresented = false
}
} label: {
HStack {
Spacer()
Text("Save")
Spacer()
}
}
}
}
}
}
#Preview {
EditTodoView(isPresented: .constant(false), todo: Todo(title: "", complete: false, createdAt: Date(), updatedAt: Date()))
}
Finally, we add our update method to our Appdatabase
func updateToDo(_ todo: inout Todo) throws {
todo.updatedAt = Date()
try dbWriter.write { db in
do {
try todo.save(db)
} catch {
print(error.localizedDescription)
}
}
}
If you rerun the app, you can edit todo items now.
Reading ToDos Offline,
Now that we know we can create ToDos that use the local Database, we should render the ToDo list in case we go offline.
In SceneDelegate
,
extension SceneDelegate: TurboNavigationDelegate {
func handle(proposal: VisitProposal) -> ProposalResult {
....
func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) {
if (error as NSError).code == -1004 || (error as NSError).code == -1009 {
turboNavigator.navigationController.pushViewController(ToDoViewController(), animated: true)
}else if let errorPresenter = visitable as? ErrorPresenter {
errorPresenter.presentError(error) {
retry()
}
}
}
}
The error code comes from here. This will push the ToDoViewController. You could also set the root view to the ToDoViewController.
self.window?.rootViewController = ToDoViewController()
However, users may have to force quit your app when they return online. There are many options, and to create the best experience when someone goes offline or online will depend on your specific circumstances.
We also have lots of edge cases.
Up Next
In this article, we built some offline CRUD functionality. I hope you can now see how powerful Turbo Native iOS can be when you hand-off critical parts to the local native app. In the following article, we’ll discuss possible data-syncing strategies. Of course, each app is different, but for this example, we will assume that all local todos get published to the web.
Shout Out
Big shout to Joe Masilotti and the folks at 37 Signals for the great work on the libraries and to Gwendal Roué.