I am displaying quite a lot of data in a SwiftUI view, the data is stored in SwiftData and it's working fine, the problem is when I need to filter the data. Below code is a simpler version of the filters I want to use, I've made it simple so that it's easy for y'all to understand. The below code(simpler version) took 21 seconds to build.
The below is the SwiftUI view which displays some data stored in SwiftData:
struct TestView: View {
@Environment(\.modelContext) var modelContext
@Query(sort: \GroceryItem.name) var groceryItems: [GroceryItem]
init(sort: SortDescriptor<GroceryItem>, searchText: String, stores: Set<String>) {
_groceryItems = Query(filter: #Predicate {
// The below expression takes a little while to compile
if searchText.isEmpty {
if stores.isEmpty {
return true
} else {
return stores.contains($0.store)
}
} else {
if stores.isEmpty {
return $0.name.localizedStandardContains(searchText)
} else {
return stores.contains($0.store) && $0.name.localizedStandardContains(searchText)
}
}
} , sort: [sort])
}
var body: some View {
List(groceryItems) { item in
GroceryItemCell(groceryItem: item, isAddGroceryItemView: true) { _ in
print("Update total price")
}
.listRowInsets(EdgeInsets())
}
}
}
This initialiser is being called from another view like to display the stored data in SwiftData like so:
TestView(sort: sortOrder, searchText: searchText, stores: selectedStores)
Code explanation: The initialiser takes in 3 parameters:
sort -> By default it is SortDescriptor(\GroceryItem.name)
, this just sorts the items in the array by order of name
searchText -> This is the text from the .searchable
modifier, it filters the array items according to what the user types
stores -> @State private var selectedStores = Set<String>()
, this is the Set of stores that are selected by the user. The initialiser in the TestView filters the array according to the selected stores.
Here's the GroceryItem model properties:
@Model
class GroceryItem: Codable, Identifiable {
var id : String?
var store : String
var category : String?
var subCategory : String?
var name : String
var itemDescription : [DescriptionItem]?
var price : String?
var pricePerType : String?
var image : String?
var url : String?
var isItemSelected : Bool?
var quantity = 1
}
The code is working fine but it takes too long to build the project. Like I mentioned, this is the simpler version of the code, the complex version doesn't even build, it gives this error: The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions
is there any way I could use a different approach to set the filters?
Here's the complex version of the code:
init(sort: SortDescriptor<GroceryItem>, searchText: String, stores: Set<String>, categories: Set<String>, subCategories: Set<String>) {
_groceryItems = Query(filter: #Predicate {
if searchText.isEmpty {
if stores.isEmpty {
if categories.isEmpty {
if subCategories.isEmpty {
return true
} else {
return subCategories.contains($0.subCategory!)
}
} else {
if subCategories.isEmpty {
return categories.contains($0.category!)
} else {
return categories.contains($0.category!) && subCategories.contains($0.subCategory!)
}
}
} else {
if categories.isEmpty {
if subCategories.isEmpty {
return true
} else {
return subCategories.contains($0.subCategory!)
}
} else {
if subCategories.isEmpty {
return categories.contains($0.category!)
} else {
return categories.contains($0.category!) && subCategories.contains($0.subCategory!)
}
}
}
} else {
if stores.isEmpty {
if categories.isEmpty {
if subCategories.isEmpty {
return $0.name.localizedStandardContains(searchText)
} else {
return $0.name.localizedStandardContains(searchText) && subCategories.contains($0.subCategory!)
}
} else {
if subCategories.isEmpty {
return $0.name.localizedStandardContains(searchText) && categories.contains($0.category!)
} else {
return $0.name.localizedStandardContains(searchText) && categories.contains($0.category!) && subCategories.contains($0.subCategory!)
}
}
} else {
if categories.isEmpty {
if subCategories.isEmpty {
return $0.name.localizedStandardContains(searchText) && stores.contains($0.store)
} else {
return $0.name.localizedStandardContains(searchText) && stores.contains($0.store) && subCategories.contains($0.subCategory!)
}
} else {
if subCategories.isEmpty {
return $0.name.localizedStandardContains(searchText) && stores.contains($0.store) && categories.contains($0.category!)
} else {
return $0.name.localizedStandardContains(searchText) && stores.contains($0.store) && categories.contains($0.category!) && subCategories.contains($0.subCategory!)
}
}
}
}
}, sort: [sort])
}
You should put as little code in #Predicate { ... }
as possible. The #Predicate
macro parses the code in the closure and expands to a "tree" of calls of the build_XXX
methods in PredicateExpressions
. The error is basically saying this "tree" is too complicated.
The deeply nested if
in your code seems to boil down to the condition:
(searchText.isEmpty || foo.name.localizedStandardContains(searchText)) &&
(stores.isEmpty || stores.contains(foo.store)) &&
(categories.isEmpty || categories.contains(foo.category!)) &&
(subCategories.isEmpty || subCategories.contains(foo.subCategory!))
On my machine, this is still too complicated for the compiler to handle, and we can extract all the xxx.isEmpty
out, as let
constants:
let searchTextEmpty = searchText.isEmpty
let storesEmpty = stores.isEmpty
let categoriesEmpty = categories.isEmpty
let subCategoriesEmpty = subCategories.isEmpty
let predicate = #Predicate<GroceryItem> { foo in
(searchTextEmpty || foo.name.localizedStandardContains(searchText)) &&
(storesEmpty || stores.contains(foo.store)) &&
(categoriesEmpty || categories.contains(foo.category!)) &&
(subCategoriesEmpty || subCategories.contains(foo.subCategory!))
}
This now compiles in a reasonable period of time.
You should not force unwrap category
and subCategory
if it is possible for them to be nil. I would instead create Set<String?>
from the Set<String>
s.
let optionalCategories = categories.map(Optional.some)
let optionalSubCategories = subCategories.map(Optional.some)
let searchTextEmpty = searchText.isEmpty
let storesEmpty = stores.isEmpty
let categoriesEmpty = optionalCategories.isEmpty
let subCategoriesEmpty = optionalSubCategories.isEmpty
let predicate = #Predicate<GroceryItem> { foo in
(searchTextEmpty || foo.name.localizedStandardContains(searchText)) &&
(storesEmpty || stores.contains(foo.store)) &&
(categoriesEmpty || optionalCategories.contains(foo.category)) &&
(subCategoriesEmpty || optionalSubCategories.contains(foo.subCategory))
}
Hopefully in future versions of Swift Data, the #Predicate
macro doesn't expand to a single giant "tree" of method calls that takes ages to type check, and instead declare let
constants to break the expression down.