Video #303: SQLite: SwiftUI
Episode: Video #303 Date: Nov 18, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep303-sqlite-swiftui

Description
Let’s see how to integrate a SQLite database into a SwiftUI view. We will explore the tools GRDB provides to query the database so that we can display its data in our UI, as well as build and enforce table relations to protect the integrity of our app’s state. And we will show how everything can be exercised in Xcode previews.
Video
Cloudflare Stream video ID: a37ca51820a6350d2eaa9ec38972d608 Local file: video_303_sqlite-swiftui.mp4 *(download with --video 303)*
References
- Discussions
- Issue Reporting
- documentation of GRDB
- SQLite
- GRDB
- 0303-sqlite-pt3
- Brandon Williams
- Stephen Celis
- Mastodon
- GitHub
- CC BY-NC-SA 4.0
- source code
- MIT License
Transcript
— 0:15
We have now nearly recreated all the functionality we had when interacting directly with the SQLite C library, but this time using GRDB. We can create database connections, create tables, and insert data into the tables. And each step of the way we saw how GRDB provided a nice interface to this functionality, and allowed us to use many of Swift’s powerful features, such as value types and Swift’s Encodable protocol for automatically inserting a first class Swift data type into the database. Stephen
— 0:30
The only functionality that we haven’t recreated yet is querying the database to get players. This was quite cumbersome when calling the C library directly, so let’s see what GRDB gives us for this, and along the way we will see what it takes to start showing data from our database in a SwiftUI view. Querying the database
— 0:47
In order to explore making a query to fetch players from the database we are going to start putting something on the screen. Let’s use the ContentView that is already created for us in the project to show a list of all players in the database. We can start by adding some local @State to the view for holding onto the players: @State var players: [Player] = []
— 1:05
And we will update the body of the view to show the names of these players in a form: Form { ForEach(players, id: \.id) { player in Text(player.name) } }
— 1:11
We’d like to load all the players from the database once this view appears, and so we will use an onAppear view modifier: .onAppear { }
— 1:18
You can also use the .task modifier if you want to perform the query in an async fashion, but we will not be doing that.
— 1:25
In the onAppear we want to query the database to fetch all of the users, and then update the players state in the view. But how do we do that? Over in the entry point of the app, all interactions with the database went through the DatabaseQueue object that we created. But we don’t have access to that now.
— 1:41
We certainly could create a whole new database connection and store it in the view: @State var databaseQueue = DatabaseQueue(…)
— 1:45
That would work just fine, and SQLite was built to handle multiple connections to a single database stored on disk. However, this may not be the most efficient approach. It seems like overkill if literally every single view in your app that needs data from the database maintains its own connection to the database. And what if you have a list of rows, and each row has some database logic, such as updating a row in a table. Should you really have hundreds or thousands of connections to the database, one for each row?
— 2:14
And so it would be a lot better to share one single connection with the entire app. And if you are worried about having a single database connection service an entire app, then you can also upgrade your queue to be a database pool. That allows you to have multiple concurrent reads from the database, while still only allow one write at a time.
— 2:29
So, rather than creating a new database queue in our view, let’s force it to be passed to us from whoever constructs the view: struct ContentView: View { let databaseQueue: DatabaseQueue … }
— 2:40
This breaks the preview, but we can fix it by providing a database queue that operates in-memory: #Preview { ContentView(databaseQueue: try! DatabaseQueue()) }
— 2:53
When you create a database connection without a path it creates a brand new database that is entirely stored in memory. This means each time you restart the app, or re-run the preview, the previous database is completely scrapped and a whole new one is created. That may sound bad, but that is exactly what we want from previews, and even tests.
— 3:06
However, this does demonstrate to us more concretely why we would want to share a single connection with all of our views instead of instead of creating a fresh connection for each view. If we were to have each view maintain its own connection, then it would not be possible to have multiple views share the same database in previews. Each view would create its own in-memory database that are completely distinct from each other. And that means the previews would not represent how your app behaves when run on a device, and so not very useful.
— 3:31
We still have one more compiler error back in the entry point of the app where we are creating the ContentView : ContentView(databaseQueue: <#DatabaseQueue#>)
— 3:34
To provide this database we need the entry point to hold onto a database: @main struct SQLitePersistence2App: App { let databaseQueue: DatabaseQueue … }
— 3:50
And then assign it in the initializer: databaseQueue = try DatabaseQueue(path: databasePath)
— 3:53
And then this is the database we can supply to the ContentView : ContentView(databaseQueue: databaseQueue)
— 3:57
And now everything is compiling, and we are ready to try to perform a query.
— 4:02
Back in the onAppear we can use the read method to start up a transaction for reading from the database: try databaseQueue.read { db in }
— 4:11
Technically this read could also be a write as we did previously when creating tables and inserting data: try databaseQueue.write { db in }
— 4:18
However, if you only need read access to the database then it is preferable to use read because SQLite allows for many concurrent reads, but it will lock when writing. And so if you app is performing hundreds of reads, all of that can happen concurrently without a problem. And the moment a write starts, all reads will be blocked until the write finishes. And so it would be very wasteful if you used the write method but did not actually write anything.
— 4:38
Inside this read there are a few ways to execute a query. You can technically execute a raw SQL expression directly and interact with the cursor to get the data from the database, much like we did when interacting directly with the C library. However, GRDB gives much better tools for handling this.
— 4:54
But to get access to these tools we need to conform our Player type to another protocol, called FetchableRecord : struct Player: Encodable, MutablePersistableRecord, FetchableRecord { … }
— 5:03
And just as MutablePersistableRecord required Encodable because it needs to encode our values into a format that can be inserted into the database, the FetchableRecord protocol requires Decodable so that it can decode data from the database back into our Player type: struct Player: Codable, MutablePersistableRecord, FetchableRecord { … }
— 5:27
With that done we get a whole suite of helpers defined on the Player type for constructing SQL expressions to query the table. We can even use raw SQL strings: try Player.fetchAll(db, sql: """ SELECT * FROM "players" """)
— 5:56
That will select all columns and all players from the table, and decode the data into an array of Player values.
— 6:02
But we can also have GRDB write this query for us in order to minimize mistakes and typos. For example, something as simple as this works: try Player.fetchAll(db)
— 6:10
And the array this returns is ultimately returned from the read method, and so we can assign the players state from it: do { players = try databaseQueue.read { db in try Player.fetchAll(db) } } catch { }
— 6:26
We do have the question of what to do in the catch here. There two primary things that can go wrong in this code:
— 6:33
First, we may not have a valid connection to the database, perhaps it was somehow closed by someone else, and that would throw an error when trying to execute a query.
— 6:40
And second, the query we are executing may be malformed.
— 6:44
The first is quite rare and probably would not be possible to recover from. It might even be best to just crash the app at that point, and you have a serious bug on your hands that you need to figure out. And the second should also be pretty rare because we are using GRDB to build the query for us from simple pieces that we provide, and so there is less room for error in constructing queries.
— 7:04
Either way, you have to figure out how you want to handle these errors, but we have a suggestion that allows one to surface these errors in a noticeable, yet unobtrusive way, and it serves as a good first attempt at error handling. And then as your feature becomes more complex and you have a better understanding of how these errors can occur you can beef up the logic more.
— 7:21
Our suggestion is to use our Issue Reporting library, which provides a singular, lightweight tool for reporting issues. By default these issues are surfaced as purple runtime warnings when run in the simulator, but also, if they issues are ever reported in a testing context, then a test failure is emitted. Let’s import the library: import IssueReporting
— 7:39
Now unfortunately we need a quick history lesson here because we cannot simply depend on this swift-issue-reporting library that Xcode shows by default. The Issue Reporting library is an evolution of an older library we maintained called XCTest Dynamic Overlay which provided testing tools that can be invoked in non-test targets. The Issue Reporting library has all of that functionality, but so much more, and unfortunately renaming an SPM package is not straightforward. In fact, when we first tried we ended up breaking people’s apps.
— 8:07
So, for now we must depend on the older XCTest Dynamic Overlay library, and sometime in the near future we will complete the multi-step migration process to properly rename the library to Issue Reporting.
— 8:20
And let Xcode add the library for us…
— 8:26
Now we can simply report the issue when an error is emitted by our query do { players = try databaseQueue.read { db in try Player.fetchAll(db) } } catch { reportIssue(error) }
— 8:39
That’s all it takes. We can run the app in the simulator and we will see all of the Blobs that we had previously created while playing around with SQLite APIs.
— 8:45
Currently we only see the name of the player, not their injured status. Let’s add that. But rather than having to run the app in the simulator over and over, I think it would be nice for us to get a proper preview set up. But that will take some work.
— 8:58
First of all, if we run the preview it just immediately crashes with this little bit in the stack trace: Crashing Thread: 0 libswiftCore.dylib 0x00002e39c _assertionFailure(_:_:file:line:flags:) + 244 1 ??? 0x380018890 ??? 2 ??? 0x380019d80 ??? 3 SwiftUI 0x0005c5ee4 static App.main() + 132
— 9:01
This is pretty cryptic, but something happening in the entry point of our app is causing an assertion failure.
— 9:11
But also, why is the entry point of the app even running? This is a quirk of Xcode previews, and something we feel is a bug that should be fixed, but when you run a preview Xcode is secretly creating the entry point of your app. And in this situation, that means we are creating a database connection to a database file on disk, and something about that is causing a crash.
— 9:28
In our opinion it is best to make everything in the entry point as lazy as possible so that these heavy weight objects do not get created every time you run your preview. The easiest way to do that is to make the properties on your App struct statics because those are lazily initialized.
— 9:42
For example, the databaseQueue can be turned into a static like this: @main struct SQLitePersistence3App: App { static let databaseQueue: DatabaseQueue = { … }() … }
— 10:09
And then in the body of the app we can use the static databaseQueue : var body: some Scene { WindowGroup { ContentView(databaseQueue: Self.databaseQueue) } }
— 10:13
It’s worth noting that while the entry point of the app is created when running previews, the body of the app is not executed. So this will now make sure that the database queue is not created when running previews.
— 10:24
However, running our preview still does not show any players in a list. Currently in our preview we are creating a fresh new, in-memory database: #Preview { ContentView(databaseQueue: try! DatabaseQueue()) }
— 10:36
This database is a complete blank slate. It has not run any of the migrations we have in the app entry point, and so it doesn’t even have a “players” table. In fact, if we look at the logs for the preview we will see that an issue was reported: SQLiteExplorations/ContentView.swift:21: Caught error: SQLite error 1: no such table: players - while executing SELECT * FROM "players" We can’t query for the players because the “players” table hasn’t even been created yet.
— 10:52
We would like to run migrations on the this in-memory database, and so to do that we need to move our migration logic into a place that can be accessed from multiple parts of the app. One place we could move that code is a custom initializer or static function on DatabaseQueue that sets up our database exactly how we want.
— 11:09
Let’s create a static function called appDatabase that provision a migrated database for us: extension DatabaseQueue { static func appDatabase() throws -> DatabaseQueue { } }
— 11:27
We can move all of the logic from the entry point of the app to this helper: static func appDatabase() throws -> DatabaseQueue { let databasePath = URL.documentsDirectory .appending(path: "db.sqlite").path() print("open", databasePath) let databaseQueue = try DatabaseQueue(path: databasePath) var migrator = DatabaseMigrator() #if DEBUG migrator.eraseDatabaseOnSchemaChange = true #endif migrator.registerMigration("Create 'players' table") { db in try db.create(table: "players", options: .ifNotExists) { table in table.autoIncrementedPrimaryKey("id") table.column("name", .text).notNull() table.column("createdAt", .datetime) .defaults(sql: "current_timestamp") } } migrator.registerMigration("Add 'isInjured' to 'players'") { database in try database.alter(table: "players") { table in table.add(column: "isInjured", .boolean) .defaults(to: false) } } try migrator.migrate(databaseQueue) return databaseQueue }
— 11:42
And then the entry point can simply do this: @main struct SQLitePersistence3App: App { static let databaseQueue = try! DatabaseQueue.appDatabase() … }
— 11:52
And the preview can do something similar: #Preview { ContentView(databaseQueue: try! .appDatabase()) }
— 12:01
That seems pretty nice, but it also doesn’t really fix our problem in the preview. In fact, the preview is crashing again.
— 12:07
This is happening because we have gone back to using a database on disk for previews. We need to have custom logic for provisioning the database so that we use an in-memory database in previews. We can do this by checking an environment variable to see if we are currently running in a preview context: let databaseQueue: DatabaseQueue if ProcessInfo.processInfo .environment["XCODE_RUNNING_FOR_PREVIEWS"] == nil { let databasePath = URL.documentsDirectory .appending(path: "db.sqlite").path() print("open", databasePath) databaseQueue = try DatabaseQueue( path: databasePath, configuration: config ) } else { databaseQueue = try DatabaseQueue( configuration: config ) }
— 12:54
And now our preview runs without crashing and without outputting errors to the console.
— 13:23
However, still nothing is showing in the preview. This is because although our database has been migrated, it is still completely empty. We can even quickly create some players to insert into the database so that we can see some actual data in the list: #Preview { let databaseQueue = try! DatabaseQueue.appDatabase() try! databaseQueue.write { db in for index in 1...10 { _ = try Player(name: "Blob \(index)", createdAt: Date()) .inserted(db) } } return ContentView(databaseQueue: databaseQueue) }
— 14:02
And now that works! Our preview is running and showing data inside the list.
— 14:09
Let’s start adding new functionality to the view since it’s so easy to preview. We will display a little red stethoscope icon next to any players that are injured: ForEach(players) { player in HStack { Text(player.name) if player.isInjured { Spacer() Image(systemName: "stethoscope") .foregroundStyle(.red) } } }
— 14:37
Nothing updated in the preview because we don’t actually have any injured players. To see this change we will update the mock players we create for the preview so that every 3rd player is injured: try! databaseQueue.write { db in for index in 1...10 { _ = try Player( name: "Blob \(index)", createdAt: Date(), isInjured: index.isMultiple(of: 3) ) .inserted(db) } }
— 14:49
And now we see the icon displaying in the injured rows.
— 14:53
We can also run the app in the simulator to see it working, but because there is no data in the database it is just a blank screen. And we haven’t even added the functionality to our app to create new players, so we see to be a bit stuck.
— 15:07
We could of course adapt the entry point so that after the database queue is created we fill in some data: static let databaseQueue = { let databaseQueue = try! DatabaseQueue.appDatabase() try! databaseQueue.write { db in for index in 1...10 { _ = try Player( name: "Blob \(index)", createdAt: Date() ) .inserted(db) } } return databaseQueue }()
— 15:21
But this will now run every time the app starts up, not just a single time to seed the data. And it will also run on simulators and devices, and maybe we only want to seed data like this while testing in the simulator.
— 15:39
Luckily for us we can leverage migrations to seed this data a single time, and we can even limit it to only simulators. For example, we could have it so that only in DEBUG builds and only when building for the simulator do we create a few players in the database: #if DEBUG && targetEnvironment(simulator) migrator.registerMigration("Seed simulator data") { db in _ = try Player( name: "Blob", createdAt: Date(), isInjured: false ) .inserted(db) _ = try Player( name: "Blob Jr", createdAt: Date(), isInjured: false ) .inserted(db) _ = try Player( name: "Blob Sr", createdAt: Date(), isInjured: true ) .inserted(db) } #endif
— 16:16
And as our database scheme gets more and more complex we can add more and more mock data, including relationships between the models. This can help speed up the time to develop your app since you will always have some reasonable data waiting for you in the simulator. Now when we run the app in the simulator we see these 3 players that we just created.
— 16:48
This greatly improves the developer experience of working on apps with SQLite persistence. We are able to get data up on the screen in the simulator before we have even built the feature to add players. And even if we had that feature, it would be a real pain to literally tap through the full interface flow in the simulator multiple times just to get a few players up on the screen. Migrations are a great place to provide mock data for the simulator so that you don’t have to create data over-and-over in the simulator just to test something. Foreign keys
— 17:16
OK, things are looking pretty good. Not only can we query the database quite easily, but we are already displaying the data in SwiftUI. This shows the very basics of how one might integrate a SQLite database into a SwiftUI feature, though of course there is a lot more to learn before you can be fully effective. And we even showed the basics of getting data into previews, but we will have more to say about that a bit later.
— 17:35
Now let’s move onto some more advanced topics. These will be topics that we didn’t even attempt to talk about when dealing with the SQLite C library directly because it can be some cumbersome. Brandon
— 17:45
The first topic we will discuss is that of relationships between tables. One of SQLite’s great powers, and really any SQL-based database, is modeling relationships between multiple tables. In a properly normalized database schema you will have many tables representing your various core domain entities, along with relationships that describe when one entity “belongs” to another. And these relationships come in many flavors, such as one-to-one, one-to-many, many-to-many, and even recursive relationships.
— 18:16
Right now we just have a single table, and so there isn’t much we can relate. So, let’s add a new table in order to express such a relationship.
— 18:26
We are going to add a new model that represents a team that the players can belong to. We will keep it very simple right now by having each time consist of just a name and ID, and we will go ahead and make it conform to all the protocols that we are know are useful: struct Team: Codable, MutablePersistableRecord, FetchableRecord { static let databaseTableName = "teams" var id: Int64? var name = "" mutating func didInsert(_ inserted: InsertionSuccess) { id = inserted.rowID } }
— 19:26
Next we will add a migration for creating a table that represents teams: migrator.registerMigration("Add 'teams'") { db in try db.create(table: Team.databaseTableName) { table in table.autoIncrementedPrimaryKey("id") table.column("name", .text).notNull() } }
— 20:27
And then further we want to create a kind of relationship between players and teams so that each player can belong to at most one team. This requires updating the struct representation of the player as well as the database table where the player data is stored.
— 20:45
Let’s start with the Player data type. We will represent the relationship by having the Player type hold onto an optional teamID : struct Player: Codable, MutablePersistableRecord, FetchableRecord { … var teamID: Int64? … }
— 20:58
If this value is non- nil , then the player belongs to a team, and otherwise they do not belong to a team or their team has not yet been determined. And it’s important to note that we only want to hold onto the ID of the team rather than an entire Team value. If we held a team on the player: var team: Team?
— 21:15
…then we would be forced to fetch the team data anytime we want player data. And in the future teams could hold onto lots of data, and even have relationships of its own that transitively also holds onto lots of data. It is better to hold the minimal data for each of your core domain types and then perform specialized queries to load the data you need selectively.
— 21:54
Next we will add a bit of data to the “players” table so that we know which team each player is on. The way one does this is to add a column to the “players” table that holds the ID of the team they are associated with: try db.alter(table: "players") { table in table.add(column: "teamID", .integer) }
— 22:39
This allows each player to belong to at most one team, and each team can have any number of players. This is known as a 1-to-many relationship, or sometimes many-to-1.
— 22:52
But we can make this relationship stronger. As it stands right now with this database, it would be possible to create players that have team IDs that don’t even exist. That would mean in our code we can hold onto a Player value that appears to have a team, because their team ID is non- nil , yet if we try to fetch that team from the database we will not get anything back.
— 23:15
This can leak complexity into our code base where we can no longer trust our data, and we are forced to constantly be defensive against these situations by checking for nil values and creating error states for situations that should never even be possible.
— 23:40
Well, luckily SQLite provides a feature that can provide these kinds of data consistency guarantees, and it is called “foreign keys.” It allows you to say when a column references a row in another table, and SQLite will not allow using a team ID that does not exist. And GRDB provides a helper method for setting this up by using the references method: table.add(column: "teamID", .integer) .references("teams")
— 24:34
However, by default the configuration of SQLite is not strict with foreign key constraints. You are allowed to assign team IDs to players for non-existent teams.
— 24:47
You must enable a setting in SQLite to make it so that it a valid team ID is provided, and one does that in GRDB by providing a custom configuration when establishing a connection to the database: var config = Configuration() config.foreignKeysEnabled = true
— 25:13
And just to test things, let’s try creating a player with an invalid ID right when the app starts. We can do so in the “Seed simulator data” migration: _ = try Player( name: "Blob", createdAt: Date(), isInjured: false, teamID: 42 ) .inserted(db)
— 25:37
If we run the app in the simulator we are immediately met with a throw error letting us know that the foreign key constraint failed: Fatal error: ‘try!’ expression unexpectedly raised an error: SQLite error 19: FOREIGN KEY constraint failed - while executing INSERT INTO "players" ("id", "name", "createdAt", "isInjured", "teamID") VALUES (?,?,?,?,?)
— 26:24
So this is good. We should not be allowed to create players that belong to a non-existent team. And it also works in the opposite direction. You will not be allowed to delete a team if there are players assigned to that team, because that would leave those players with invalid team IDs.
— 26:42
And while we are here let’s also update our migration for seeding simulator data by adding a few teams and assigning them to our players: #if DEBUG && targetEnvironment(simulator) migrator.registerMigration("Seed simulator data") { db in let lions = try Team(name: "Lions").inserted(db) let tigers = try Team(name: "Tigers").inserted(db) let bears = try Team(name: "Bears").inserted(db) _ = try Player( name: "Blob", createdAt: Date(), isInjured: false, teamID: lions.id! ) .inserted(db) _ = try Player( name: "Blob Jr", createdAt: Date(), isInjured: false, teamID: tigers.id! ) .inserted(db) _ = try Player( name: "Blob Sr", createdAt: Date(), isInjured: true, teamID: bears.id! ) .inserted(db) } #endif
— 27:42
We have now finished the domain modeling for teams and we have a foreign key relationship between teams and players so that each player has at most one team. Let’s now add something to the app to display this new data. We will make it so that you can tap on a player in the list to bring up an information sheet about that player, and in that sheet we will show their team.
— 28:04
Let’s start with just the view for displaying the player details. It will be a view that holds onto the player we are displaying, and we will put this in a new PlayerDetail.swift file: import SwiftUI struct PlayerDetailView: View { let player: Player }
— 28:21
And in this view we will want to load the team for the player, which will require making a database request. So, we will need a database queue, as well as some local @State for the team we are loading, which will need to be optional since it will be nil at first and then we will load the data: import GRDB import SwiftUI struct PlayerDetailView: View { let databaseQueue: DatabaseQueue let player: Player @State var team: Team? }
— 28:48
For the body of the view we can start with some basic view hierarchy to display the properties of the player: var body: some View { Form { Section { Text(player.name) if player.isInjured { Text("\(Image(systemName: "stethoscope")) Injured") .foregroundStyle(.red) } else { Text("Not injured") } } header: { Text("Details") } Section { if let team { Text(team.name) } else { Text("No team") .italic() } } header: { Text("Team") } } .navigationTitle("Player") }
— 29:09
Nothing too special here.
— 29:11
Where things get more interesting is when loading the team data. We will do this in an onAppear again: .onAppear { }
— 29:20
And in here we can check if the player has a teamID , and if so perform a SQL query to fetch the player in a read transaction: do { guard let teamID = player.teamID else { return } team = try databaseQueue.read { db in } } catch { reportIssue(error) }
— 29:41
And this time we will use a different helper for fetching data called a fetchOne . It allows you to fetch a single row from the database from its primary key, which is its ID: team = try databaseQueue.read { db in try Team.fetchOne(db, key: teamID) }
— 30:44
And that’s all there is to this view.
— 30:47
But, while we are here, let’s also get a preview into place: #Preview("Player detail") { PlayerDetailView( databaseQueue: <#DatabaseQueue#>, player: <#Player#> ) }
— 31:05
Just as with our other preview, we are going to provision an in-memory database: databaseQueue: try! .appDatabase()
— 31:10
And remember that this database is already migrated, and it even has some mock data already plugged in. So we can even just construct a player with a team ID from the database to use in the preview: player: Player( name: "Blob", createdAt: Date(), teamID: 1 )
— 31:44
The preview renders with the player’s info, and we even see that their team is the Lions, and we can change the team ID to see the Tigers and Bears.
— 31:55
And it’s pretty incredible to see how we are exercising a lot of complex functionality of our code base directly in a preview. We are making a live query to a SQLite database and displaying that data in the view. This can give us a lot of confidence that it will work properly when we run the app on an actual device.
— 32:16
For example, suppose that we didn’t know about fetchOne or didn’t want to use it because we needed a more complex query to execute. At the end of the day we can also execute raw SQL statements and decode the data into our models like this: try Team.fetchOne( db, sql: """ SELECT * FROM "teams" WHERE "id" = ? """, arguments: [teamID] )
— 33:05
This works the exact same, but it of course opens us up to the possibility of typos like this: try Team.fetchOne( db, sql: """ SELECT * FROM "team" where "id" = ? """, arguments: [teamID] )
— 33:26
With that small change we instantly see that the preview no longer displays the team, and we even have a helpful message in the console letting us know what went wrong thanks to our reportIssue : SQLiteExplorations/ContentView.swift:81: Caught error: SQLite error 1: no such table: team - while executing SELECT * FROM "team" where "id" = 1
— 34:02
And so truly if there is a bug in our feature code, the preview should be able to show us what is wrong, and hopefully even provide some helpful error messaging, so that we can fix the problem without ruining our iterative development cycle going through the simulator or device.
— 34:19
Now one thing people like to do when they have complex processes that provide data to the view is to separate the view into an “outer” view that performs these processes like API and database requests, and an “inner” view that simply takes the data and lays it out in the view hierarchy. struct PlayerDetailView: View { struct Core: View { let player: Player let team: Team var body: some View { … } } … var body: some View { Core(player: player, team, team) .onAppear { … } } }
— 35:16
And then the preview could bypass all those processes and simply render the “inner” view: #Preview("Player detail core") { PlayerDetailView.Core(…) }
— 35:45
And this works, but does it give us any confidence that our feature will actually run in the simulator or on device? All we’re testing is this inert inner view, which is just showing whatever data we hand to it. But there’s a lot of work that needs to be done in order to prepare that data. So we personally don’t feel that splitting views out in this way is worth the code or time.
— 36:25
They take a lot of work to put into place.
— 36:27
They make the view a lot more complex than it needs to be.
— 36:30
And they prevent you from actually testing that feature’s logic in the preview.
— 36:34
And so we’re going to go back to the way it was before, without the extra view layer.
— 37:14
With the player detail view now done we just need to navigate to this screen from the player list. We are going to do this via a SwiftUI sheet and using detents so that the sheet does not cover the majority of the screen.
— 37:32
Going back to the list view for players, we will wrap each row in a button: Button { } label: { … }
— 37:45
And in the action of this button we need to mutate some state to present the player’s detail view. Sheets can be presented from simple booleans, or optional state. We want to use optional state, and in particular an optional Player : @State var playerDetail: Player?
— 38:00
…so that when this state flips to non- nil we will present the sheet, and we will hand that unwrapped player to the detail view.
— 38:07
So, in the button’s action closure we can mutate this state to signify that the sheet show be presented: Button { playerDetail = player } label: { … }
— 38:15
And then we can use the sheet(item:) method to present a sheet when the state flips to a non- nil value: .sheet(item: $playerDetail) { player in }
— 38:33
To construct the detail view we will pass along the database queue and the freshly unwrapped player value: .sheet(item: $playerDetail) { player in PlayerDetailView(databaseQueue: databaseQueue, player: player) } Instance method ‘sheet(item:onDismiss:content:)’ requires that ‘Player’ conform to ‘Identifiable’
— 38:45
Now this is not working because the Player type is not Identifiable , and this is not the first time we’ve encountered a SwiftUI API that wanted an identifiable data type. Previously we specified an explicit id in a ForEach view because ForEach prefers identifiable types.
— 39:19
The documentation of GRDB does prefer avoiding Identifiable for auto-incrementing models with optional IDs, and optional IDs are a strange concept when it comes to Identifiable : IDs are supposed to uniquely identify a model, but if the ID is optional then all unsaved records share the same identity, and this could lead to bad situations, for example if you tried to ForEach over multiple, unsaved records, SwiftUI will not render each one and will instead re-render the same record over and over again.
— 40:21
And so you could do some extra work to avoid the Identifiable conformance and the strangeness that comes along with it, but we think that when it comes to developing SwiftUI applications, this conformance can be practical and worth the trade-off if you know the caveats, especially when you could end up in the exact same situation without the conformance, like if you specify id: \.id in the ForEach , but multiple unsaved records with nil IDs are in the list.
— 40:40
So while not ideal , it is extremely useful, so we will conform our models to be Identifiable and simply be aware of the limitations and take care. extension Player: Identifiable {} extension Team: Identifiable {}
— 41:02
And now we can clean up a few things. Our ForEach can drop the id parameter: ForEach(players) { player … } And we can use the id parameter of fetchOne : try Team.fetchOne(db, id: teamID)
— 41:15
Our sheet view modifier is now compiling. We will further wrap the detail view in a navigation stack so that we get access to a toolbar for titles and buttons, which will be handy in the future, and we will apply medium detents: .sheet(item: $playerDetail) { player in NavigationStack { PlayerDetailView(databaseQueue: databaseQueue, player: player) } .presentationDetents([.medium]) }
— 41:31
With that little bit of work everything is already working. We can tap a row to see the sheet fly up, and if there is a team assigned for the player we even see that info displayed. And so our preview is now even showing the integration of multiple features together, and each of those features is interacting with the database. Next time: Database observation
— 42:19
With just a little bit of work we have modeled a new domain type for our application, created a relationship between domain types, and created a new view to display the data for this new type, as well as navigation to the view. And this sets the stage for how one can build a simple SQLite-based application. In the onAppear of a feature’s view you can execute some queries to populate the data in the view, and then SwiftUI takes care of the rest. Stephen
— 42:47
But also most apps cannot be so simple. Typically you will have lots of features accessing the database, and then also lots of features writing to the database. And when one features makes a write you will want to make sure that all other views update in order to show the freshest data.
— 43:01
This brings us to a powerful feature that SQLite has called “change notifications”. We didn’t explore this when dealing with the C library directly because it can be quite cumbersome due to how one handles callback functions in C. But luckily GRDB has a nice interface to this functionality, so let’s take a look…next time! References SQLite The SQLite home page https://www.sqlite.org