Below you can find a more generic approach to this problem. The algorithm minimises the number of CoreData entities that require an update, to the contrary of the accepted answer. My solution is inspired by the following article: https://www.appsdissected.com/order-core-data-entities-maximum-speed/
First I declare a protocol
as follows to use with your model struct
(or class
):
protocol Sortable {
var sortOrder: Int { get set }
}
As an example, assume we have a SortItem
model which implements our Sortable
protocol, defined as:
struct SortItem: Identifiable, Sortable {
var id = UUID()
var title = ""
var sortOrder = 0
}
We also have a simple SwiftUI View
with a related ViewModel
defined as (stripped down version):
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
}
}
}
Before I continue to the algorithm, I want to note that the destination
variable from the move
function does not contain the new index when moving items down the list. Assuming that only a single item is moved, retrieving the new index (after the move is complete) can be achieved as follows:
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
}
}
The algorithm itself is implemented as an extension
to Array
for the case that the Element
is of the Sortable
type. It consists of a recursive updateSortOrder
function as well as a private
helper function enclosingIndices
which retrieves the indices that enclose around a certain index of the array, whilst remaining within the array bounds. The complete algorithm is as follows (explained below):
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)
}
}
First, the enclosingIndices
function. It returns an optional Range<Int>
. The offset
argument defines the distance for the enclosing indices left and right of the index
argument. The guard
ensures that the complete enclosing indices are contained within the array. Further, in case the offset
goes beyond the startIndex
or endIndex
of the array, the enclosing indices will be shifted to the right or left, respectively. Hence, at the boundaries of the array, the index
is not necessarily located in the middle of the enclosing indices.
Second, the updateSortOrder
function. It requires at least the index
around which the update of the sorting order should be started. This is the new index from the move
function in the ViewModel
. Further, the updateSortOrder
expects an @escaping
closure providing two integers, which will be explained below. All other arguments are optional. The keyPath
is defaulted to \.sortOrder
in conformance with the expectations from the protocol
. However, it can be specified if the model parameter for sorting differs. The spacing
argument defines the sort order spacing that is typically used. The larger this value, the more sort operations can be performed without requiring any other CoreData update except for the moved item. The offset
argument should not really be touched and is used in the recursion of the function.
The function first requests the enclosingIndices
. In case these are not found, which happens immediately when the array is smaller than three items or either inside one of the recursions of the updateSortOrder
function when the offset
is such that it would go beyond the boundaries of the array; then the sort order of all items in the array are reset in the else
case. In that case, if the sortOrder
differs from the items existing value, the @escaping
closure is called. It's implementation will be discussed further below.
When the enclosingIndices
are found, both the left and right index of the enclosing indices not being the index of the moved item are determined. With these indices known, the existing 'sort order' values for these indices are obtained through the keyPath
. It is then verified if these values are not equal (which could occur if the items were added with equal sort orders in the array) as well as if a division of the difference between the sort orders and the number of enclosing indices minus the moved item would result in a non-integer value. This basically checks whether there is a place left for the moved item's potentially new sort order value within the minimum spacing of 1. If this is not the case, the enclosing indices should be expanded to the next higher offset
and the algorithm run again, hence the recursive call to updateSortOrder
in that case.
When all was successful, the new spacing should be determined for the items between the enclosing indices. Then all enclosing indices are looped through and each item's sorting order is compared to the potentially new sorting order. In case it changed, the @escaping
closure is called. For the next item in the loop the sort order value is updated again.
This algorithm results in the minimum amount of callbacks to the @escaping
closure. Since this only happens when an item's sort order really needs to be updated.
Finally, as you perhaps guessed, the actual callbacks to CoreData will be handled in the closure. With the algorithm defined, the ViewModel
move
function is then updated as follows:
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
}
}
}
Please let me know if you have any questions regarding this approach. I hope you like it.