I'm not even sure the title question makes sense. Please carry on reading regardless :)
EDIT: Cross-link to Apple's Developer Forum.
EDIT: Here's the source code of the project.
I have a SwiftUI view that is using a peculiar combination of a "fixed" data structure and data from Core Data, and I'm struggling to understand how to make the two cooperate. I am pretty sure this is not something covered in your average "advanced core data and swiftui tutorials" because I just spent three days down a rabbit hole of documentation, guides, and tutorials, and all I could come up with is not working. Let me explain.
My view is mainly a List
that shows a "fixed" set of rows. Let's call them "budget categories" -- yes, I'm making the nth budgeting app, bear with me. It looks more or less like this.
The structure of the budget stays the same -- unless the user changes it, but that's a problem for another day -- and Core Data holds a "monthly instance" of each category, let's call it BudgetCategoryEntry
. Because of this, the data that drives the list is in a budget
property that has sections
, and each section has categories
. The list's code goes like this:
var body: some View {
VStack {
// some other views
List {
ForEach(budget.sections) { section in
if !section.hidden {
Section(header: BudgetSectionCell(section: section,
amountText: ""
) {
ForEach(section.categories) { category in
if !category.hidden {
BudgetCategoryCell(category: category, amountText: formatAmount(findBudgetCategoryEntry(withId: category.id)?.amount ?? 0) ?? "--")
}
}
}
}
}
}
// more views
}
}
The smarter among you would have noticed the findBudgetCategoryEntry(withId: UUID) -> BudgetCategoryEntry
function. The idea is that I ask Core Data to give me the budget category entries corresponding to my budget, month, and year, using the ID from the fixed budget structure, and then I only display the amount in the cell.
Now, because I need to be able to specify month
and year
, I have them as @State
properties in my view:
@State private var month: Int = 0
@State private var year: Int = 0
Then I need a FetchRequest
but, because I need to set up NSPredicates
that refer to instance properties (month
and year
) I can't use the @FetchRequest
wrapper. So, this is what I have:
private var budgetEntriesRequest: FetchRequest<BudgetCategoryEntry>
private var budgetEntries: FetchedResults<BudgetCategoryEntry> { budgetEntriesRequest.wrappedValue }
init(budget: BudgetInfo, currentBudgetId: Binding<UUID?>) {
_budget = .init(initialValue: budget)
_currentBudgetId = currentBudgetId
var cal = Calendar(identifier: .gregorian)
cal.locale = Locale(identifier: self._budget.wrappedValue.localeIdentifier)
let now = Date()
_month = .init(initialValue: cal.component(.month, from: now))
_monthName = .init(initialValue: cal.monthSymbols[self._month.wrappedValue])
_year = .init(initialValue: cal.component(.year, from: now))
budgetEntriesRequest = FetchRequest<BudgetCategoryEntry>(entity: BudgetCategoryEntry.entity(),
sortDescriptors: [],
predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [
NSPredicate(format: "budgetId == %@", budget.id as CVarArg),
NSPredicate(format: "month == %i", _month.wrappedValue),
NSPredicate(format: "year == %i", _year.wrappedValue)
])
)
}
private func findBudgetCategoryEntry(withId: UUID) -> BudgetCategoryEntry? {
return budgetEntries.first(where: { $0.budgetCategoryId == withId })
}
This appears to work. Once. On view appear. Then good luck changing month
or year
and having the budgetEntries
update accordingly, which baffles me a bit because I thought any update to a @State
variable makes the entire view re-render, but then again maybe SwiftUI is a bit smarter than that and has some magicking to know what parts to update when a certain variable changes. And therein lies the problem: those variables only indirectly affect the List
, in that they should force a new fetch request which then in turn would update budgetEntries
. Except that that is not going to work either, because budgetEntries
does not affect directly the List
as that is mediated by the findBudgetCategoryEntry(withId: UUID)
function.
I then tried a number of combinations of setups, including making budgetEntriesRequest
a @State
variable, with an appropriate change of initializer, but that made accessing budgetEntries
go bang and I'm not entirely sure why, but I can see this is not the right approach.
The only other thing I can think of is to make findBudgetCategoryEntry
a property in the shape fo a Dictionary
where I use the category ID as a key to access the entry. I haven't tried this but, even if it does make sense in my head right now, it still depends on whether changing the three variables in the NSPredicates
actually do make the fetch request run again.
In summary: I'm open to suggestions.
EDIT: I tried the solution outlined here which looks like a sensible approach, and in fact it sort-of works in the sense that it provides more or less the same functionality AND the entries seem to change. Two problems, however:
- There seems to be a lag. Whenever I change month, for example, I get the budget entries for the month that was previously selected. I verified this with an
onChange(of: month)
which prints out theFetchedResults
, but maybe it's just because theFetchedResults
are updated afteronChange
is called. I don't know. - The list still does not update. I though surrounding it with the
DynamicFetchView
as in the example would somehow convince SwiftUI to consider theFetchedResults
as part of the dataset of the content view, but nope.
In response to 2, I tried something stupid but sometimes stupid works, which is to have a boolean @State
, toggle it onChange(of: month)
(and year), and then having something like .background(needsRefresh ? Color.clear : Color.clear)
on the list but of course that didn't work either.
EDIT 2: I verified that, regarding point 1 in the previous EDIT, the FetchedResults
do update correctly, if only after a while. So at this point I just can't get the List to redraw its cells with the correct values.
EDIT 3: A breakpoint later (on the line of BudgetCategoryCell) I can confirm the budgetEntries (FetchedResults
) are updated, and the List is redrawn when I simply change a @State
property (month
) -- and yes, I made sure I removed all the hacks and onChange
stuffs.
EDIT 4: Following nezzy's suggestion of building a ViewModel, I did the following:
class ViewModel: ObservableObject {
var budgetId: UUID {
didSet {
buildRequest()
objectWillChange.send()
}
}
var month: Int {
didSet {
buildRequest()
objectWillChange.send()
}
}
var year: Int {
didSet {
buildRequest()
objectWillChange.send()
}
}
var budgetEntriesRequest: FetchRequest<BudgetCategoryEntry>
public var budgetEntries: FetchedResults<BudgetCategoryEntry> { budgetEntriesRequest.wrappedValue }
init(budgetId: UUID, month: Int, year: Int) {
self.budgetId = budgetId
self.month = month
self.year = year
budgetEntriesRequest = FetchRequest<BudgetCategoryEntry>(entity: BudgetCategoryEntry.entity(),
sortDescriptors: [],
predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [
NSPredicate(format: "budgetId == %@", self.budgetId.uuidString),
NSPredicate(format: "month == %ld", self.month),
NSPredicate(format: "year == %ld", self.year)
])
)
}
func buildRequest() {
budgetEntriesRequest = FetchRequest<BudgetCategoryEntry>(entity: BudgetCategoryEntry.entity(),
sortDescriptors: [],
predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [
NSPredicate(format: "budgetId == %@", budgetId.uuidString),
NSPredicate(format: "month == %ld", month),
NSPredicate(format: "year == %ld", year)
])
)
}
private func findBudgetCategoryEntry(withId: UUID) -> BudgetCategoryEntry? {
return budgetEntries.first(where: { $0.budgetCategoryId == withId })
}
}
but I still get an EXC_BAD_INSTRUCTION
when accessing budgetEntriesRequest.wrappedValue
through the budgetEntries
computed property.
EDIT 5: I made a minimum reproducible example using the DynamicFetchView
technique and I managed to get the List
to update. So at least I know that this technique is viable and working. Now I have to figure out what I'm doing wrong in my main application.
EDIT 6: I tried replacing BudgetCategoryCell
with its content (from the HStack
down) and I can now get the list's cells to update. It looks like this might have been an issue with binding. I am now trying to figure out how to make BudgetCategoryCell
a view with bindings considering that I'm passing local variables into it.