Pixelboy
Pixelboy

Reputation: 33

Saving NSTableView Reordering in Core Data with NSArrayController Binding

I had a LOT of grief trying to find a way to save the result of a user's drag and drop reordering on my NSTableView into Core Data. I found a few useful bits and pieces online (like this) but because of my binding setup – my tableview's sortDescriptors are bound to my ArrayController in XCode Storyboard – I found that none of the methods were working for me. In the hope that this may help someone else who has endured the same frustration, I'm posting my solution here.

Upvotes: 0

Views: 312

Answers (2)

Willeke
Willeke

Reputation: 15598

Only the rows between the first and last dragged rows and the drop row need reindexing. NSArrayController.rearrangeObjects() sorts the data objects into the new order.

func tableView(_ tableView: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation {
    if dropOperation == .above {
        return .move
    }
    return []
}

func tableView(_ tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableView.DropOperation) -> Bool {

    if let items = billablesArrayController?.arrangedObjects as? [BillableItem] {

        NSAnimationContext.runAnimationGroup({(NSAnimationContext) -> Void in

            // put the dragged row indexes in an IndexSet so we can calculate which rows need moving and reindexing
            let rowArray = info.draggingPasteboard.pasteboardItems!.map{ Int($0.string(forType: .string)!)! }
            let draggedIndexes = IndexSet(rowArray)

            tableView.beginUpdates()

            // rows above drop row
            if draggedIndexes.first! < row {
                let indexesAboveDropRow = IndexSet(draggedIndexes.first! ..< row)

                // move the dragged rows down, start at the bottom to prevent the animated rows from tumbling over each other
                var newIndex = row - 1
                indexesAboveDropRow.intersection(draggedIndexes).reversed().forEach { oldIndex in
                    tableView.moveRow(at: oldIndex, to: newIndex)
                    items[oldIndex].sortOrder = Int16(newIndex)
                    newIndex -= 1
                }

                // reindex other rows
                indexesAboveDropRow.subtracting(draggedIndexes).reversed().forEach { oldIndex in
                    items[oldIndex].sortOrder = Int16(newIndex)
                    newIndex -= 1
                }
            }

            // rows below drop row
            if row < draggedIndexes.last! {
                let indexesBelowDropRow = IndexSet(row ... draggedIndexes.last!)

                // move the dragged rows up
                var newIndex = row
                indexesBelowDropRow.intersection(draggedIndexes).forEach { oldIndex in
                    tableView.moveRow(at: oldIndex, to: newIndex)
                    items[oldIndex].sortOrder = Int16(newIndex)
                    newIndex += 1
                }

                // reindex other rows
                indexesBelowDropRow.subtracting(draggedIndexes).forEach { oldIndex in
                    items[oldIndex].sortOrder = Int16(newIndex)
                    newIndex += 1
                }
            }

            tableView.endUpdates()

        }) {
            // rearrange the objects in the array controller so the objects match the moved rows
            // wait until the animation is finished to prevent weird or no animations
            self.billablesArrayController.rearrangeObjects()
        }

        // save
    }

    return true
}

Upvotes: 2

Pixelboy
Pixelboy

Reputation: 33

(NOTE: This method would probably not be appropriate for a tableView with a huge row count as we're looping through all objects and setting a new sortOrder)

To summarise the issue - it's relatively easy to get tableview reordering working, thanks to helpful SO posts like this - the difficulty is in saving this info to Core Data because the user/UI reordering of your table is overridden by the sortDescriptors on your bound ArrayController. The bound ArrayController essentially undoes the user's table row reordering. Here's my working code:

My arrayController sortDescriptors:

billablesArrayController.sortDescriptors = [NSSortDescriptor(key: "sortOrder", ascending: true)]

in my onViewDidLoad of the ViewController:

override func viewDidLoad() {
    super.viewDidLoad()

    // Set ViewController as dataSource for tableView and register an array of accepted drag types
    billablesTableView.dataSource = self
    billablesTableView.registerForDraggedTypes([.string])
}

Implement Drag and Drop methods in your ViewController:

extension JobsViewController: NSTableViewDataSource {

    func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {

        let item = NSPasteboardItem()
        item.setString(String(row), forType: .string)
        return item
    }

    func tableView(_ tableView: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation {

        if dropOperation == .above {
            // billablesArrayController.sortDescriptors are bound to tableView in xcode UI
            // so we remove arrayController sortDescriptors temporarily so as not to mess with user/UI table reordering
            billablesArrayController.sortDescriptors = []
            return .move
        }
        return []
    }

    func tableView(_ tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableView.DropOperation) -> Bool {

        var oldIndexes = [Int]()

        info.enumerateDraggingItems(options: [], for: tableView, classes: [NSPasteboardItem.self], searchOptions: [:]) { dragItem, _, _ in
            if let str = (dragItem.item as! NSPasteboardItem).string(forType: .string), let index = Int(str) {
                oldIndexes.append(index)
            }
        }

        var oldIndexOffset = 0
        var newIndexOffset = 0
        var selectionIndex = 0

        //Start tableView reordering

        tableView.beginUpdates()

        for oldIndex in oldIndexes {

            if oldIndex < row {
                tableView.moveRow(at: oldIndex + oldIndexOffset, to: row - 1)
                oldIndexOffset -= 1
                selectionIndex = row - 1
            } else {
                tableView.moveRow(at: oldIndex, to: row + newIndexOffset)
                newIndexOffset += 1
                selectionIndex = row
            }

        }

        tableView.endUpdates()

        //Get items.count from ArrayController for loop

        if let items = billablesArrayController?.arrangedObjects as? [BillableItem] {

            var newArray = [BillableItem]()

            // get the new item order from the tableView

            for i in 0..<items.count {

                if let view = billablesTableView.view(atColumn: 0, row: i, makeIfNecessary: false) as? NSTableCellView {
                    if let tableItem = view.objectValue as? BillableItem {
                        newArray.append(tableItem)
                    }
                }

            }

            // assign new sortOrder to each managedObject based on its index position in newArray
            var index = 0
            for bi in newArray {
                bi.sortOrder = Int16(index)
                index += 1
            }

        }

        // reinstate arrayController sortDescriptors
        billablesArrayController.sortDescriptors = [NSSortDescriptor(key: "sortOrder", ascending: true)]
        // assign the dragged row as the selected item
        billablesArrayController.setSelectionIndex(selectionIndex)

        //save 'em
        if managedObjectContext.hasChanges {

            do {

                try self.managedObjectContext.save()

            } catch {

                NSSound.beep()
                _ = alertDialog(question: "Error: Can't save billable items sort order.", text: error.localizedDescription, showCancel: false)

            }
        }

        return true

    }
}

Upvotes: 0

Related Questions