Displaying Core Data entity in Table (iOS 16)

HI. I'm trying to replace a hacky LazyVGrid view I cobbled together to display a table of player stats (name, games, scores, etc.) that are saved as a Core Data entity, with the new (to iOS 16) Table view.

Benefits of this are: 1. less hacky (hopefully), 2. resortable by different categories (games, points, etc.)

I manage to have a table appear, but I can't do the reordering. When I put the .onChange(of: sortOrder) {...} I get one of the cryptic "The compiler is unable to type-check this expression in reasonable time..." at the var body: some View stage.

If I instead use a sample array (so not coming from Core Data), it works fine. The array I create to test the sortable table is an Identifiable one (which it needs to be to re-sort).

I think the issue is the Identifiable part. I just added a UUID to the Core Data entity, which didn't do anything. I don't know how to declare the fetch request to be Identifiable, or if that even makes sense.

Here is what does NOT work:

struct StatsView: View {
    @FetchRequest(sortDescriptors: []) var stats: FetchedResults<PlayerStats>
    @State private var sortOrder = [KeyPathComparator(\PlayerStats.name)]

    var body: some View {

            if #available(iOS 16.0, *) {
                Table(stats, sortOrder: $sortOrder) {
                    TableColumn("Player", value: \.name!)
                    TableColumn("W-L", value: \.wins) {stats in Text("\(stats.wins)" + " - " + "\(stats.losses)")}
                    TableColumn("Points", value: \.points) {stats in Text("\(stats.points)")}
                    TableColumn("Innings", value: \.innings) {stats in Text("\(stats.innings)")}
                    TableColumn("LR", value: \.longRun) {stats in Text("\(stats.longRun)")}
                    TableColumn("Avg", value: \.lastAvg) {stats in Text("\((stats.lastAvg), specifier: "%.3f")")}

                }
                .onChange(of: sortOrder) { newOrder in
                    stats.sort(using: newOrder)}

            }
            else {...} \\old hacky lazyVGrid based table
      }
}

Again: if I comment out the .onChange modifier, it compiles and the table shows.

Here is what DOES work:

struct PlayerStat: Identifiable {
    let id: Int
    var name: String
    var wins: Int
    var losses: Int
    var points: Int
    var innings: Int
    var longRun: Int
    var lastAvg: Double
}

struct ContentView: View {

    @State private var sortOrder = [KeyPathComparator(\PlayerStat.name)]

    @State var stats: [PlayerStat] = [
        PlayerStat(id: 1, name: "Ale", wins: 9, losses: 4, points: 126, innings: 137, longRun: 6, lastAvg: 0.527),
        PlayerStat(id: 2, name: "Luis", wins: 8, losses: 5, points: 153, innings: 125, longRun: 7, lastAvg: 0.427),
        PlayerStat(id: 3, name: "Isabel", wins: 9, losses: 4, points: 126, innings: 152, longRun: 5, lastAvg: 0.623),
        PlayerStat(id: 4, name: "Amy", wins: 10, losses: 2, points: 172, innings: 143, longRun: 4, lastAvg: 0.829),
        PlayerStat(id: 5, name: "Daniel", wins: 3, losses: 2, points: 55, innings: 63, longRun: 9, lastAvg: 1.069)
    ]

    var body: some View {
                Table(stats, sortOrder: $sortOrder) {
                    TableColumn("Player", value: \.name)
                    TableColumn("W-L", value: \.wins) {stats in Text("\(stats.wins)" + " - " + "\(stats.losses)")}
                    TableColumn("Points", value: \.points) {stats in Text("\(stats.points)")}
                    TableColumn("Innings", value: \.innings) {stats in Text("\(stats.innings)")}
                    TableColumn("LR", value: \.longRun) {stats in Text("\(stats.longRun)")}
                    TableColumn("Avg", value: \.lastAvg) {stats in Text("\((stats.lastAvg), specifier: "%.3f")")}
                }
                .onChange(of: sortOrder) { newOrder in
                 stats.sort(using: newOrder) }
         }

}

Thanks for any help.

Answered by Don Nissen in 737496022

The way I solved this problem was to use two SwiftUI files. the first file was like this:


    @FetchRequest(sortDescriptors: []) var stats: FetchedResults<PlayerStats>
    var body: some View {
        Stats2View(stats: Array(stats))
    }
}

The second file has most of your original code but adding one line.


    // Add this
    @State var stats:Array<PlayerStats>

    @State private var sortOrder = [KeyPathComparator(\PlayerStats.name)]

    var body: some View {
        if #available(iOS 16.0, *) {
           Table(stats, sortOrder: $sortOrder) {
                TableColumn("Player", value: \.name!)
                TableColumn("W-L", value: \.wins) {stats in Text("\(stats.wins)" + " - " + "\(stats.losses)")}
                TableColumn("Points", value: \.points) {stats in Text("\(stats.points)")}
                TableColumn("Innings", value: \.innings) {stats in Text("\(stats.innings)")}
                TableColumn("LR", value: \.longRun) {stats in Text("\(stats.longRun)")}
                TableColumn("Avg", value: \.lastAvg) {stats in Text("\((stats.lastAvg), specifier: "%.3f")")}
            }
           .onChange(of: sortOrder) { newOrder in
               stats.sort(using: newOrder)}
        }
        else {...} \\old hacky lazyVGrid based table
    }
}

FetchRequeset has its own sort descriptors. Better to let it resort itself. Also, is Table optimized for lots and lots of items?

I have still not found a solution for this. I don't see how to do another fetch request within the table, plus it seems wasteful in this case (no data would have changed in my context).

Accepted Answer

The way I solved this problem was to use two SwiftUI files. the first file was like this:


    @FetchRequest(sortDescriptors: []) var stats: FetchedResults<PlayerStats>
    var body: some View {
        Stats2View(stats: Array(stats))
    }
}

The second file has most of your original code but adding one line.


    // Add this
    @State var stats:Array<PlayerStats>

    @State private var sortOrder = [KeyPathComparator(\PlayerStats.name)]

    var body: some View {
        if #available(iOS 16.0, *) {
           Table(stats, sortOrder: $sortOrder) {
                TableColumn("Player", value: \.name!)
                TableColumn("W-L", value: \.wins) {stats in Text("\(stats.wins)" + " - " + "\(stats.losses)")}
                TableColumn("Points", value: \.points) {stats in Text("\(stats.points)")}
                TableColumn("Innings", value: \.innings) {stats in Text("\(stats.innings)")}
                TableColumn("LR", value: \.longRun) {stats in Text("\(stats.longRun)")}
                TableColumn("Avg", value: \.lastAvg) {stats in Text("\((stats.lastAvg), specifier: "%.3f")")}
            }
           .onChange(of: sortOrder) { newOrder in
               stats.sort(using: newOrder)}
        }
        else {...} \\old hacky lazyVGrid based table
    }
}

Don Nissen, you the person! It actually works! Thank you, thank you, thank you. I was failing bad, and trying to use a dynamic fetch request thing I saw on the invaluable Hacking with Swift, but I wasn't happy with the solution, since it necessitated refetching every time a reordering is called, which seems wasteful.

Am I correct in parsing what you do as transferring the core data entity (whatever thing that is) into an array (which is what I hoped a core data entity should be)?

The only sad I have is that I need to make a second file to achieve that. Tried to do everything in the same file unsuccessfully.

Thanks again. I haven't seen anyone with a solution to this. I don't understand how it's not a more prevalent problem for more people. I even did an "Ask Apple" office hour appointment, and their solution was to refetch, which I don't see how that is not hacky within a table.

Anyways, thanks Don Nissen.

You can bind the table to the fetch request's sort descriptors there is no need to have additional state, e.g.

Table(stats, sortOrder: $stats.sortDescriptors) {

When the users clicks on a table column the fetch and the table will update automatically.

Unfortunately there is an issue where if the View containing the FetchRequest is re-init then the sortDescriptors get reset to their default. I'm not sure if that is a design flaw in @FetchRequest or we are supposed to make our own @State for the sort in the view above and pass it in as a binding and use the value in the @FetchRequest and using the binding to the binding in the Table, e.g.

@Binding var sortDescriptors: [SortDescriptor<Item>]

init(sd: Binding<[SortDescriptor<Item>]>) {
    _items = FetchRequest(sortDescriptors: sd.wrappedValue)
    _sortDescriptors = sd
}
...
    Table(stats, sortOrder: $sortDescriptors) {
Displaying Core Data entity in Table (iOS 16)
 
 
Q