如何在 SwiftUI 中观察一组 CoreData 对象?
How can I observe an array of CoreData objects in SwiftUI?
我是 Swift 的新手UI,我很难找到解决我遇到的问题的方法,我的 UI 在对 Core Data 对象进行更改时没有更新。在过去的三天里,我一直在研究这个错误,并通过 Whosebug 和其他来源进行了搜索,但我没有找到任何可以解决我的问题的方法。
本质上,我有一个核心数据实体“书籍”,它与其他实体(即“作者”、“编辑”、“流派”等)有关系,而这些实体又继承自抽象实体(“AbstractName” ):
我在 Swift 中创建了一个视图UI,可以毫无问题地显示图书数据。我使用全局视图模型来跟踪 selected 书(全局,以便我可以根据一本书是否 selected 在 macOS 上启用和禁用菜单栏项目)并注入它作为环境对象进入视图。一切正常,当我编辑这本书时,视图也正确地更新了除关系之外的所有内容。
它不适用于关系的原因是因为我使用子视图以一致的方式显示它们,而 SwiftUI 在数据更改时不会更新。我必须 select 另一本书,然后返回我刚刚编辑的那本书以更新数据。我知道这是行不通的,因为核心数据对象数组不符合 ObservableObject,因此您无法观察到它,但我不知道如何修复它。
现在终于有了一些代码。我已经稍微简化了代码以仅包含必要的部分,但我的项目也在 GitHub (https://github.com/eiskalteschatten/BookJournalSwift) 上,所以我将 post 和 link 到代码片段下方的完整文件。我将关系传递给的子视图称为 WrappingSmallChipsWithName
.
显示图书数据的视图:
import SwiftUI
struct MacBookView: View {
@EnvironmentObject private var globalViewModel: GlobalViewModel
var body: some View {
if let book = globalViewModel.selectedBook {
let offset = 100.0
let maxWidth = 800.0
let textWithLabelSpacing = 50.0
let groupBoxSpacing = 20.0
let groupBoxWidth = (maxWidth / 2) - (groupBoxSpacing / 2)
ScrollView {
LazyVStack(spacing: groupBoxSpacing) {
if book.authors != nil && book.sortedAuthors.count > 0 {
WrappingSmallChipsWithName<Author>(data: book.sortedAuthors, chipColor: AUTHOR_COLOR)
}
Group {
HStack(alignment: .top, spacing: groupBoxSpacing) {
MacBookViewGroupBox(title: "Editors", icon: "person.2.wave.2", width: groupBoxWidth) {
if book.editors != nil && book.sortedEditors.count > 0 {
WrappingSmallChipsWithName<Editor>(data: book.sortedEditors, chipColor: EDITOR_COLOR, alignment: .leading)
}
else {
Text("No editors selected")
}
}
MacBookViewGroupBox(title: "Genres", icon: "text.book.closed", width: groupBoxWidth) {
if book.genres != nil && book.sortedGenres.count > 0 {
WrappingSmallChipsWithName<Genre>(data: book.sortedGenres, chipColor: GENRE_COLOR, alignment: .leading)
}
else {
Text("No genres selected")
}
}
}
HStack(alignment: .top, spacing: groupBoxSpacing) {
MacBookViewGroupBox(title: "Lists", icon: "list.bullet.rectangle", width: groupBoxWidth) {
if book.lists != nil && book.sortedLists.count > 0 {
WrappingSmallChipsWithName<ListOfBooks>(data: book.sortedLists, chipColor: LIST_COLOR, alignment: .leading)
}
else {
Text("No lists selected")
}
}
MacBookViewGroupBox(title: "Tags", icon: "tag", width: groupBoxWidth) {
if let unwrappedTags = book.tags {
if unwrappedTags.allObjects.count > 0 {
WrappingSmallChipsWithName<Tag>(data: unwrappedTags.allObjects as! [Tag], chipColor: TAG_COLOR, alignment: .leading)
}
else {
Text("No tags selected")
}
}
}
}
HStack(alignment: .top, spacing: groupBoxSpacing) {
MacBookViewGroupBox(title: "Translators", icon: "person.2", width: groupBoxWidth) {
if book.translators != nil && book.sortedTranslators.count > 0 {
WrappingSmallChipsWithName<Translator>(data: book.sortedTranslators, chipColor: TRANSLATOR_COLOR, alignment: .leading)
}
else {
Text("No translators selected")
}
}
}
}
}
}
}
}
}
}
显示关系数据的WrappingSmallChipsWithName子视图:
import SwiftUI
import WrappingHStack
struct WrappingSmallChipsWithName<T: AbstractName>: View {
var title: String?
var data: [T]
var chipColor: Color = .gray
var alignment: HorizontalAlignment = .center
var body: some View {
VStack(alignment: alignment, spacing: 1) {
if title != nil {
Text(title!)
}
if data.count > 0 {
Spacer()
WrappingHStack(data, id: \.self, alignment: alignment) { item in
SmallChip(background: chipColor) {
HStack(alignment: .center, spacing: 4) {
if let name = item.name {
Text(name)
}
}
}
.padding(.horizontal, 1)
.padding(.vertical, 3)
.contextMenu {
let copyButtonLabel = item.name != nil ? "Copy \"\(item.name!)\"" : "Copy"
Button(copyButtonLabel) {
if let name = item.name {
copyTextToClipboard(name)
}
}
.disabled(item.name == nil)
}
}
.frame(minWidth: 250)
}
}
}
}
全局视图模型:
import SwiftUI
import CoreData
final class GlobalViewModel: ObservableObject {
private let defaults = UserDefaults.standard
private var viewContext: NSManagedObjectContext?
private let selectedBookURLKey = "GlobalViewModel.selectedBookURL"
@Published var selectedBook: Book? {
didSet {
// Use user defaults instead of @SceneStorage so it can be initialized in the constructor
defaults.set(selectedBook?.objectID.uriRepresentation(), forKey: selectedBookURLKey)
}
}
#if os(iOS)
@Published var globalError: String?
@Published var globalErrorSubtext: String?
@Published var showGlobalErrorAlert: Bool = false {
didSet {
if !showGlobalErrorAlert {
globalError = nil
globalErrorSubtext = nil
}
}
}
#endif
static let shared: GlobalViewModel = GlobalViewModel()
private init() {
let persistenceController = PersistenceController.shared
viewContext = persistenceController.container.viewContext
if let url = defaults.url(forKey: selectedBookURLKey),
let objectID = viewContext!.persistentStoreCoordinator!.managedObjectID(forURIRepresentation: url),
let book = try? viewContext!.existingObject(with: objectID) as? Book {
selectedBook = book
}
}
#if os(macOS)
func promptToDeleteBook() {
let alert = NSAlert()
alert.messageText = "Are you sure you want to delete this book?"
alert.informativeText = "This is permanent."
alert.addButton(withTitle: "No")
alert.addButton(withTitle: "Yes")
alert.alertStyle = .warning
let delete = alert.runModal() == NSApplication.ModalResponse.alertSecondButtonReturn
if delete {
deleteBook()
}
}
func deleteBook() {
withAnimation {
if let unwrappedBook = selectedBook {
selectedBook = nil
viewContext!.delete(unwrappedBook)
do {
try viewContext!.save()
} catch {
handleCoreDataError(error as NSError)
}
}
}
}
#endif
}
https://github.com/eiskalteschatten/BookJournalSwift/blob/main/Shared/Models/GlobalViewModel.swift
定义“sortedAuthors”、“sortedEditors”等的扩展:
import SwiftUI
extension Book {
public var sortedAuthors: [Author] {
let set = authors as? Set<Author> ?? []
return set.sorted {
[=13=].wrappedName < .wrappedName
}
}
public var sortedEditors: [Editor] {
let set = editors as? Set<Editor> ?? []
return set.sorted {
[=13=].wrappedName < .wrappedName
}
}
public var sortedTranslators: [Translator] {
let set = translators as? Set<Translator> ?? []
return set.sorted {
[=13=].wrappedName < .wrappedName
}
}
public var sortedLists: [ListOfBooks] {
let set = lists as? Set<ListOfBooks> ?? []
return set.sorted {
[=13=].wrappedName < .wrappedName
}
}
public var sortedGenres: [Genre] {
let set = genres as? Set<Genre> ?? []
return set.sorted {
[=13=].wrappedName < .wrappedName
}
}
}
extension AbstractName {
public var wrappedName: String {
name ?? "Unnamed"
}
}
https://github.com/eiskalteschatten/BookJournalSwift/blob/main/Shared/Extensions/CoreData.swift
我只 post 编辑了用于 macOS 的视图的代码。我也有一个类似的用于 iOS,但它使用相同的子视图 (WrappingSmallChipsWithName) 并且有同样的问题。
我知道我需要以某种方式将可以观察到的对象或视图模型传递给子视图,但是由于子视图是通用的并且需要访问不同类型的实体(它们都继承自同一个抽象实体),我只是不确定该怎么做。
这里有人有什么建议吗?我开始为这个看似很小的问题而烦恼。
我们使用FetchRequest
(或@FetchRequest
)来观察托管对象数组。要观察相关对象的数组,请提供 NSPredicate
,例如观察作者使用的书籍数组:"author = %@", author
。例如
private var fetchRequest: FetchRequest<Book>
private var books: FetchedResults<Book> {
fetchRequest.wrappedValue
}
init(author: Author) {
let sortDescriptors = [SortDescriptor(\Book.timestamp, order: sortAscending ? .forward : .reverse)]
fetchRequest = FetchRequest(sortDescriptors: sortDescriptors, predicate: NSPredicate(format: "author = %@", author), animation: .default)
FetchRequest
是一个 DynamicProperty
结构,与普通结构相比,它具有一些特殊的能力。在 SwiftUI 调用 body
之前,它会在所有动态属性上调用 update
,而在 FetchRequest
的情况下,它会从 @Environment
.[=33= 中读取托管对象上下文]
当使用 Core Data 时,我们可以生成一个 NSManagedObject
subclass 或扩展来添加额外的功能,例如class Book: NSManagedObject
。在模型编辑器中,从菜单中选择 generate model subclass。将 BookModel
中的所有内容移动到 Book 扩展中。这些托管对象确实符合 ObservableObject
,它允许您使用 @ObservedObject
,这再次使 View
结构值类型表现得像视图模型对象,因为当它检测到更改时,body
将被调用。
在 SwiftUI 中,我们尽量不将对象用于视图状态,因为 SwiftUI 使用值类型旨在消除对象的典型错误,因此我建议删除 GlobalViewModel
class 并改为使用 SwiftUI 特性 @State
和 @AppStorage
这使得 View
结构值类型具有视图模型对象语义。
我是 Swift 的新手UI,我很难找到解决我遇到的问题的方法,我的 UI 在对 Core Data 对象进行更改时没有更新。在过去的三天里,我一直在研究这个错误,并通过 Whosebug 和其他来源进行了搜索,但我没有找到任何可以解决我的问题的方法。
本质上,我有一个核心数据实体“书籍”,它与其他实体(即“作者”、“编辑”、“流派”等)有关系,而这些实体又继承自抽象实体(“AbstractName” ):
我在 Swift 中创建了一个视图UI,可以毫无问题地显示图书数据。我使用全局视图模型来跟踪 selected 书(全局,以便我可以根据一本书是否 selected 在 macOS 上启用和禁用菜单栏项目)并注入它作为环境对象进入视图。一切正常,当我编辑这本书时,视图也正确地更新了除关系之外的所有内容。
它不适用于关系的原因是因为我使用子视图以一致的方式显示它们,而 SwiftUI 在数据更改时不会更新。我必须 select 另一本书,然后返回我刚刚编辑的那本书以更新数据。我知道这是行不通的,因为核心数据对象数组不符合 ObservableObject,因此您无法观察到它,但我不知道如何修复它。
现在终于有了一些代码。我已经稍微简化了代码以仅包含必要的部分,但我的项目也在 GitHub (https://github.com/eiskalteschatten/BookJournalSwift) 上,所以我将 post 和 link 到代码片段下方的完整文件。我将关系传递给的子视图称为 WrappingSmallChipsWithName
.
显示图书数据的视图:
import SwiftUI
struct MacBookView: View {
@EnvironmentObject private var globalViewModel: GlobalViewModel
var body: some View {
if let book = globalViewModel.selectedBook {
let offset = 100.0
let maxWidth = 800.0
let textWithLabelSpacing = 50.0
let groupBoxSpacing = 20.0
let groupBoxWidth = (maxWidth / 2) - (groupBoxSpacing / 2)
ScrollView {
LazyVStack(spacing: groupBoxSpacing) {
if book.authors != nil && book.sortedAuthors.count > 0 {
WrappingSmallChipsWithName<Author>(data: book.sortedAuthors, chipColor: AUTHOR_COLOR)
}
Group {
HStack(alignment: .top, spacing: groupBoxSpacing) {
MacBookViewGroupBox(title: "Editors", icon: "person.2.wave.2", width: groupBoxWidth) {
if book.editors != nil && book.sortedEditors.count > 0 {
WrappingSmallChipsWithName<Editor>(data: book.sortedEditors, chipColor: EDITOR_COLOR, alignment: .leading)
}
else {
Text("No editors selected")
}
}
MacBookViewGroupBox(title: "Genres", icon: "text.book.closed", width: groupBoxWidth) {
if book.genres != nil && book.sortedGenres.count > 0 {
WrappingSmallChipsWithName<Genre>(data: book.sortedGenres, chipColor: GENRE_COLOR, alignment: .leading)
}
else {
Text("No genres selected")
}
}
}
HStack(alignment: .top, spacing: groupBoxSpacing) {
MacBookViewGroupBox(title: "Lists", icon: "list.bullet.rectangle", width: groupBoxWidth) {
if book.lists != nil && book.sortedLists.count > 0 {
WrappingSmallChipsWithName<ListOfBooks>(data: book.sortedLists, chipColor: LIST_COLOR, alignment: .leading)
}
else {
Text("No lists selected")
}
}
MacBookViewGroupBox(title: "Tags", icon: "tag", width: groupBoxWidth) {
if let unwrappedTags = book.tags {
if unwrappedTags.allObjects.count > 0 {
WrappingSmallChipsWithName<Tag>(data: unwrappedTags.allObjects as! [Tag], chipColor: TAG_COLOR, alignment: .leading)
}
else {
Text("No tags selected")
}
}
}
}
HStack(alignment: .top, spacing: groupBoxSpacing) {
MacBookViewGroupBox(title: "Translators", icon: "person.2", width: groupBoxWidth) {
if book.translators != nil && book.sortedTranslators.count > 0 {
WrappingSmallChipsWithName<Translator>(data: book.sortedTranslators, chipColor: TRANSLATOR_COLOR, alignment: .leading)
}
else {
Text("No translators selected")
}
}
}
}
}
}
}
}
}
}
显示关系数据的WrappingSmallChipsWithName子视图:
import SwiftUI
import WrappingHStack
struct WrappingSmallChipsWithName<T: AbstractName>: View {
var title: String?
var data: [T]
var chipColor: Color = .gray
var alignment: HorizontalAlignment = .center
var body: some View {
VStack(alignment: alignment, spacing: 1) {
if title != nil {
Text(title!)
}
if data.count > 0 {
Spacer()
WrappingHStack(data, id: \.self, alignment: alignment) { item in
SmallChip(background: chipColor) {
HStack(alignment: .center, spacing: 4) {
if let name = item.name {
Text(name)
}
}
}
.padding(.horizontal, 1)
.padding(.vertical, 3)
.contextMenu {
let copyButtonLabel = item.name != nil ? "Copy \"\(item.name!)\"" : "Copy"
Button(copyButtonLabel) {
if let name = item.name {
copyTextToClipboard(name)
}
}
.disabled(item.name == nil)
}
}
.frame(minWidth: 250)
}
}
}
}
全局视图模型:
import SwiftUI
import CoreData
final class GlobalViewModel: ObservableObject {
private let defaults = UserDefaults.standard
private var viewContext: NSManagedObjectContext?
private let selectedBookURLKey = "GlobalViewModel.selectedBookURL"
@Published var selectedBook: Book? {
didSet {
// Use user defaults instead of @SceneStorage so it can be initialized in the constructor
defaults.set(selectedBook?.objectID.uriRepresentation(), forKey: selectedBookURLKey)
}
}
#if os(iOS)
@Published var globalError: String?
@Published var globalErrorSubtext: String?
@Published var showGlobalErrorAlert: Bool = false {
didSet {
if !showGlobalErrorAlert {
globalError = nil
globalErrorSubtext = nil
}
}
}
#endif
static let shared: GlobalViewModel = GlobalViewModel()
private init() {
let persistenceController = PersistenceController.shared
viewContext = persistenceController.container.viewContext
if let url = defaults.url(forKey: selectedBookURLKey),
let objectID = viewContext!.persistentStoreCoordinator!.managedObjectID(forURIRepresentation: url),
let book = try? viewContext!.existingObject(with: objectID) as? Book {
selectedBook = book
}
}
#if os(macOS)
func promptToDeleteBook() {
let alert = NSAlert()
alert.messageText = "Are you sure you want to delete this book?"
alert.informativeText = "This is permanent."
alert.addButton(withTitle: "No")
alert.addButton(withTitle: "Yes")
alert.alertStyle = .warning
let delete = alert.runModal() == NSApplication.ModalResponse.alertSecondButtonReturn
if delete {
deleteBook()
}
}
func deleteBook() {
withAnimation {
if let unwrappedBook = selectedBook {
selectedBook = nil
viewContext!.delete(unwrappedBook)
do {
try viewContext!.save()
} catch {
handleCoreDataError(error as NSError)
}
}
}
}
#endif
}
https://github.com/eiskalteschatten/BookJournalSwift/blob/main/Shared/Models/GlobalViewModel.swift
定义“sortedAuthors”、“sortedEditors”等的扩展:
import SwiftUI
extension Book {
public var sortedAuthors: [Author] {
let set = authors as? Set<Author> ?? []
return set.sorted {
[=13=].wrappedName < .wrappedName
}
}
public var sortedEditors: [Editor] {
let set = editors as? Set<Editor> ?? []
return set.sorted {
[=13=].wrappedName < .wrappedName
}
}
public var sortedTranslators: [Translator] {
let set = translators as? Set<Translator> ?? []
return set.sorted {
[=13=].wrappedName < .wrappedName
}
}
public var sortedLists: [ListOfBooks] {
let set = lists as? Set<ListOfBooks> ?? []
return set.sorted {
[=13=].wrappedName < .wrappedName
}
}
public var sortedGenres: [Genre] {
let set = genres as? Set<Genre> ?? []
return set.sorted {
[=13=].wrappedName < .wrappedName
}
}
}
extension AbstractName {
public var wrappedName: String {
name ?? "Unnamed"
}
}
https://github.com/eiskalteschatten/BookJournalSwift/blob/main/Shared/Extensions/CoreData.swift
我只 post 编辑了用于 macOS 的视图的代码。我也有一个类似的用于 iOS,但它使用相同的子视图 (WrappingSmallChipsWithName) 并且有同样的问题。
我知道我需要以某种方式将可以观察到的对象或视图模型传递给子视图,但是由于子视图是通用的并且需要访问不同类型的实体(它们都继承自同一个抽象实体),我只是不确定该怎么做。
这里有人有什么建议吗?我开始为这个看似很小的问题而烦恼。
我们使用FetchRequest
(或@FetchRequest
)来观察托管对象数组。要观察相关对象的数组,请提供 NSPredicate
,例如观察作者使用的书籍数组:"author = %@", author
。例如
private var fetchRequest: FetchRequest<Book>
private var books: FetchedResults<Book> {
fetchRequest.wrappedValue
}
init(author: Author) {
let sortDescriptors = [SortDescriptor(\Book.timestamp, order: sortAscending ? .forward : .reverse)]
fetchRequest = FetchRequest(sortDescriptors: sortDescriptors, predicate: NSPredicate(format: "author = %@", author), animation: .default)
FetchRequest
是一个 DynamicProperty
结构,与普通结构相比,它具有一些特殊的能力。在 SwiftUI 调用 body
之前,它会在所有动态属性上调用 update
,而在 FetchRequest
的情况下,它会从 @Environment
.[=33= 中读取托管对象上下文]
当使用 Core Data 时,我们可以生成一个 NSManagedObject
subclass 或扩展来添加额外的功能,例如class Book: NSManagedObject
。在模型编辑器中,从菜单中选择 generate model subclass。将 BookModel
中的所有内容移动到 Book 扩展中。这些托管对象确实符合 ObservableObject
,它允许您使用 @ObservedObject
,这再次使 View
结构值类型表现得像视图模型对象,因为当它检测到更改时,body
将被调用。
在 SwiftUI 中,我们尽量不将对象用于视图状态,因为 SwiftUI 使用值类型旨在消除对象的典型错误,因此我建议删除 GlobalViewModel
class 并改为使用 SwiftUI 特性 @State
和 @AppStorage
这使得 View
结构值类型具有视图模型对象语义。