在 SwiftUI 模型对象中发布计算属性

Published computed properties in SwiftUI model objects

假设我的 SwiftUI 应用程序中有一个如下所示的数据模型:

class Tallies: Identifiable, ObservableObject {
  let id = UUID()
  @Published var count = 0
}

class GroupOfTallies: Identifiable, ObservableObject {
  let id = UUID()
  @Published var elements: [Tallies] = []
}

我想将计算的 属性 添加到 GroupOfTallies,类似于以下内容:

// Returns the sum of counts of all elements in the group
var cumulativeCount: Int {
  return elements.reduce(0) { [=11=] + .count }
}

但是,我希望 SwiftUI 在 cumulativeCount 更改时更新视图。这会在 elements 更改(数组获得或丢失元素)或任何包含的 Tallies 对象的 count 字段更改时发生。

我研究过将其表示为 AnyPublisher,但我认为我对 Combine 的掌握不够好,无法使其正常工作。 中提到了这一点,但从中创建的 AnyPublisher 是基于已发布的 Double 而不是已发布的 Array。如果我尝试在不修改的情况下使用相同的方法,cumulativeCount 仅在元素数组更改时更新,而不是在其中一个元素的 count 属性 更改时更新。

最简单最快的是使用值类型模型。

这是一个简单的演示。测试并使用 Xcode 12 / iOS 14

struct TestTallies: View {
    @StateObject private var group = GroupOfTallies()     // SwiftUI 2.0
//    @ObservedObject private var group = GroupOfTallies()     // SwiftUI 1.0

    var body: some View {
        VStack {
            Text("Cumulative: \(group.cumulativeCount)")
            Divider()
            Button("Add") { group.elements.append(Tallies(count: 1)) }
            Button("Update") { group.elements[0].count = 5 }
        }
    }
}

struct Tallies: Identifiable {       // << make struct !!
  let id = UUID()
  var count = 0
}

class GroupOfTallies: Identifiable, ObservableObject {
  let id = UUID()
  @Published var elements: [Tallies] = []

    var cumulativeCount: Int {
      return elements.reduce(0) { [=10=] + .count }
    }
}

这里有多个问题需要解决。

首先,重要的是要了解 SwiftUI 在检测到变化时会更新视图的主体,无论是在 @State 属性 中,还是从 ObservableObject 中(通过 @ObservedObject@EnvironmentObject 属性 包装纸)。

在后一种情况下,这是通过 @Published 属性 或手动 objectWillChange.send() 完成的。 objectWillChangeObservableObjectPublisher 发布者,可在任何 ObservableObject.

上使用

IF 计算 属性 中的变化是由 together 与任何 @Published 属性 的变化 - 例如,当从某处添加另一个元素时:

elements.append(Talies())

那么不需要做任何其他事情 - SwiftUI 将重新计算观察它的视图,并将读取计算的新值 属性 cumulativeCount.


当然,如果 Tallies 对象之一的 .count 属性 发生变化,这不会导致 elements 发生变化,因为 Tallies 是参考类型。

给出简化示例的最佳方法实际上是使其成为值类型 - struct:

struct Tallies: Identifiable {
  let id = UUID()
  var count = 0
}

现在,任何 Tallies 对象的变化都会导致 elements 的变化,这将导致“观察”它的视图获得计算的新值属性。同样,不需要额外的工作。


但是,如果您坚持认为 Tallies 出于任何原因不能是值类型,那么您需要通过订阅 [=33] 来收听 Tallies 中的任何更改=] 出版商:

class GroupOfTallies: Identifiable, ObservableObject {
   let id = UUID()
   @Published var elements: [Tallies] = [] {
      didSet {
         cancellables = [] // cancel the previous subscription
         elements.publisher
            .flatMap { [=12=].objectWillChange }
            .sink(receiveValue: self.objectWillChange.send) 
            .store(in: &cancellables)
      }
   }

   private var cancellables = Set<AnyCancellable>

   var cumulativeCount: Int {
     return elements.reduce(0) { [=12=] + .count } // no changes here
   }
} 

以上将通过以下方式订阅 elements 数组中的更改(以说明添加和删除):

  • 将数组转换为每个数组元素的Sequence发布者
  • 然后 flatMap 再次将每个数组元素,这是一个 Tallies 对象,放入其 objectWillChange 发布者
  • 然后对于任何输出,调用 objectWillChange.send(),以通知观察它的视图它自己的更改。

这类似于 @New Dev 答案的最后一个选项,但更短一些,本质上只是将 objectWillChange 通知传递给父对象:

import Combine

class Tallies: Identifiable, ObservableObject {
  let id = UUID()
  @Published var count = 0

  func increase() {
    count += 1
  }
}

class GroupOfTallies: Identifiable, ObservableObject {
  let id = UUID()
  var sinks: [AnyCancellable] = []
  
  @Published var elements: [Tallies] = [] {
    didSet {
      sinks = elements.map {
        [=10=].objectWillChange.sink( receiveValue: objectWillChange.send)
      }
    }
  }

  var cumulativeCount: Int {
    return elements.reduce(0) { [=10=] + .count }
  }
}

SwiftUI 演示:

struct ContentView: View {
  @ObservedObject
  var group: GroupOfTallies

  init() {
    let group = GroupOfTallies()
    group.elements.append(contentsOf: [Tallies(), Tallies()])
    self.group = group
  }

  var body: some View {
    VStack(spacing: 50) {
      Text( "\(group.cumulativeCount)")
      Button( action: group.elements.first!.increase) {
        Text( "Increase first")
      }
      Button( action: group.elements.last!.increase) {
        Text( "Increase last")
      }
    }
  }
}