Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor the SwiftUI demo app #953

Merged
merged 1 commit into from
Apr 5, 2021
Merged

Conversation

groue
Copy link
Owner

@groue groue commented Apr 5, 2021

This pull request refactors the SwiftUI demo application:

  • The demo app no longer builds on top of the MVVM pattern. Not only such an architectural choice should be left to the application developer, but it was poorly done due to my lack of SwiftUI experience, and made the demo app more complex than necessary.
  • The demo app now uses the SwiftUI environment in order to inject the database in views.
  • The demo app defines a reusable @Query property wrapper that lets views automatically display an up-to-date state of the database

This work is entirely based on Core Data and SwiftUI by @davedelong. I highly recommend reading this article! Below I will pay tribute to this generous sharing of experience 🙂

Dave's has polished his laws of Core Data over the years. This paragraph of him pretty much resonates with me:

In my opinion, you should keep the details of graph integrity and persistence to a confined part of your app, and data should only get out via custom-purpose struct values (or something like them).

Indeed, support for struct is ready made in GRDB, thanks to its record protocols, and his advice about information hiding is pervasive in the Good Practices for Designing Record Types.

Based on this common foundation, we can come back to Dave's take on Core Data and SwiftUI. He wrote:

The Roadmap

When designing a new API, I often like to start with the final syntax of how I want to use a thing, and then work backwards to make that syntax possible. As I was imagining a data abstraction layer, I came up with this:

struct ContactList: View {
    @Query(.all) var contacts: QueryResults<Contact>

    var body: some View {
        List(contacts) { contact in
            Text(contact.givenName)
        }
    }
}

This is of course heavily inspired by @FetchRequest, but that’s not a bad thing. It’s pretty minimal: I define a query to fetch everything (.all the contacts), and I’ve got a basic body implementation. Like with @FetchRequest, I do need a special “results” type, which we’ll get to later.

We’re going to end up with a few different things to make this possible, which I’ll outline here:

  • a DataStore class
  • a Queryable protocol
  • a QueryFilter protocol
  • a Query property wrapper
  • a QueryResults struct

🤩 What isn't to love in this roadmap?

The demo app already had an equivalent of DataStore, named AppDatabase, which is the object that handles all database mutations.

Dave's goal is to design his @Query property wrapper so that it can update a SwiftUI view with the latest state of the database, as instructed by Queryable and QueryFilter. The design of those protocols matches Core Data's observation tool, NSFetchedResultsController, which track lists of managed objects.

Since GRDB can observe any kind of database content (not only lists of records), this pull request also defines a Queryable protocol, but without constraining it to any particular record type:

/// The protocol that feeds the `@Query` property wrapper.
protocol Queryable: Equatable {
    /// The type of the fetched value
    associatedtype Value
    
    /// The default value, used whenever the database is not available
    static var defaultValue: Value { get }
    
    /// Fetches the database value
    func fetchValue(_ db: Database) throws -> Value
}

Dave's QueryResults has no GRDB equivalent, because we do not currently support lazy collections of many database rows.

Applying Queryable in the demo app gives:

/// A player request that defines how to feed the player list
struct PlayerRequest {
    enum Ordering {
        case byScore
        case byName
    }
    
    var ordering: Ordering
}

/// Make `PlayerRequest` able to be used with the `@Query` property wrapper.
extension PlayerRequest: Queryable {
    static var defaultValue: [Player] { [] }
    
    func fetchValue(_ db: Database) throws -> [Player] {
        switch ordering {
        case .byScore: return try Player.all().orderedByScore().fetchAll(db)
        case .byName: return try Player.all().orderedByName().fetchAll(db)
        }
    }
}

Here is how the demo app can use PlayerRequest with the @Query property wrapper:

struct PlayerList: View {
    @Query(PlayerRequest(ordering: .byScore)) var players
    
    var body: some View {
        List(players) { player in
            Text(player.name)
        }
    }
}

This is not as pretty as Dave's goal: his @Query(.all) is unmatched. But I did not find a way to tie the query type to the type of the tracked value, as Dave could do given the constraints of NSFetchedResultsController. GRDB has no "natural" fetched type, and no "natural" request type either: it tracks the results of functions that accept a database connection, and these functions can do and return anything.

But this is not bad!

And now we get to the most sexy of Dave's ideas. He wrote:

Mutating the Filter

It’d be nice to have a way to modify the filter from the UI, so we can control things like sort order or change aspects of the predicate. For example, if you’re showing a list of contacts, it’d be nice to have a search field so you can search for contacts that match some user-provided text.

This matches so well the use case of the demo app, which lets the user choose the ordering of the player list (by name or by score)!

His idea makes it very easy to toggle player ordering from the view:

/// The main application view
struct PlayerList: View {
    @Query(PlayerRequest(ordering: .byScore)) var players
    
    var body: some View {
        NavigationView {
            List(players) { player in
                Text(player.name)
            }
            .navigationBarItems(trailing: ToggleOrderingButton(ordering: $players.ordering))
        }
    }
}

struct ToggleOrderingButton: View {
    @Binding var ordering: PlayerRequest.Ordering
    
    var body: some View {
        switch ordering {
        case .byName:
            Button(
                action: { ordering = .byScore },
                label: { ... })
        case .byScore:
            Button(
                action: { ordering = .byName },
                label: { ... })
        }
    }
}

@groue groue changed the base branch from master to development April 5, 2021 14:03
@groue groue merged commit 63a52ac into development Apr 5, 2021
@groue groue deleted the dev/refactor-SwiftUI-demo branch April 5, 2021 14:51
@steipete
Copy link
Collaborator

steipete commented Apr 7, 2021

I did play with this, however it seems slightly problematic with the use of @StateObject in the query. If I have a view that is set up outside, I need to set the parameters of the query somewhere - ideally not in the view. However @StateObject can't be accessed outside of the view block

Screen Shot 2021-04-07 at 15 10 11

@steipete
Copy link
Collaborator

steipete commented Apr 7, 2021

Playing with this some more, I understand the root issue now and fixed it in #955.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants