NSOutlineView does not allow expansion - cocoa

I have a simple NSOutlineView, init via swift, with 2 columns. I made the following very simple data source, hoping to test it this way, but maybe I this format is not allowable. I assume the table view only queries as needed, so that this won't cause an infinite loop.
The result is a 4 row, 2 column table layout with "Name" and "Value", but no expansion buttons.
I've implemented isExpandable as mentioned in the Obj-C post with similar name and added the columns.
Is there something more that I need to do to setup a NSOutlineView with expandable elements, or should I attempt another more realistic dataSource test:
import Cocoa
class OutlineDataSource : NSObject,NSOutlineViewDataSource
{
var a = "Name"
var b = "Value"
var column1 : NSTableColumn
var column2 : NSTableColumn
init(column1:NSTableColumn,column2:NSTableColumn) {
self.column1 = column1
self.column2 = column2
}
func outlineView(outlineView: NSOutlineView, numberOfChildrenOfItem item: AnyObject?) -> Int
{
return 4;
}
func outlineView(outlineView: NSOutlineView, child index: Int, ofItem item: AnyObject?) -> AnyObject
{
return a
}
func outlineView(outlineView: NSOutlineView, isItemExpandable item: AnyObject) -> Bool
{
return true;
}
func outlineView(outlineView: NSOutlineView, objectValueForTableColumn tableColumn: NSTableColumn?, byItem item: AnyObject?) -> AnyObject?
{
if (tableColumn == column1)
{
return a
}
return b
}
}

outlineTableColumn needs to be set on the outline view.

Related

Big Sur outline view expandable items broken

I've started a new macOS project (currently on Big Sur beta 3), and the NSOutlineView nodes seem to be broken. Can't tell if this is me or the os.
Here's a sample project that demonstrates the issue. And an image...
As you can see, the cell is overlapping the expansion chevrons. Clicking on either chevron restores the first row to the proper layout, but not the second. Also, the autosave methods persistentObjectForItem and itemForPersistentObject are never called.
The test project is super simple--all I did was add the SourceView component from the view library to the default app project and hook up the delegate/data source to the view controller. Also checked Autosave Expanded Items in IB and put a name in the Autosave field. Here's the entirety of the controller code:
class ViewController: NSViewController {
#IBOutlet var outlineView: NSOutlineView?
let data = [Node("First item", 1), Node("Second item", 2)]
}
extension ViewController: NSOutlineViewDataSource {
func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
data[index]
}
func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
true
}
func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
item == nil ? data.count : 0
}
func outlineView(_ outlineView: NSOutlineView, objectValueFor tableColumn: NSTableColumn?, byItem item: Any?) -> Any? {
item
}
func outlineView(_ outlineView: NSOutlineView, persistentObjectForItem item: Any?) -> Any? {
(item as? Node)?.id
}
func outlineView(_ outlineView: NSOutlineView, itemForPersistentObject object: Any) -> Any? {
guard let id = object as? Int else { return nil }
return data.first { $0.id == id }
}
}
extension ViewController: NSOutlineViewDelegate {
func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
guard let node = item as? Node else {
preconditionFailure("Invalid data item \(item)")
}
let view = outlineView.makeView(withIdentifier: nodeCellIdentifier, owner: self) as? NSTableCellView
view?.textField?.stringValue = node.name
view?.imageView?.image = NSImage(systemSymbolName: node.icon, accessibilityDescription: nil)
return view
}
}
final class Node {
let id: Int
let name: String
let icon: String
init(_ name: String, _ id: Int, _ icon: String = "folder") {
self.id = id
self.name = name
self.icon = icon
}
}
private let nodeCellIdentifier = NSUserInterfaceItemIdentifier("DataCell")
Any Mac developers left out there that can help?
Source list
What is a source list? It's NSOutlineView (which is a subclass of
NSTableView) with a special
treatment. Finder screenshot:
To create a source list, all you have to do is to set the
selectionHighlightStyle property to
.sourceList. The documentation says:
The source list style of NSTableView. On 10.5, a light blue gradient is used to highlight selected rows.
What it does exactly? Jump to Definition in Xcode and read comments (not included in the docs):
The source list style of NSTableView. On 10.10 and higher, a blur selection is used to highlight rows. Prior to that, a light blue gradient was used. Note: Cells that have a drawsBackground property should have it set to NO. Otherwise, they will draw over the highlighting that NSTableView does. Setting this style will have the side effect of setting the background color to the "source list" background color. Additionally in NSOutlineView, the following properties are changed to get the standard "source list" look: indentationPerLevel, rowHeight and intercellSpacing. After calling setSelectionHighlightStyle: one can change any of the other properties as required. In 10.11, if the background color has been changed from the "source list" background color to something else, the table will no longer draw the selection as a source list blur style, and instead will do a normal blue highlight.
Since you're on Big Sur, be aware that the SelectionHighlightStyle.sourceList is deprecated.
One should use style
& effectiveStyle.
Sample project
Xcode:
New project
macOS & App (Storyboard & AppKit App Delegate & Swift)
Main.storyboard
Add Source List control
Position & fix constraints
Set delegate & dataSource to ViewController
Enable Autosave Expanded Items
Set Autosave to whatever you want (I have FinderLikeSidebar there)
Choose wisely because the expansion state is saved in the user defaults
under the NSOutlineView Items FinderLikeSidebar key
Create #IBOutlet var outlineView: NSOutlineView!
Add another Text Table Cell View (no image)
Set identifier to GroupCell
ViewController.swift
Commented code below
Screenshots
As you can see, it's almost Finder like - 2nd level is still indented. The reason
for this is that the Documents node is expandable (has children). I have them here to demonstrate autosaving.
Just remove them if you'd like to move all 2nd level nodes to the left.
ViewController.swift code
There's not much to say about it except - read comments :)
import Cocoa
// Sample Node class covering groups & regular items
class Node {
let id: Int
let title: String
let symbolName: String?
let children: [Node]
let isGroup: Bool
init(id: Int, title: String, symbolName: String? = nil, children: [Node] = [], isGroup: Bool = false) {
self.id = id
self.title = title
self.symbolName = symbolName
self.children = children
self.isGroup = isGroup
}
convenience init(groupId: Int, title: String, children: [Node]) {
self.init(id: groupId, title: title, children: children, isGroup: true)
}
}
extension Node {
var cellIdentifier: NSUserInterfaceItemIdentifier {
// These must match identifiers in Main.storyboard
NSUserInterfaceItemIdentifier(rawValue: isGroup ? "GroupCell" : "DataCell")
}
}
extension Array where Self.Element == Node {
// Search for a node (recursively) until a matching element is found
func firstNode(where predicate: (Element) throws -> Bool) rethrows -> Element? {
for element in self {
if try predicate(element) {
return element
}
if let matched = try element.children.firstNode(where: predicate) {
return matched
}
}
return nil
}
}
class ViewController: NSViewController, NSOutlineViewDelegate, NSOutlineViewDataSource {
#IBOutlet var outlineView: NSOutlineView!
let data = [
Node(groupId: 1, title: "Favorites", children: [
Node(id: 11, title: "AirDrop", symbolName: "wifi"),
Node(id: 12, title: "Recents", symbolName: "clock"),
Node(id: 13, title: "Applications", symbolName: "hammer")
]),
Node(groupId: 2, title: "iCloud", children: [
Node(id: 21, title: "iCloud Drive", symbolName: "icloud"),
Node(id: 22, title: "Documents", symbolName: "doc", children: [
Node(id: 221, title: "Work", symbolName: "folder"),
Node(id: 221, title: "Personal", symbolName: "folder.badge.person.crop"),
])
]),
]
override func viewWillAppear() {
super.viewWillAppear()
// Expanded items are saved in the UserDefaults under the key:
//
// "NSOutlineView Items \(autosaveName)"
//
// By default, this value is not present. When you expand some nodes,
// an array with persistent objects is saved. When you collapse all nodes,
// the array is removed from the user defaults (not an empty array,
// but back to nil = removed).
//
// IOW there's no way to check if user already saw this source list,
// modified expansion state, etc. We will use custom key for this
// purpose, so we can expand group nodes (top level) when the source
// list is displayed for the first time.
//
// Next time, we wont expand anything and will honor autosaved expanded
// items.
if UserDefaults.standard.object(forKey: "FinderLikeSidebarAppeared") == nil {
data.forEach {
outlineView.expandItem($0)
}
UserDefaults.standard.set(true, forKey: "FinderLikeSidebarAppeared")
}
}
// Number of children or groups (item == nil)
func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
item == nil ? data.count : (item as! Node).children.count
}
// Child of a node or group (item == nil)
func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
item == nil ? data[index] : (item as! Node).children[index]
}
// View for our node
func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
guard let node = item as? Node,
let cell = outlineView.makeView(withIdentifier: node.cellIdentifier, owner: self) as? NSTableCellView else {
return nil
}
cell.textField?.stringValue = node.title
if !node.isGroup {
cell.imageView?.image = NSImage(systemSymbolName: node.symbolName ?? "folder", accessibilityDescription: nil)
}
return cell
}
// Mark top level items as group items
func outlineView(_ outlineView: NSOutlineView, isGroupItem item: Any) -> Bool {
(item as! Node).isGroup
}
// Every node is expandable if it has children
func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
!(item as! Node).children.isEmpty
}
// Top level items (group items) are not selectable
func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool {
!(item as! Node).isGroup
}
// Object to save in the user defaults (NSOutlineView Items FinderLikeSidebar)
func outlineView(_ outlineView: NSOutlineView, persistentObjectForItem item: Any?) -> Any? {
(item as! Node).id
}
// Find an item from the saved object (NSOutlineView Items FinderLikeSidebar)
func outlineView(_ outlineView: NSOutlineView, itemForPersistentObject object: Any) -> Any? {
guard let id = object as? Int else { return nil }
return data.firstNode { $0.id == id }
}
}

Display Image View in Table View conditionally in Swift

I'm trying to display an image in a table view cell view on the condition of a Boolean value.
The Boolean is a representation of the state of an object of the class "Book" where the objects are initialized:
class Book: NSObject, Codable {
#objc dynamic var author: String
#objc dynamic var title: String
#objc dynamic var lentBy: String
#objc dynamic var available: Bool {
if lentBy == "" {
return true
} else {return false}
}
init(author: String, title: String, lentBy: String) {
self.author = author
self.title = title
self.lentBy = lentBy
}
}
If the String lentBy is not specified, the Bool available returns true: no one has lent the book and hence it should be available. Binding the available object to the table view, the respective table view cell displays either 1 or 0. Instead of 1 or 0 I would like it to display an image: NSStatusAvailable or NSStatusUnavailable.
Have a look at this: https://i.imgur.com/xkp0znT.png.
Where the text field "Geliehen von" (lent by) is empty, the status is 1 and should display the green circle; otherwise a red circle. The green circle you see now is simply dragged into the table cell view and is non-functional. But this is the idea.
Now I'm wondering how to display the respective image view instead of the Bool 1 or 0.
The table view is constructed with the interface builder in a storyboard. If I'm trying to make changes to it programmatically, nothing gets display in the table view anymore. I suppose this is due to the set bindings. Removing the bindings just for the last column doesn't work. This is how I tried it (without implementation of the image view; I don't know how to do that programmatically):
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
if tableColumn == tableView.tableColumns[2] {
let cellIdentifier = "statusCellID"
let cell = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: cellIdentifier), owner: self) as? NSTextField
if let cell = cell {
cell.identifier = NSUserInterfaceItemIdentifier(rawValue: cellIdentifier)
cell.stringValue = books[row].lentBy
}
return cell
}
return nil
}
What's the best solution to achieve this? Could I somehow, instead of a Bool, directly return the respective, e.g. CGImage types for lentBys representation available?
You are using Cocoa Bindings. This makes it very easy.
In Interface Builder drag an NSTableCellView with image view into the last column and delete the current one.
Delete the text field and set appropriate constraints for the image view.
Rather than viewForColumn:Row implement
func tableView(_ tableView: NSTableView, objectValueFor tableColumn: NSTableColumn?, row: Int) -> Any? {
return books[row]
}
Extend the model with an image property which is driven by KVO
class Book: NSObject, Codable {
#objc dynamic var author: String
#objc dynamic var title: String
#objc dynamic var lentBy: String
#objc dynamic var available: Bool {
return lentBy.isEmpty
}
#objc dynamic var image: NSImage {
return NSImage(named: (lentBy.isEmpty) ? NSImage.statusAvailableName : NSImage.statusUnavailableName)!
}
static func keyPathsForValuesAffectingImage() -> Set<String> { return ["lentBy"] }
init(author: String, title: String, lentBy: String) {
self.author = author
self.title = title
self.lentBy = lentBy
}
}
In Interface Builder bind the Value of the image view of the table cell view to Table Cell View > objectValue.image

Error with NSTreeController - this class is not key value coding-compliant for the key

I am new to Swift and trying to learn how to implement NSTreeController with NSOutlineView. I've been following several guides which shows such examples, but I keep getting an error. I followed step by step and/or try to run their source codes if available, but I was getting same error. I come to think there is some change in Swift 4 which makes these Swift 3 examples to produce error. As there are not many examples done in Swift 4, I decided I'd give a try by asking the question here.
The error I'm getting is:
this class is not key value coding-compliant for the key isLeaf.
I believe that error is coming from the key path set up for NSTreeController:
However I am not sure what needs to be done to fix the error.
I have simple model class called Year.
class Year: NSObject {
var name: String
init(name: String) {
self.name = name
}
func isLeaf() -> Bool {
return true
}
}
My view controller looks like this.
class ViewController: NSViewController, NSOutlineViewDataSource, NSOutlineViewDelegate {
#IBOutlet weak var outlineView: NSOutlineView!
#IBOutlet var treeController: NSTreeController!
override func viewDidLoad() {
super.viewDidLoad()
addData()
outlineView.delegate = self
outlineView.dataSource = self
}
func addData() {
let root = ["name": "Year", "isLeaf": false] as [String : Any]
let dict: NSMutableDictionary = NSMutableDictionary(dictionary: root)
dict.setObject([Year(name: "1999"), Year(name: "2000")], forKey: "children" as NSCopying)
treeController.addObject(dict)
}
func isHeader(item: Any) -> Bool {
if let item = item as? NSTreeNode {
return !(item.representedObject is Year)
} else {
return !(item is Year)
}
}
func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
if isHeader(item: item) {
return outlineView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "HeaderCell"), owner: self)!
} else {
return outlineView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "DataCell"), owner: self)!
}
}
}
When I run the program, it causes no issue, but when I expand the node to show the two children of the root, it is giving the error I mentioned above.
Because is isLeaf is used in KVO by NSOutlineView, you have to add #objc in front of isLeaf function:
#objc func isLeaf() -> Bool {
return true
}
The class to which you are binding needs to be KVO compliant.
So, it needs to be a subclass of NSObject.
And the objc runtime needs access.
One way to do this:
#objcMembers
class FileSystemItem: NSObject {
Or, you can annotate each field/function with #objc
Full Example

How to sort NSTableView with custom class data

I am using cell-based tableview to store custom class data, for example
class Person {
var name:String = ""
var age: Int = 0
}
And then use a NSMUtableArray to collect person1, person2 ... datas, call it dataArray.
Present Person name on column1 and age on column2, works fine and editable.
func tableView(_ tableView: NSTableView, objectValueFor tableColumn: NSTableColumn?, row: Int) -> Any? {
if tableColumn?.identifier == "Name"
{
return (self.dataArray[row] as! Person).name
}
else if tableColumn?.identifier == "Age"
{
return (self.dataArray[row] as! Person).age
}
}
How can I sort each column with respected sortDescriptor?
I follow that fefernce but "sortDescriptorWithKey:tableColumn.identifier" always fail in my code, it can be passed if set the tableColumn.identifier to "self", it also confused me.
Below are my other code fragments.
func tableView(_ tableView: NSTableView, sortDescriptorsDidChange oldDescriptors: [NSSortDescriptor]) {
let newDescriptors = tableView.sortDescriptors
dataArray.sort(using: newDescriptors)
tableView.reloadData()
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.delegate = self
tableView.dataSource = self
// Sort
for tableColumn in tableView.tableColumns {
let sortDescrptor = NSSortDescriptor.init(key: tableColumn.identifier, ascending: true, selector: #selector(NSString.caseInsensitiveCompare))
tableColumn.sortDescriptorPrototype = sortDescrptor
}
}

Drag & Drop Reorder Rows on NSTableView

I was just wondering if there was an easy way to set an NSTableView to allow it to reorder its rows without writing any pasteboard code. I only need it to be able to do this internally, within one table. I have no issue writing the pboard code, except that I'm fairly sure that I saw Interface Builder have a toggle for this somewhere / saw it working by default. It certainly seems like a common enough task.
Thanks
Set your table view's datasource to be a class that conforms to NSTableViewDataSource.
Put this in an appropriate place (-applicationWillFinishLaunching, -awakeFromNib, -viewDidLoad or something similar):
tableView.registerForDraggedTypes(["public.data"])
Then implement these three NSTableViewDataSource methods:
tableView:pasteboardWriterForRow:
tableView:validateDrop:proposedRow:proposedDropOperation:
tableView:acceptDrop:row:dropOperation:
Here is fully-working code that supports drag-and-drop reordering multiple rows:
func tableView(tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {
let item = NSPasteboardItem()
item.setString(String(row), forType: "public.data")
return item
}
func tableView(tableView: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableViewDropOperation) -> NSDragOperation {
if dropOperation == .Above {
return .Move
}
return .None
}
func tableView(tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableViewDropOperation) -> Bool {
var oldIndexes = [Int]()
info.enumerateDraggingItemsWithOptions([], forView: tableView, classes: [NSPasteboardItem.self], searchOptions: [:]) {
if let str = ($0.0.item as! NSPasteboardItem).stringForType("public.data"), index = Int(str) {
oldIndexes.append(index)
}
}
var oldIndexOffset = 0
var newIndexOffset = 0
// For simplicity, the code below uses `tableView.moveRowAtIndex` to move rows around directly.
// You may want to move rows in your content array and then call `tableView.reloadData()` instead.
tableView.beginUpdates()
for oldIndex in oldIndexes {
if oldIndex < row {
tableView.moveRowAtIndex(oldIndex + oldIndexOffset, toIndex: row - 1)
--oldIndexOffset
} else {
tableView.moveRowAtIndex(oldIndex, toIndex: row + newIndexOffset)
++newIndexOffset
}
}
tableView.endUpdates()
return true
}
Swift 3 version:
func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {
let item = NSPasteboardItem()
item.setString(String(row), forType: "private.table-row")
return item
}
func tableView(_ tableView: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableViewDropOperation) -> NSDragOperation {
if dropOperation == .above {
return .move
}
return []
}
func tableView(_ tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableViewDropOperation) -> Bool {
var oldIndexes = [Int]()
info.enumerateDraggingItems(options: [], for: tableView, classes: [NSPasteboardItem.self], searchOptions: [:]) {
if let str = ($0.0.item as! NSPasteboardItem).string(forType: "private.table-row"), let index = Int(str) {
oldIndexes.append(index)
}
}
var oldIndexOffset = 0
var newIndexOffset = 0
// For simplicity, the code below uses `tableView.moveRowAtIndex` to move rows around directly.
// You may want to move rows in your content array and then call `tableView.reloadData()` instead.
tableView.beginUpdates()
for oldIndex in oldIndexes {
if oldIndex < row {
tableView.moveRow(at: oldIndex + oldIndexOffset, to: row - 1)
oldIndexOffset -= 1
} else {
tableView.moveRow(at: oldIndex, to: row + newIndexOffset)
newIndexOffset += 1
}
}
tableView.endUpdates()
return true
}
If you take a look at the tool tip in IB you'll see that the option you refer to
- (BOOL)allowsColumnReordering
controls, well, column reordering. I do not believe there is any other way to do this other than the standard drag-and-drop API for table views.
EDIT: ( 2012-11-25 )
The answer refers to drag-and-drop reordering of NSTableViewColumns; and while it was the accepted answer at the time. It does not appear, now nearly 3 years on, to be correct. In service of making the information useful to searchers, I'll attempt to give the more correct answer.
There is no setting that allows drag and drop reordering of NSTableView rows in Interface Builder. You need to implement certain NSTableViewDataSource methods, including:
- tableView:acceptDrop:row:dropOperation:
- (NSDragOperation)tableView:(NSTableView *)aTableView validateDrop:(id < NSDraggingInfo >)info proposedRow:(NSInteger)row proposedDropOperation:(NSTableViewDropOperation)operation
- (BOOL)tableView:(NSTableView *)aTableView writeRowsWithIndexes:(NSIndexSet *)rowIndexes toPasteboard:(NSPasteboard *)pboard
There are other SO question that address this reasonably thoroughly, including this one
Apple link to Drag and Drop APIs.
#Ethan's solution - Update Swift 4
in viewDidLoad :
private var dragDropType = NSPasteboard.PasteboardType(rawValue: "private.table-row")
override func viewDidLoad() {
super.viewDidLoad()
myTableView.delegate = self
myTableView.dataSource = self
myTableView.registerForDraggedTypes([dragDropType])
}
Later on delegate extension :
extension MyViewController: NSTableViewDelegate, NSTableViewDataSource {
// numerbOfRow and viewForTableColumn methods
func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {
let item = NSPasteboardItem()
item.setString(String(row), forType: self.dragDropType)
return item
}
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 {
var oldIndexes = [Int]()
info.enumerateDraggingItems(options: [], for: tableView, classes: [NSPasteboardItem.self], searchOptions: [:]) { dragItem, _, _ in
if let str = (dragItem.item as! NSPasteboardItem).string(forType: self.dragDropType), let index = Int(str) {
oldIndexes.append(index)
}
}
var oldIndexOffset = 0
var newIndexOffset = 0
// For simplicity, the code below uses `tableView.moveRowAtIndex` to move rows around directly.
// You may want to move rows in your content array and then call `tableView.reloadData()` instead.
tableView.beginUpdates()
for oldIndex in oldIndexes {
if oldIndex < row {
tableView.moveRow(at: oldIndex + oldIndexOffset, to: row - 1)
oldIndexOffset -= 1
} else {
tableView.moveRow(at: oldIndex, to: row + newIndexOffset)
newIndexOffset += 1
}
}
tableView.endUpdates()
return true
}
}
Plus, for those it may concerne:
If you want to disable certain cells from being dragable, return nil in pasteboardWriterForRows method
If you want to prevent drop a certain locations ( too far for instance ) just use return [] in validateDrop's method
Do not call tableView.reloadData() synchronously inside func tableView(_ tableView:, acceptDrop info:, row:, dropOperation:). This will disturb Drag and Drop animation, and can be very confusing. Find a way to wait until animation finishes, and async it's reloading
This answer covers Swift 3, View-based NSTableViews and single/multiple rows drag&drop reorder.
There are 2 main steps which must be performed in order to achieve this:
Register table view to a specifically allowed type of object which can be dragged.
tableView.register(forDraggedTypes: ["SomeType"])
Implement 3 NSTableViewDataSource methods: writeRowsWith, validateDrop and acceptDrop.
Before drag operation has started, store IndexSet with indexes of rows which will be dragged, in the pasteboard.
func tableView(_ tableView: NSTableView, writeRowsWith rowIndexes: IndexSet, to pboard: NSPasteboard) -> Bool {
let data = NSKeyedArchiver.archivedData(withRootObject: rowIndexes)
pboard.declareTypes(["SomeType"], owner: self)
pboard.setData(data, forType: "SomeType")
return true
}
Validate drop only if dragging operation is above of specified row. This ensures when dragging is performed other rows won't be highlighted when dragged row will float above them. Also, this fixes an AutoLayout issue.
func tableView(_ tableView: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int,
proposedDropOperation dropOperation: NSTableViewDropOperation) -> NSDragOperation {
if dropOperation == .above {
return .move
} else {
return []
}
}
When accepting drop just retrieve IndexSet that previously was saved in the pasteboard,
iterate through it and move rows using calculated indexes.
Note: Part with iteration and row moving I've copied from #Ethan's answer.
func tableView(_ tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableViewDropOperation) -> Bool {
let pasteboard = info.draggingPasteboard()
let pasteboardData = pasteboard.data(forType: "SomeType")
if let pasteboardData = pasteboardData {
if let rowIndexes = NSKeyedUnarchiver.unarchiveObject(with: pasteboardData) as? IndexSet {
var oldIndexOffset = 0
var newIndexOffset = 0
for oldIndex in rowIndexes {
if oldIndex < row {
// Dont' forget to update model
tableView.moveRow(at: oldIndex + oldIndexOffset, to: row - 1)
oldIndexOffset -= 1
} else {
// Dont' forget to update model
tableView.moveRow(at: oldIndex, to: row + newIndexOffset)
newIndexOffset += 1
}
}
}
}
return true
}
View-based NSTableViews update themselfs when moveRow is called, there is no need to use beginUpdates() and endUpdates() block.
Unfortunately you do have to write the Paste board code. The Drag and Drop API is fairly generic which makes it very flexible. However, if you just need reordering it's a bit over-the-top IMHO. But anyway, I have created a small sample project which has an NSOutlineView where you can add and remove items as well as reorder them.
This is not an NSTableView but the implementation of the Drag & Drop protocol is basically identical.
I implemented drag and Drop in one go so it's best to look at this commit.
If your are moving only one row at the time you can use the following code:
func tableView(_ tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableViewDropOperation) -> Bool {
let pasteboard = info.draggingPasteboard()
guard let pasteboardData = pasteboard.data(forType: basicTableViewDragAndDropDataType) else { return false }
guard let rowIndexes = NSKeyedUnarchiver.unarchiveObject(with: pasteboardData) as? IndexSet else { return false }
guard let oldIndex = rowIndexes.first else { return false }
let newIndex = oldIndex < row ? row - 1 : row
tableView.moveRow(at: oldIndex, to: newIndex)
// Dont' forget to update model
return true
}
This is an update to #Ethan's answer for Swift 3:
let dragDropTypeId = "public.data" // or any other UTI you want/need
func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {
let item = NSPasteboardItem()
item.setString(String(row), forType: dragDropTypeId)
return item
}
func tableView(_ tableView: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableViewDropOperation) -> NSDragOperation {
if dropOperation == .above {
return .move
}
return []
}
func tableView(_ tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableViewDropOperation) -> Bool {
var oldIndexes = [Int]()
info.enumerateDraggingItems(options: [], for: tableView, classes: [NSPasteboardItem.self], searchOptions: [:]) {
if let str = ($0.0.item as! NSPasteboardItem).string(forType: self.dragDropTypeId), let index = Int(str) {
oldIndexes.append(index)
}
}
var oldIndexOffset = 0
var newIndexOffset = 0
// For simplicity, the code below uses `tableView.moveRowAtIndex` to move rows around directly.
// You may want to move rows in your content array and then call `tableView.reloadData()` instead.
tableView.beginUpdates()
for oldIndex in oldIndexes {
if oldIndex < row {
tableView.moveRow(at: oldIndex + oldIndexOffset, to: row - 1)
oldIndexOffset -= 1
} else {
tableView.moveRow(at: oldIndex, to: row + newIndexOffset)
newIndexOffset += 1
}
}
tableView.endUpdates()
self.reloadDataIntoArrayController()
return true
}
Swift 5 solution. I had to add 'registerForDraggedTypes' method in viewDidLoad for this to work.
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view.
tableView.dataSource = self
tableView.delegate = self
// you must register the type you want to drag-n-drop! in this case 'strings'
tableView.registerForDraggedTypes([.string])
self.mapView.fitAll(in: teamManager.group.teams(), andShow: true)
}
extension ViewController : NSTableViewDataSource {
func numberOfRows(in tableView: NSTableView) -> Int {
return dataModel.count // or whatever
}
func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {
let pasteboard = NSPasteboardItem()
// in this example I'm dragging the row index. Once dropped i'll look up the value that is moving by using this.
// remember in viewdidload I registered strings so I must set strings to pasteboard
pasteboard.setString("\(row)", forType: .string)
return pasteboard
}
func tableView(_ tableView: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation {
let canDrop = (row > 2) // in this example you cannot drop on top two rows
print("valid drop \(row)? \(canDrop)")
if (canDrop) {
return .move //yes, you can drop on this row
}
else {
return [] // an empty array is the equivalent of nil or 'cannot drop'
}
}
func tableView(_ tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableView.DropOperation) -> Bool {
let pastboard = info.draggingPasteboard
if let sourceRowString = pastboard.string(forType: .string) {
print("from \(sourceRowString). dropping row \(row)")
return true
}
return false
}
}
Here is the fully working code in swift 5. Lets you move multiple items at once!
override func viewDidLoad() {
super.viewDidLoad()
myTableView.delegate = self
myTableView.dataSource = self
myTableView.registerForDraggedTypes([.string])
}
func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {
let pasteboard = NSPasteboardItem()
pasteboard.setString("\(row)", forType: .string)
return pasteboard
}
func tableView(_ tableView: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation {
return .move
}
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
// For simplicity, the code below uses `tableView.moveRowAtIndex` to move rows around directly.
// You may want to move rows in your content array and then call `tableView.reloadData()` instead.
tableView.beginUpdates()
for oldIndex in oldIndexes {
if oldIndex < row {
tableView.moveRow(at: oldIndex + oldIndexOffset, to: row - 1)
oldIndexOffset -= 1
} else {
tableView.moveRow(at: oldIndex, to: row + newIndexOffset)
newIndexOffset += 1
}
}
tableView.endUpdates()
return true
}
Hope it's not too late...
I work with VisualStudio for Mac in C# and don't have Swift skills...
Can you give me a transcription in C# of this part of you'r sample?
Thank tou for helping if possible
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)
}
}

Resources