SwiftUI 对 List 中的 CoreData 对象重新排序



我想更改从核心数据中检索对象的列表中的行顺序。移动行有效,但问题是我无法保存更改。我不知道如何保存更改的 CoreData 对象的索引。

这是我的代码:

核心数据类:

public class CoreItem: NSManagedObject, Identifiable{
@NSManaged public var name: String
}
extension CoreItem{
static func getAllCoreItems() -> NSFetchRequest <CoreItem> {
let request: NSFetchRequest<CoreItem> = CoreItem.fetchRequest() as! NSFetchRequest<CoreItem>
let sortDescriptor = NSSortDescriptor(key: "date", ascending: true)
request.sortDescriptors = [sortDescriptor]
return request
}
}
extension Collection where Element == CoreItem, Index == Int {
func move(set: IndexSet, to: Int,  from managedObjectContext: NSManagedObjectContext) {
do {
try managedObjectContext.save()
} catch {
let nserror = error as NSError
fatalError("Unresolved error (nserror), (nserror.userInfo)")
}
}
} 

列表:


struct CoreItemList: View {
@Environment(.managedObjectContext) var managedObjectContext
@FetchRequest(fetchRequest: CoreItem.getAllCoreItems()) var CoreItems: FetchedResults<CoreItem>

var body: some View {
NavigationView{
List {
ForEach(CoreItems, id: .self){
coreItem in
CoreItemRow(coreItem: coreItem)
}.onDelete {
IndexSet in let deleteItem = self.CoreItems[IndexSet.first!]
self.managedObjectContext.delete(deleteItem)
do {
try self.managedObjectContext.save()
} catch {
print(error)
}
}
.onMove {
self.CoreItems.move(set: $0, to: $1, from: self.managedObjectContext)
}
}
.navigationBarItems(trailing: EditButton())
}.navigationViewStyle(StackNavigationViewStyle())
}
}

谢谢你的帮助。

警告:下面的答案未经测试,尽管我在示例项目中使用了并行逻辑并且该项目似乎正在工作。

答案有几个部分。正如Joakim Danielson所说,为了保持用户的首选顺序,您需要将订单保存在CoreItem类中。修订后的类如下所示:

public class CoreItem: NSManagedObject, Identifiable{
@NSManaged public var name: String
@NSManaged public var userOrder: Int16
}

第二部分是根据userOrder属性对项目进行排序。在初始化时,userOrder通常默认为零,因此在userOrder内按name排序也可能很有用。假设你想这样做,那么在CoreItemList代码中:

@FetchRequest( entity: CoreItem.entity(),
sortDescriptors:
[
NSSortDescriptor(
keyPath: CoreItem.userOrder,
ascending: true),
NSSortDescriptor(
keyPath:CoreItem.name,
ascending: true )
]
) var coreItems: FetchedResults<CoreItem>

第三部分是你需要告诉swiftui允许用户修改列表的顺序。如示例中所示,这是使用onMove修饰符完成的。在该修饰符中,您可以执行按用户首选顺序对列表重新排序所需的操作。例如,您可以调用一个名为move的便利函数,以便修饰符为:

.onMove( perform: move )

您的move函数将传递一个索引集和一个 Int。索引集包含 FetchRequestResult 中要移动的所有项(通常只有一个项)。Int 指示它们应移动到的位置。逻辑是:

private func move( from source: IndexSet, to destination: Int) 
{
// Make an array of items from fetched results
var revisedItems: [ CoreItem ] = coreItems.map{ $0 }
// change the order of the items in the array
revisedItems.move(fromOffsets: source, toOffset: destination )
// update the userOrder attribute in revisedItems to 
// persist the new order. This is done in reverse order 
// to minimize changes to the indices.
for reverseIndex in stride( from: revisedItems.count - 1,
through: 0,
by: -1 )
{
revisedItems[ reverseIndex ].userOrder =
Int16( reverseIndex )
}
}

技术提醒:存储在修订项中的项是类(即通过引用),因此更新这些项必然会更新获取结果中的项。@FetchedResults包装器将导致您的用户界面反映新订单。

诚然,我是 SwiftUI 的新手。可能会有更优雅的解决方案!

Paul Hudson(Hacking with Swift)有更多细节。下面是有关在列表中移动数据的信息的链接。下面是一个将核心数据与 SwiftUI 配合使用的链接(它涉及删除列表中的项目,但与onMove逻辑非常相似)

您可以在下面找到解决此问题的更通用方法。该算法最大限度地减少了需要更新的 CoreData 实体的数量,这与接受的答案相反。我的解决方案受到以下文章的启发:https://www.appsdissected.com/order-core-data-entities-maximum-speed/

首先,我声明如下protocol以与您的模型struct(或class一起使用):

protocol Sortable {
var sortOrder: Int { get set }
}

例如,假设我们有一个实现Sortable协议的SortItem模型,定义为:

struct SortItem: Identifiable, Sortable {
var id = UUID()
var title = ""
var sortOrder = 0
}

我们还有一个简单的 SwiftUIView,其相关ViewModel定义为(精简版本):

struct ItemsView: View {
@ObservedObject private(set) var viewModel: ViewModel

var body: some View {
NavigationView {
List {
ForEach(viewModel.items) { item in
Text(item.title)
}
.onMove(perform: viewModel.move(from:to:))
}
}
.navigationBarItems(trailing: EditButton())
}
}
extension ItemsView {
class ViewModel: ObservableObject {
@Published var items = [SortItem]()

func move(from source: IndexSet, to destination: Int) {
items.move(fromOffsets: source, toOffset: destination)
// Note: Code that updates CoreData goes here, see below
}
}
}

在继续算法之前,我想注意的是,在将项目向下移动列表时,move函数中的destination变量不包含新索引。假设只移动单个项目,检索新索引(在移动完成后)可以按如下方式实现:

func move(from source: IndexSet, to destination: Int) {
items.move(fromOffsets: source, toOffset: destination)

if let oldIndex = source.first, oldIndex != destination {
let newIndex = oldIndex < destination ? destination - 1 : destination

// Note: Code that updates CoreData goes here, see below
}
}

算法本身是作为ArrayElementSortable类型的情况extension实现的。它由一个递归updateSortOrder函数和一个private辅助函数组成,enclosingIndices该函数检索围绕数组某个索引的索引,同时保持在数组边界内。完整的算法如下(如下所述):

extension Array where Element: Sortable {
func updateSortOrder(around index: Int, for keyPath: WritableKeyPath<Element, Int> = .sortOrder, spacing: Int = 32, offset: Int = 1, _ operation: @escaping (Int, Int) -> Void) {
if let enclosingIndices = enclosingIndices(around: index, offset: offset) {
if let leftIndex = enclosingIndices.first(where: { $0 != index }),
let rightIndex = enclosingIndices.last(where: { $0 != index }) {
let left = self[leftIndex][keyPath: keyPath]
let right = self[rightIndex][keyPath: keyPath]

if left != right && (right - left) % (offset * 2) == 0 {
let spacing = (right - left) / (offset * 2)
var sortOrder = left
for index in enclosingIndices.indices {
if self[index][keyPath: keyPath] != sortOrder {
operation(index, sortOrder)
}
sortOrder += spacing
}
} else {
updateSortOrder(around: index, for: keyPath, spacing: spacing, offset: offset + 1, operation)
}
}
} else {
for index in self.indices {
let sortOrder = index * spacing
if self[index][keyPath: keyPath] != sortOrder {
operation(index, sortOrder)
}
}
}
}

private func enclosingIndices(around index: Int, offset: Int) -> Range<Int>? {
guard self.count - 1 >= offset * 2 else { return nil }
var leftIndex = index - offset
var rightIndex = index + offset

while leftIndex < startIndex {
leftIndex += 1
rightIndex += 1
}
while rightIndex > endIndex - 1 {
leftIndex -= 1
rightIndex -= 1
}

return Range(leftIndex...rightIndex)
}
}

首先,enclosingIndices函数。它返回一个可选的Range<Int>offset参数定义index参数左侧和右侧封闭索引的距离。guard确保完整的封闭索引包含在数组中。此外,如果offset超出数组的startIndexendIndex,封闭索引将分别向右或向左移动。因此,在数组的边界处,index不一定位于封闭索引的中间。

第二,updateSortOrder功能。它至少需要应围绕index开始排序顺序的更新。这是来自ViewModelmove函数的新索引。此外,updateSortOrder期望一个@escaping闭包提供两个整数,这将在下面解释。所有其他参数都是可选的。keyPath默认为.sortOrder符合protocol的期望。但是,如果用于排序的模型参数不同,则可以指定它。spacing参数定义通常使用的排序顺序间距。此值越大,可以执行的排序操作就越多,而无需任何其他 CoreData 更新(移动的项目除外)。offset参数不应该真正被触及,而是在函数的递归中使用。

该函数首先请求enclosingIndices。如果未找到这些,当数组小于三个项目或在updateSortOrder函数的一个递归内时,当offset超出数组边界时,会立即发生这种情况;然后,数组中所有项目的排序顺序在else情况下重置。在这种情况下,如果sortOrder与项现有值不同,则调用@escaping闭包。它的实现将在下面进一步讨论。

找到enclosingIndices时,将确定封闭索引的左索引和右索引,而不是移动项目的索引。在已知这些索引的情况下,这些索引的现有"排序顺序"值是通过keyPath获得的。然后验证这些值是否不相等(如果在数组中以相等的排序顺序添加项目,则可能会发生这种情况),以及排序顺序与封闭索引数之间的差值除以移动的项目是否会导致非整数值。这基本上检查在最小间距 1 内是否为移动项目的潜在新排序顺序值留有位置。如果不是这种情况,则封闭索引应扩展到下一个更高的offset,并且算法再次运行,因此在这种情况下递归调用updateSortOrder

当一切成功时,应为封闭索引之间的项目确定新的间距。然后遍历所有封闭索引,并将每个项目的排序顺序与潜在的新排序顺序进行比较。如果它发生了变化,则调用@escaping闭包。对于循环中的下一项,排序顺序值将再次更新。

此算法导致对@escaping闭包的最小回调量。因为仅当项目的排序顺序确实需要更新时,才会发生这种情况。

最后,正如您可能猜到的那样,对 CoreData 的实际回调将在闭包中处理。定义算法后,ViewModelmove函数将按如下方式更新:

func move(from source: IndexSet, to destination: Int) {
items.move(fromOffsets: source, toOffset: destination)

if let oldIndex = source.first, oldIndex != destination {
let newIndex = oldIndex < destination ? destination - 1 : destination
items.updateSortOrder(around: newIndex) { [weak self] (index, sortOrder) in
guard let self = self else { return }
var item = self.items[index]
item.sortOrder = sortOrder

// Note: Callback to interactor / service that updates CoreData goes here
}
}
}

如果您对此方法有任何疑问,请告诉我。希望你喜欢。

Int16 出现问题,并通过将其更改为@NSManaged public var userOrder: NSNumber?并在 func 中解决了它:NSNumber(value: Int16( reverseIndex ))

此外,我还需要在 func 中添加try? managedObjectContext.save()以实际保存新订单。

现在它工作正常 - 谢谢!

我不确定对视图模型对象使用CoreDataNSManagedObject是最好的方法,但如果这样做,下面是一个在SwiftUIList中移动项目并保留基于对象值的排序顺序的示例。

在移动过程中发生错误以回滚任何更改时,将使用UndoManager

class Note: NSManagedObject {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Note> {
return NSFetchRequest<Note>(entityName: "Note")
}
@NSManaged public var id: UUID?
@NSManaged public var orderIndex: Int64
@NSManaged public var text: String?
} 
struct ContentView: View {
@Environment(.editMode) var editMode
@Environment(.managedObjectContext) var viewContext
@FetchRequest(sortDescriptors: 
[NSSortDescriptor(key: "orderIndex", ascending: true)],
animation: .default)
private var notes: FetchedResults<Note>
var body: some View {
NavigationView {
List {
ForEach (notes) { note in
Text(note.text ?? "")
}
}
.onMove(perform: moveNotes)
}
.navigationTitle("Notes")
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
EditButton()
}
}
}
func moveNotes(_ indexes: IndexSet, _ i: Int) {

guard
1 == indexes.count,
let from = indexes.first,
from != i
else { return }

var undo = viewContext.undoManager
var resetUndo = false

if undo == nil {
viewContext.undoManager = .init()
undo = viewContext.undoManager
resetUndo = true
}

defer {
if resetUndo {
viewContext.undoManager = nil
}
}

do {
try viewContext.performAndWait {
undo?.beginUndoGrouping()
let moving = notes[from]

if from > i { // moving up
notes[i..<from].forEach {
$0.orderIndex = $0.orderIndex + 1
}
moving.orderIndex = Int64(i)
}

if from < i { // moving down
notes[(from+1)..<i].forEach {
$0.orderIndex = $0.orderIndex - 1
}
moving.orderIndex = Int64(i)
}

undo?.endUndoGrouping()
try viewContext.save()

}

} catch {

undo?.endUndoGrouping()
viewContext.undo()

// TODO: something with the error
// set a state variable to display the error condition
fatalError(error.localizedDescription)
}

}

}

如果这样做

.onMove {
self.CoreItems.move(set: $0, to: $1, from: self.managedObjectContext)
try? managedObjectContext.save()

最新更新