【问题标题】:SwiftUI reorder CoreData Objects in ListSwiftUI 对 List 中的 CoreData 对象重新排序
【发布时间】:2020-05-01 16:15:20
【问题描述】:

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

感谢您的帮助。

【问题讨论】:

  • 如果您想存储订单,那么您需要在 CoreItem 实体中为其提供一个属性,并且您需要在排序描述符中使用该属性。但这真的是个好主意吗,您需要为所有受移动影响的对象更新此属性吗?
  • 也许有更好的解决方案,我不知道。问题是我想让用户重新排序列表并保存更改。
  • 就像我说的,那么你需要一个包含订单的属性
  • 我见过的一种模式可以避免 Joakim 提到的缩放问题,即在移动单个项目时必须更新许多项目,是让 iterm 上的排序标准是某种小数.然后,当一个项目被移动时,通过将其 order 值设置为列表中它前面和后面的项目之间的值来更新单个项目。
  • 您可以根据浮动序列进行排序。当您在最后添加元素时,您会增加 1。当您在两个之间插入时,您会找到它们的平均值。最终你会达到浮动的分辨率。这将需要发生如此多的划分,您可以使用它来触发非常昂贵的 - 数组重新排序和将元素间隔 1。

标签: core-data swiftui


【解决方案1】:

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

答案有几个部分。正如 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 函数将传递一个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 )
    }
}

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

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

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

【讨论】:

  • 值得一提的是,对我来说,在模拟器中,移动的行偶尔会跳回其原始位置,然后最终到达新位置。虽然在实际设备上不太明显,但仍然存在。事实证明,默认情况下正在通过比较每个行并更新来重建 List 视图。解决方法是在列表中添加一个 .id(UUID()) 修饰符来触发替换。 HwS 对这里的类似情况进行了解释hackingwithswift.com/articles/210/…
  • 这是一个很好的解决方案,并且在实践中有效。我修改了您的答案以更明确。做得好,谢谢!
  • 嗨,感谢您帮助我实施此解决方案,但 move 只被调用一次
  • 我知道为什么 onMove 只调用一次 SwiftUI Xcode 12 iOS 14 这是因为编辑按钮需要在工具栏修饰符中
  • 这是一个很好的答案。我很高兴我们能够见证@FetchRequest 的魔力,其中只需重新排序数组即可自动将更改推送回 Core Data。 ?
【解决方案2】:

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

我还需要在函数中添加try? managedObjectContext.save() 来实际保存新订单。

现在它工作正常 - 谢谢!

【讨论】:

    【解决方案3】:

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

    首先我声明一个protocol 如下用于您的模型struct(或class):

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

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

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

    我们还有一个简单的 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
            }
        }
    }
    

    在继续算法之前,我想注意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
        }
    }
    

    对于Element 属于Sortable 类型的情况,算法本身实现为extensionArray。它由一个递归的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&lt;Int&gt;offset 参数定义了 index 参数左右封闭索引的距离。 guard 确保完整的封闭索引包含在数组中。此外,如果offset 超出数组的startIndexendIndex,则封闭索引将分别向右或向左移动。因此,在数组的边界处,index 不一定位于封闭索引的中间。

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

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

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

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

    【讨论】:

      【解决方案4】:

      我不确定将CoreDataNSManagedObject 用于视图模型对象是最好的方法,但如果您在下面执行此操作,则可以在SwiftUI List 中移动项目并保持对象值基于排序顺序。

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

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 2022-07-12
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2023-01-11
        • 2020-05-09
        • 1970-01-01
        相关资源
        最近更新 更多