Search code examples
iosswiftswiftuiswiftdata

Create sortable, ordered, and grouped data from @Query in SwiftData


I've been trying to wrap my head around a sorting and grouping process for a SwiftUI app I've been trying to build.

All the tutorials I've seen have been fairly "basic" when it comes to the sorting and filtering aspect - particularly when using SwiftData.

What I wanted to incorporate was not only sorting by one of the attributes and forward/reverse, but also grouping the data too.

For example, this is code from the Earthquake project by Apple (some items removed for brevity):

struct QuakeList: View {
    @Environment(ViewModel.self) private var viewModel
    @Environment(\.modelContext) private var modelContext
    @Query private var quakes: [Quake]

    init(
        sortParameter: SortParameter = .time,
        sortOrder: SortOrder = .reverse
    ) {
        switch sortParameter {
            case .time:
                _quakes = Query(sort: \.time, order: sortOrder)
            case .magnitude:
                _quakes = Query(sort: \.magnitude, order: sortOrder)
        }
    }

What they do in this is pass in the sortParameter and the sortOrder to this view and it re-renders the view on change/update.

How can I expand this so it also can handle grouping so the quakes variable would really be a multidimensional array or even a dictionary.

For example, I was trying to do this to perform the rendering:

enum GroupOption { // New grouping for Section
    case time
    case magnitude
    case none
}

struct ListScreen: View {
    @Environment(ViewModel.self) private var viewModel
    @Environment(\.modelContext) private var modelContext
    @Query private var quakes: [Quake]
    @State private var groupedQuakes: [[Quake]] = [] // New multidimensional array

    init(
        sortParameter: SortParameter = .time,
        sortOrder: ComparisonResult = .orderedAscending, // using ComparisonResult to store the enum value in defaults
        sortGrouping: GroupOption = .none
    ) {
        switch (sortParameter, sortOrder) {
            case (.time, .orderedAscending):
                _quakes = Query(sort: \.time, order: .forward)
            case (.time, .orderedDescending):
                _quakes = Query(sort: \.time, order: .reverse)

            case (.magnitude, .orderedAscending):
                _quakes = Query(sort: \.magnitude, order: .forward)
            case (.magnitude, .orderedDescending):
                _quakes = Query(sort: \.magnitude, order: .reverse)

            default:
                _quakes = Query(sort: \.time, order: .forward)
        }

        switch sortGrouping {
            case .time:
                groupedQuakes = Dictionary(grouping: _quakes.wrappedValue, by: { $0.time })
                    .sorted(by: { $0.key < $1.key })
                    .map({ $0.value })
            case .magnitude:
                groupedQuakes = Dictionary(grouping: _quakes.wrappedValue, by: { $0.magnitude })
                    .sorted(by: { $0.key < $1.key })
                    .map({ $0.value })
            case .none:
                groupedQuakes = [_quakes.wrappedValue]
        }
    }

Except, when I use it in the view body it is empty. So switching from the // 1 to // 2 makes the array of data return empty.

// 1
List(quakes) { quake in
  QuakeRow(quake: quake)
}

// 2
List {
  ForEach(groupedQuakes, id: \.self) { group in
    Section {
      ForEach(group) { quake in
        QuakeRow(quake: quake)
      }
    } header: {
      groupHeader(for: group)
    }
  }
}
// ...
func groupHeader(for group: [Quake]) -> Text {
  guard let group = group.first else { return Text("Unknown") }
  switch groupOption {
    case .time:
      return Text(group.time.formatted(date: .numeric, time: .omitted))
    case .magnitude:
      return Text("\(group.magnitude)")
    case .none:
      return Text("All quakes")
  }
}

So when I return the general @Query private var quakes: [Quake] there is an array returned with the data.

Using the sorting included in the Apple test project the quakes are sorted correctly.

As soon as I try to add in grouping and sort that data returns blank arrays.

Is there something I'm overlooking?


Solution

  • Okay so after a bit more research the current status is there is no SwiftData solution at present.

    There are however two methods of achieving this though - computed variable (as suggested by Joakim Danielson) and a Swift package.

    Computed Variable

    In the example code for the ListScreen we move the switch sortGrouping { ... } into a computed property, and add in a @Binding for the current property to assign the multidimensional array to:

    struct ListScreen: View {
        @Environment(ViewModel.self) private var viewModel
        @Environment(\.modelContext) private var modelContext
        @Query private var quakes: [Quake]
    
        @Binding var sortGrouping: GroupOption // <-- new binding
        private var groupedQuakes: [[Quake]] { // <-- new computed property
            switch sortGrouping {
                case .time:
                    groupedQuakes = Dictionary(grouping: _quakes.wrappedValue, by: { $0.time })
                        .sorted(by: { $0.key < $1.key })
                        .map({ $0.value })
                case .magnitude:
                    groupedQuakes = Dictionary(grouping: _quakes.wrappedValue, by: { $0.magnitude })
                        .sorted(by: { $0.key < $1.key })
                        .map({ $0.value })
                case .none:
                    groupedQuakes = [_quakes.wrappedValue]
            }
        }
    
        init(
            sortParameter: SortParameter = .time,
            sortOrder: ComparisonResult = .orderedAscending, // using ComparisonResult to store the enum value in defaults
            sortGrouping: Binding<GroupOption>
        ) {
            _sortGrouping = sortGrouping // <-- assign the Binding
    
            switch (sortParameter, sortOrder) {
                case (.time, .orderedAscending):
                    _quakes = Query(sort: \.time, order: .forward)
                case (.time, .orderedDescending):
                    _quakes = Query(sort: \.time, order: .reverse)
    
                case (.magnitude, .orderedAscending):
                    _quakes = Query(sort: \.magnitude, order: .forward)
                case (.magnitude, .orderedDescending):
                    _quakes = Query(sort: \.magnitude, order: .reverse)
    
                default:
                    _quakes = Query(sort: \.time, order: .forward)
            }
        }
    

    Then when the sortGrouping changes in the @Binding the view will re-render, grouping your data.

    I'm not sure how it will go with overly complex cells, but it works for about 200 cells with an image, headline, and sub-headline without any real noticeable lag.


    Swift Package - SectionedQuery

    Fairly explanatory README file, but the only thing I couldn't see was having different group types. From what I was briefly testing, you can only set one type and use that as the group meaning that your grouping is fairly fixed.