3

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.

BudgetListView

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:

  1. 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 the FetchedResults, but maybe it's just because the FetchedResults are updated after onChange is called. I don't know.
  2. The list still does not update. I though surrounding it with the DynamicFetchView as in the example would somehow convince SwiftUI to consider the FetchedResults 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.

Morpheu5
  • 2,610
  • 6
  • 39
  • 72
  • I believe you should separate those things into subviews and perform sub requests there (like in https://stackoverflow.com/a/59345830/12299030), but due to complexity without access to project it is just guessing. Can I have project? – Asperi Oct 09 '20 at 03:59
  • @Asperi here's the project: http://git.morpheu5.net/Morpheu5/yDnab-iOS but isn't yours a similar approach to that of nezzy's answer? At any rate, I tried something very similar with recreating the predicates but without extracting the view and didn't get anywhere. I'll try extracting the view. However, the problem really is the List view. The underlying data from Core Data are getting updated correctly, it's just the numbers shown by the List don't change. – Morpheu5 Oct 09 '20 at 08:41
  • Ok, I build & run that project... but it does not even contain variables from question (eg. findBudgetCategoryEntry).. and what should I do to get a crash? (I played a bit with it here & there but no crash). – Asperi Oct 09 '20 at 16:00
  • There is no crash. That is not the problem. When you open up the app you should have a "Default budget", you go in there, tap the lightning bolt and select "All categories to zero" and that creates budget entries for the month you have selected. If you open the sqlite file that was created you can then change the values, restart the app, and try to change the month to see that the List does not update. Some things are commented out as I was testing. – Morpheu5 Oct 09 '20 at 17:40

3 Answers3

1

Is the init(budget: BudgetInfo, currentBudgetId: Binding<UUID?>for the View? If so I believe the problem isn't with @State but with the budgetEntriesRequest only being created in the init. Making the budgetEntriesRequest a computed variable should fix this.

var budgetEntriesRequest: FetchRequest<BudgetCategoryEntry> { 
    FetchRequest<BudgetCategoryEntry>(entity: BudgetCategoryEntry.entity(),
                                                             sortDescriptors: [],
                                                             predicate: NSCompoundPredicate(andPredicateWithSubpredicates: [
                                                                NSPredicate(format: "budgetId == %@", budget.id as CVarArg),
                                                                NSPredicate(format: "month == %i", month),
                                                                NSPredicate(format: "year == %i", year)
                                                             ])
    )
}

Edit:

Since the request needs to only be built when the values change it could be useful to build a ViewModel such as

class ViewModel: ObservableObject {
   var month: Int = 0 {
     didSet { 
       buildRequest()
       objectWillChange.send()
     }
   }

   func buildRequest() {
     //TODO: Build the request here 
   }

   func findBudgetCategoryEntry(withId: UUID) -> BudgetCategoryEntry? {}
}
nezzy
  • 11
  • 2
  • Mmmh, that's an interesting idea -- the thought did cross my mind. However, that pulls me a nasty `EXC_BAD_INSTRUCTION` on the next line, where I access `budgetEntriesRequest.wrappedValue`. – Morpheu5 Oct 06 '20 at 17:58
  • Then again, wouldn't this essentially recreate the `FetchRequest` every time `budgetEntriesRequest` is accessed? This sounds counterproductive and a performance issue. At that point, might as well go fully imperative and recreate it every time any of the parameters change. – Morpheu5 Oct 06 '20 at 18:43
  • Yeah you're right you need to recreate it only when the parameters change. Moving the CoreData code into a ViewModel of some type might help. – nezzy Oct 06 '20 at 19:11
  • I also tried this approach https://stackoverflow.com/a/60723054/554491 I'm not sure it's what you refer to as ViewModel, but it didn't quite work. Am I misunderstanding what you mean? – Morpheu5 Oct 06 '20 at 19:25
  • 1
    Update my comment with an outline of what I was thinking. – nezzy Oct 06 '20 at 19:33
  • I see! Thanks, I'll try that one too. – Morpheu5 Oct 06 '20 at 19:58
  • Mh. That was a nice idea, I'm not sure I implemented it right -- I updated my question, see edit 4. No luck though. – Morpheu5 Oct 06 '20 at 20:23
  • Looks right just double checking did you make the ViewModel an @ObservedObject in the View? – nezzy Oct 06 '20 at 20:26
  • Yes I did. It looks as though there is some object being released in the getter of `budgetEntriesRequest.wrappedValue`. The exception is triggered in `FetchRequest.wrappedValue.getter` about halfway through there's a `testq` following a call to `objc_release` and then the execution jumps to the end to the `ud2` opcode which is what triggers the exception. – Morpheu5 Oct 06 '20 at 20:45
  • My last guess would be to try calling update() before getting the wrapped value. If not you can probably use NSFetchRequest to make this work. – nezzy Oct 06 '20 at 21:03
  • That was my last guess too. However I end up with a new piece of the puzzle: Context in environment is not connected to a persistent store coordinator. This confuses me even more because my context is definitely connected to a persistent store coordinator. In fact, it's the one that Xcode set up for me. And as far as I can tell this ViewModel doesn't escape the view hierarchy into which I pass the context from the environment -- and if it did I'm not sure how to pass it to the view model as it's a `@ObservedObject var budgetViewModel: ViewModel` in the main view. – Morpheu5 Oct 06 '20 at 21:10
1

I'm someone who's been struggling with a very similar problem here, and although I haven't been able to solve the problem directly, there's a pretty simple workaround that works pretty well.

Upon dissecting the problem, it seems that FetchRequests do not re-fetch when their predicates changes. This is apparent because if you print the fetchrequest in init, you can see that the predicate does change. But as you might have experienced already, the fetched results never change. While I don't have solid evidence to support this, I think this may be a bug introduced recently, as many tutorials with the previous version of SwiftUI claim to solve the problem yet most of them throw type errors when trying to connect the Binding with the predicate argument

My workaround was to fetch without a predicate, then apply Array.filter in the body using the binding. This way you can get updated filter results when the binding value changes.

For your code, it would be something like this:

@FetchRequest(entity: BudgetCategoryEntry.entity(), sortDescriptors: []) var budgetEntries: FetchedResults<BudgetCategoryEntry>

var body: some View {
    let entryArray = budgetEntries.filter { (entry) -> Bool in
         entry.month == month && entry.budgetId == budgetId && entry.year == year
    }
    if entryArray.count != 0 {
        Text("error / no data")
    } else {
        EntryView(entry: entryArray.first!)
    }
}


However, filter should be slower than querying most of the time, and I'm unsure of the actual performance impact.

Kun Jeong
  • 76
  • 3
  • 1
    This is actually something I thought about but didn't want to go to because in the long run you may have to load and unload a large amount of data before filtering, and although we're not in the days of 256 MB or RAM anymore, putting pressure on memory is something to be avoided if possible. Having said this, I sort of managed to get my own code to work. First, by using the DynamicFetchView approach I can actually get the results to update. Second, it turns out that it was the BudgetCategoryCell view that wasn't updating properly. Inlined that, it now works. So now I have to figure out why. – Morpheu5 Oct 13 '20 at 18:15
0

It turns out that the List was correctly refreshing, but the BudgetCategoryCells within it were not. I pulled out the body of BudgetCategoryCell directly into the ForEach and all of a sudden it started working as expected. Now I need to figure out how to make that view update, but this is a matter for another question.

Morpheu5
  • 2,610
  • 6
  • 39
  • 72