SwiftUI 重新排序列表中的 CoreData 对象

TS2*_*251 7 core-data swiftui

我想更改从核心数据中检索对象的列表中的行顺序。移动行有效,但问题是我无法保存更改。我不知道如何保存更改后的 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)")
        }
    }
} 
Run Code Online (Sandbox Code Playgroud)

列表:



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())
        }
    }

Run Code Online (Sandbox Code Playgroud)

谢谢你的帮助。

小智 19

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

答案有几个部分。正如 Joakim Danielson 所说,为了保留用户的首选订单,您需要将订单保存在 CoreItem 类中。修改后的类看起来像:

public class CoreItem: NSManagedObject, Identifiable{
    @NSManaged public var name: String
    @NSManaged public var userOrder: Int16
}
Run Code Online (Sandbox Code Playgroud)

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

@FetchRequest( entity: CoreItem.entity(),
                   sortDescriptors:
                   [
                       NSSortDescriptor(
                           keyPath: \CoreItem.userOrder,
                           ascending: true),
                       NSSortDescriptor(
                           keyPath:\CoreItem.name,
                           ascending: true )
                   ]
    ) var coreItems: FetchedResults<CoreItem>
Run Code Online (Sandbox Code Playgroud)

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

.onMove( perform: move )
Run Code Online (Sandbox Code Playgroud)

您的move函数将被传递一个 IndexSet 和一个 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 )
    }
}
Run Code Online (Sandbox Code Playgroud)

技术提醒:revisedItems中存放的items是classes(即通过引用),所以更新这些items必然会更新fetched结果中的items。@FetchedResults 包装器将使您的用户界面反映新订单。

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

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

  • 这是一个很好的答案。我喜欢我们能够见证“@FetchRequest”的魔力,只需对数组重新排序即可自动将更改推送回核心数据。 (2认同)

小智 7

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

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

protocol Sortable {
    var sortOrder: Int { get set }
}
Run Code Online (Sandbox Code Playgroud)

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

struct SortItem: Identifiable, Sortable {
    var id = UUID()
    var title = ""
    var sortOrder = 0
}
Run Code Online (Sandbox Code Playgroud)

我们还有一个简单的 SwiftUI View,其相关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
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

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

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
    }
}
Run Code Online (Sandbox Code Playgroud)

对于该类型的情况,算法本身被实现为extensionto 。它由一个递归函数和一个辅助函数组成,该函数检索包围数组某个索引的索引,同时保留在数组边界内。完整的算法如下(下面解释):ArrayElementSortableupdateSortOrderprivateenclosingIndices

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)
    }
}
Run Code Online (Sandbox Code Playgroud)

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

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

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

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

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

该算法会导致对闭包的回调次数最少@escaping。因为只有当项目的排序顺序确实需要更新时才会发生这种情况。

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

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
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

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