SwiftUI:如何更新由一组静态数据驱动的列表并从另一组动态数据中提取信息位?

Posted

技术标签:

【中文标题】SwiftUI:如何更新由一组静态数据驱动的列表并从另一组动态数据中提取信息位?【英文标题】:SwiftUI: How do I update a List that is driven by a static set of data and pulls bits of information from another dynamic set of data? 【发布时间】:2020-10-05 20:38:12 【问题描述】:

我什至不确定标题问题是否有意义。无论如何请继续阅读:)

编辑:交叉链接到Apple's Developer Forum。

编辑:这是项目的source code。

我有一个 SwiftUI 视图,它使用“固定”数据结构和来自 Core Data 的数据的特殊组合,我正在努力理解如何让两者合作。我很确定这不是您的平均“高级核心数据和 swiftui 教程”中涵盖的内容,因为我刚刚花了三天时间在文档、指南和教程的兔子洞中度过,而我所能想出的只是无法正常工作。让我解释一下。

我的视图主要是一个List,它显示了一组“固定”的行。让我们称它们为“预算类别”——是的,我正在制作第 n 个预算应用程序,请耐心等待。它看起来或多或少是这样的。

预算的结构保持不变——除非用户更改它,但这是另一天的问题——Core Data 拥有每个类别的“每月实例”,我们称之为BudgetCategoryEntry。因此,驱动列表的数据位于具有sectionsbudget 属性中,并且每个部分都有categories。列表的代码如下:

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
    

更聪明的人会注意到findBudgetCategoryEntry(withId: UUID) -> BudgetCategoryEntry 函数。我的想法是,我让 Core Data 使用固定预算结构中的 ID 为我提供与我的预算、月份和年份相对应的预算类别条目,然后我只在单元格中显示金额。

现在,因为我需要能够指定monthyear,所以我认为它们是@State 属性:

@State private var month: Int = 0
@State private var year: Int = 0

然后我需要一个FetchRequest,但是因为我需要设置引用实例属性的NSPredicatesmonthyear),所以我不能使用@FetchRequest 包装器。所以,这就是我所拥有的:

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 )

这似乎有效。一次。在视图中出现。然后祝你好运更改monthyear 并相应地更新budgetEntries,这让我有点困惑,因为我认为对@State 变量的任何更新都会重新渲染整个视图,但也许SwiftUI 又是比这更聪明一点,并且有一些魔法可以知道当某个变量发生变化时要更新哪些部分。这就是问题所在:这些变量只会间接影响List,因为它们应该强制一个新的获取请求,然后又会更新budgetEntries。除了这也不起作用,因为budgetEntries 不会直接影响List,因为它是由findBudgetCategoryEntry(withId: UUID) 函数调节的。

然后我尝试了多种设置组合,包括将 budgetEntriesRequest 设置为 @State 变量,并适当更改初始化程序,但这使得访问 budgetEntries 非常成功,我不完全确定为什么,但是我可以看到这不是正确的方法。

我唯一能想到的另一件事是使findBudgetCategoryEntry 成为Dictionary 形状的属性,其中我使用类别ID 作为访问条目的键。我还没有尝试过,但即使它现在在我的脑海中确实有意义,它仍然取决于更改 NSPredicates 中的三个变量是否真的会使获取请求再次运行。

总结:我愿意接受建议。

编辑: 我尝试了outlined here 的解决方案,这看起来是一种明智的方法,实际上它在某种意义上是有效的,因为它提供了或多或少相同的功能并且条目似乎改变。但是有两个问题:

    似乎有一个滞后。例如,每当我更改月份时,我都会获得先前选择的月份的预算条目。我用onChange(of: month) 验证了这一点,它打印出FetchedResults,但也许只是因为FetchedResults 被更新之后 onChange 被调用。我不知道。 列表仍未更新。我虽然用DynamicFetchView 包围它,就像示例中那样,会以某种方式说服SwiftUI 将FetchedResults 视为内容视图数据集的一部分,但不是。

针对 2,我尝试了一些愚蠢但有时愚蠢的工作,即拥有一个布尔值 @State,切换它 onChange(of: month)(和年份),然后在列表中添加类似 .background(needsRefresh ? Color.clear : Color.clear) 的东西,但当然也没有用。

编辑 2: 我证实,关于前一个编辑中的第 1 点,FetchedResults 确实会正确更新,即使只是在一段时间之后。所以在这一点上,我无法让 List 用正确的值重绘它的单元格。

编辑 3: 稍后的断点(在 BudgetCategoryCell 行上)我可以确认budgetEntries (FetchedResults) 已更新,并且当我简单地更改 @State 属性时重新绘制列表(month) -- 是的,我确保我删除了所有的黑客和onChange 的东西。

编辑 4: 在构建 ViewModel 的 nezzy's suggestion 之后,我执行了以下操作:

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 )
    

但是当通过budgetEntries 计算属性访问budgetEntriesRequest.wrappedValue 时,我仍然得到EXC_BAD_INSTRUCTION

编辑 5: 我使用DynamicFetchView 技术制作了一个最小可重现示例,并且我设法让List 进行更新。所以至少我知道这种技术是可行的并且有效的。现在我必须弄清楚我在主应用程序中做错了什么。

编辑 6: 我尝试将 BudgetCategoryCell 替换为其内容(从 HStack 向下),现在我可以更新列表的单元格。看起来这可能是绑定的问题。考虑到我将局部变量传递给它,我现在正试图弄清楚如何使 BudgetCategoryCell 成为一个带有绑定的视图。

【问题讨论】:

我相信你应该将这些东西分成子视图并在那里执行子请求(如***.com/a/59345830/12299030),但由于无法访问项目的复杂性,这只是猜测。我可以有项目吗? @Asperi 这是项目:git.morpheu5.net/Morpheu5/yDnab-ios,但您的方法与 nezzy 的答案不相似吗?无论如何,我尝试了一些非常相似的方法来重新创建谓词,但没有提取视图并且没有得到任何结果。我会尝试提取视图。但是,问题确实出在 List 视图上。来自 Core Data 的基础数据正在正确更新,只是列表中显示的数字没有改变。 好的,我构建并运行该项目......但它甚至不包含来自问题的变量(例如 findBudgetCategoryEntry)......我应该怎么做才能导致崩溃? (我在这里和那里玩了一下,但没有崩溃)。 没有崩溃。那不是问题。当您打开应用程序时,您应该有一个“默认预算”,您进入那里,点击闪电并选择“所有类别归零”,然后为您选择的月份创建预算条目。如果您打开创建的 sqlite 文件,您可以更改值,重新启动应用程序,然后尝试更改月份以查看列表没有更新。有些东西在我测试的时候被注释掉了。 【参考方案1】:

init(budget: BudgetInfo, currentBudgetId: Binding&lt;UUID?&gt;是视图吗?如果是这样,我认为问题不在于@State,而在于仅在初始化中创建了budgetEntriesRequest。将budgetEntriesRequest 设为计算变量应该可以解决此问题。

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)
                                                             ])
    )

编辑:

由于仅在值更改时才需要构建请求,因此构建 ViewModel 可能很有用,例如

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

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

   func findBudgetCategoryEntry(withId: UUID) -> BudgetCategoryEntry? 

【讨论】:

嗯,这是一个有趣的想法——这个想法确实闪过我的脑海。然而,这让我在下一行看到了一个讨厌的EXC_BAD_INSTRUCTION,在那里我访问了budgetEntriesRequest.wrappedValue 再一次,这不是每次访问budgetEntriesRequest 时都会重新创建FetchRequest 吗?这听起来适得其反并且是一个性能问题。到那时,还不如完全命令并在每次任何参数更改时重新创建它。 是的,您是对的,您只需要在参数更改时重新创建它。将 CoreData 代码移动到某种类型的 ViewModel 中可能会有所帮助。 我也尝试过这种方法***.com/a/60723054/554491 我不确定它是否是您所说的 ViewModel,但它并不完全有效。我是不是误会你的意思了? 更新我的评论,概述我的想法。【参考方案2】:

我一直在努力解决一个非常相似的问题here,虽然我无法直接解决问题,但有一个非常简单的解决方法非常有效。

剖析问题后,似乎 FetchRequest 在其谓词更改时不会重新获取。这很明显,因为如果您在 init 中打印 fetchrequest,您可以看到谓词确实发生了变化。但正如您可能已经体验过的那样,获取的结果永远不会改变。虽然我没有确凿的证据支持这一点,但我认为这可能是最近引入的一个错误,因为许多使用 SwiftUI 早期版本的教程声称可以解决这个问题,但大多数教程在尝试连接 绑定与谓词参数

我的解决方法是在没有谓词的情况下获取,然后使用绑定在正文中应用 Array.filter。这样您就可以在绑定值更改时获得更新的过滤结果。

对于您的代码,应该是这样的:

@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!)
    



但是,大多数情况下,过滤器应该比查询慢,而且我不确定实际的性能影响。

【讨论】:

这实际上是我想过但不想去的,因为从长远来看,您可能需要在过滤之前加载和卸载大量数据,虽然我们不在不再是 256 MB 或 RAM 的日子,如果可能的话,应该避免对内存施加压力。话虽如此,我还是设法让自己的代码正常工作。首先,通过使用 DynamicFetchView 方法,我实际上可以获得要更新的结果。其次,事实证明是 BudgetCategoryCell 视图没有正确更新。内联,它现在可以工作了。所以现在我必须弄清楚为什么。【参考方案3】:

事实证明List 正确刷新,但其中的BudgetCategoryCells 却没有。我将BudgetCategoryCell 的主体直接拉到ForEach 中,突然它开始按预期工作。现在我需要弄清楚如何更新该视图,但这是另一个问题。

【讨论】:

以上是关于SwiftUI:如何更新由一组静态数据驱动的列表并从另一组动态数据中提取信息位?的主要内容,如果未能解决你的问题,请参考以下文章

如何以编程方式从 SwiftUI 列表中删除行并刷新列表视图?

如何使用@EnvironmentObject 在 SwiftUI 中自动填充和更新列表?

如何从 SwiftUI 列表和领域中删除数据

正在添加驱动程序包

在 SwiftUI 中分组列表数据以分段显示

合并SwiftUI远程获取数据-ObjectBinding不会更新视图