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?
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.