First of all, sorry for my English, I'll try my best to make it clear.(Edited with #Chip Jarred's suggestion,I've made some changes to simplify my question)
What I managed to do is achieving NSOutlineview drag & drop method, using .gap style(I just want to use this style!):
outlineView.draggingDestinationFeedbackStyle = .gap
And problems occurred, it could be easier described by a gif:
https://i.stack.imgur.com/0avhJ.gif
You can see drag&drop can run partly correctly. But the problem is: when I drag a node to the bottom of the list, a bit more below Node4, it will be dragged to the top of the list.
I've tried to fix it so I inserted a "print" fuction in validatDrop{}:
func outlineView(_ outlineView: NSOutlineView,
validateDrop info: NSDraggingInfo,
proposedItem item: Any?,
proposedChildIndex index: Int) -> NSDragOperation {
print("item:\(item),index:\(index)")
if index < 0 && item == nil{
return []
}else{
outlineView.draggingDestinationFeedbackStyle = .gap
return .move
}
}
The terminal told me that when I dropped a node to the top of the list or the bottom of the list, it returned a same index:
item:nil,index:0
https://i.stack.imgur.com/7pI7s.gif
And if I delete the .gap style:
func outlineView(_ outlineView: NSOutlineView,
validateDrop info: NSDraggingInfo,
proposedItem item: Any?,
proposedChildIndex index: Int) -> NSDragOperation {
print("item:\(item),index:\(index)")
if index < 0 && item == nil{
return []
}else{
// outlineView.draggingDestinationFeedbackStyle = .gap //ignore this line
return .move
}
}
https://i.stack.imgur.com/a8MMe.gif
Everything became normal. So it could be deduced that it could be not my "move" method's problem.
Sorry for English again, I would be grateful for any help.
And here is the essential part of my code:
outlineView.registerForDraggedTypes([.string])
...
func outlineView(_ outlineView: NSOutlineView,
heightOfRowByItem item: Any) -> CGFloat {
return 15
}
...
extension SceneCatalogView{
func outlineView(_ outlineView: NSOutlineView,
pasteboardWriterForItem item: Any) -> NSPasteboardWriting? {
let sourceNode = outlineView.row(forItem: item)
return "\(sourceNode)" as NSString
}
func outlineView(_ outlineView: NSOutlineView,
validateDrop info: NSDraggingInfo,
proposedItem item: Any?,
proposedChildIndex index: Int) -> NSDragOperation {
if index < 0 && item == nil{
return []
}else{
outlineView.draggingDestinationFeedbackStyle = .gap
return .move
}
}
func outlineView(_ outlineView: NSOutlineView,
acceptDrop info: NSDraggingInfo,
item: Any?,
childIndex index: Int) -> Bool {
let pasteboard = info.draggingPasteboard
let sourceNode = Int(pasteboard.string(forType: .string)!)!
let source = outlineView.item(atRow: sourceNode) as? Catalog
let content = source?.content
let targetNode = outlineView.row(forItem: item)
moveNode(sourceNode, targetNode, index) // Not finished
outlineView.reloadData() // Not finished
return true
}
}
After doing some research, I found multiple reports of a bug with NSTableView when using the .gap dragging style. It would seem that NSOutlineView is inheriting that bug. In any case, I found a work-around.
The problem is that when you drag below the last top-level item, the item and childIndex passed to outlineView(_:acceptDrop:item:childIndex) are always nil and 0, which are exactly the same values you get when dragging to the top of list. The only way I could find to differentiate between the two cases was to use the draggingLocation from NSDraggingInfo to compare against the first item's cell frame, and use that to translate the index.
func translateIndexForGapBug(
_ outlineView: NSOutlineView,
item: Any?,
index: Int,
for info: NSDraggingInfo) -> Int
{
guard outlineView.draggingDestinationFeedbackStyle == .gap,
items.count > 0,
item == nil,
index == 0
else { return index }
let point = outlineView.convert(info.draggingLocation, from: nil)
let firstCellFrame = outlineView.frameOfCell(atColumn: 0, row: 0)
return outlineView.isFlipped
? (point.y < firstCellFrame.maxY ? index : items.count)
: (point.y >= firstCellFrame.minY ? index : items.count)
}
I call it in outlineView(_:acceptDrop:item:childIndex):
func outlineView(
_ outlineView: NSOutlineView,
acceptDrop info: NSDraggingInfo,
item: Any?,
childIndex index: Int) -> Bool
{
assert(item == nil || item is Item)
trace("item = \(String(describing: item)), index = \(index)")
guard let sourceTitle = info.draggingPasteboard.string(forType: .string),
let source = parentAndChildIndex(forItemTitled: sourceTitle)
else { return false }
let debuggedIndex = translateIndexForGapBug(
outlineView,
item: item,
index: index,
for: info
)
moveItem(from: source, to: (item as? Item, debuggedIndex))
outlineView.reloadData()
return true
}
Since other drag styles seem to work, I only do this if it's set to .gap, so for the sake of testing, my outlineView(_:validateDrop:proposedItem:proposedChildIndex:) looks like this:
func outlineView(
_ outlineView: NSOutlineView,
validateDrop info: NSDraggingInfo,
proposedItem item: Any?,
proposedChildIndex index: Int) -> NSDragOperation
{
trace("item = \(String(describing: item)), index = \(index)")
guard info.draggingSource as? NSOutlineView === outlineView else {
return []
}
outlineView.draggingDestinationFeedbackStyle = .gap
if item == nil, index < 0 {
return []
}
return .move
}
However instead of setting it to .gap every time, you could probably just set it once when you set the data source in your outline view.
My definition of Item should be equivalent to your Catalog.
//------------------------------
class Item: CustomStringConvertible
{
var description: String { title }
var title: String
var children: [Item] = []
//------------------------------
init(_ title: String) { self.title = title }
convenience init(_ id: Int) { self.init("Item \(id)") }
//------------------------------
func addChild() {
children.append(Item("\(title).\(children.count + 1)"))
}
//------------------------------
func parentAndChildIndex(forChildTitled title: String) -> (Item?, Int)?
{
for i in children.indices
{
let child = children[i]
if child.title == title { return (self, i) }
if let found = child.parentAndChildIndex(forChildTitled: title){
return found
}
}
return nil
}
}
Here's full implementation of my data source:
//------------------------------
#objc class OVDataSource: NSObject, NSOutlineViewDataSource
{
//------------------------------
// Just creating some items programmatically for testing
var items: [Item] =
{
trace()
let items = (1...4).map { Item($0) }
items[2].addChild()
items[2].addChild()
return items
}()
//------------------------------
func outlineView(
_ outlineView: NSOutlineView,
pasteboardWriterForItem item: Any) -> NSPasteboardWriting?
{
trace()
guard let item = item as? Item else { return nil }
return item.title as NSString
}
//------------------------------
func outlineView(
_ outlineView: NSOutlineView,
numberOfChildrenOfItem item: Any?) -> Int
{
trace()
if let item = item {
return (item as? Item)?.children.count ?? 0
}
return items.count
}
//------------------------------
func outlineView(
_ outlineView: NSOutlineView,
child index: Int,
ofItem item: Any?) -> Any
{
trace()
if let item = item as? Item {
return item.children[index]
}
return items[index]
}
//------------------------------
func outlineView(
_ outlineView: NSOutlineView,
isItemExpandable item: Any) -> Bool
{
trace()
if let item = item as? Item {
return item.children.count > 0
}
return false
}
//------------------------------
func outlineView(
_ outlineView: NSOutlineView,
validateDrop info: NSDraggingInfo,
proposedItem item: Any?,
proposedChildIndex index: Int) -> NSDragOperation
{
trace("item = \(String(describing: item)), index = \(index)")
guard info.draggingSource as? NSOutlineView === outlineView else {
return []
}
outlineView.draggingDestinationFeedbackStyle = .gap
if item == nil, index < 0 {
return []
}
return .move
}
//------------------------------
func outlineView(
_ outlineView: NSOutlineView,
acceptDrop info: NSDraggingInfo,
item: Any?,
childIndex index: Int) -> Bool
{
assert(item == nil || item is Item)
trace("item = \(String(describing: item)), index = \(index)")
guard let sourceTitle = info.draggingPasteboard.string(forType: .string),
let source = parentAndChildIndex(forItemTitled: sourceTitle)
else { return false }
let debuggedIndex = translateIndexForGapBug(
outlineView,
item: item,
index: index,
for: info
)
moveItem(from: source, to: (item as? Item, debuggedIndex))
outlineView.reloadData()
return true
}
//------------------------------
func translateIndexForGapBug(
_ outlineView: NSOutlineView,
item: Any?,
index: Int,
for info: NSDraggingInfo) -> Int
{
guard outlineView.draggingDestinationFeedbackStyle == .gap,
items.count > 0,
item == nil,
index == 0
else { return index }
let point = outlineView.convert(info.draggingLocation, from: nil)
let firstCellFrame = outlineView.frameOfCell(atColumn: 0, row: 0)
return outlineView.isFlipped
? (point.y < firstCellFrame.maxY ? index : items.count)
: (point.y >= firstCellFrame.minY ? index : items.count)
}
//------------------------------
func parentAndChildIndex(forItemTitled title: String) -> (parent: Item?, index: Int)?
{
trace("Finding parent and child for item: \"\(title)\"")
for i in items.indices
{
let item = items[i]
if item.title == title { return (nil, i) }
if let found = item.parentAndChildIndex(forChildTitled: title) {
return found
}
}
return nil
}
//------------------------------
func moveItem(
from src: (parent: Item?, index: Int),
to dst: (parent: Item?, index: Int))
{
trace("src = \(src), dst = \(dst)")
let item: Item = src.parent?.children[src.index]
?? items[src.index]
if src.parent === dst.parent // Moving item in same level?
{
if let commonParent = src.parent
{
moveItem(
item,
from: src.index,
to: dst.index,
in: &commonParent.children
)
return
}
moveItem(item, from: src.index, to: dst.index, in: &items)
return
}
// Moving between levels
if let srcParent = src.parent {
srcParent.children.remove(at: src.index)
}
else { items.remove(at: src.index) }
if let dstParent = dst.parent {
insertItem(item, into: &dstParent.children, at: dst.index)
}
else { insertItem(item, into: &items, at: dst.index) }
}
//------------------------------
// Move an item within the same level
func moveItem(
_ item: Item,
from srcIndex: Int,
to dstIndex: Int,
in items: inout [Item])
{
if srcIndex < dstIndex
{
insertItem(item, into: &items, at: dstIndex)
items.remove(at: srcIndex)
return
}
items.remove(at: srcIndex)
insertItem(item, into: &items, at: dstIndex)
}
func insertItem(_ item: Item, into items: inout [Item], at index: Int)
{
if index < 0
{
items.append(item)
return
}
items.insert(item, at: index)
}
}
The trace() calls are just for debugging. Either remove them, or implement it:
func trace(
_ message: #autoclosure () -> String = "",
function: StaticString = #function,
line: UInt = #line)
{
#if DEBUG
print("\(function):\(line): \(message())")
#endif
}
I use a NSOutlineView that auto saves expanded state. If I manually reload data when dataSource updates, the func outlineView(_ outlineView: NSOutlineView, itemForPersistentObject object: Any) -> Any? datasource method is not called anymore and every cell collapses. Any idea why this might happen?
Tried to reloadItem with nil send as param but still no good.
I use this for persisting expanded rows:
func outlineView(_ outlineView: NSOutlineView, persistentObjectForItem item: Any?) -> Any? {
return NSKeyedArchiver.archivedData(withRootObject: item)
}
func outlineView(_ outlineView: NSOutlineView, itemForPersistentObject object: Any) -> Any? {
guard let data = object as? Data,
let item = NSKeyedUnarchiver.unarchiveObject(with: data) as? Category else { return nil }
let foundItem = recursiveSearch(for: item, in: viewModel.dataSource.value)
return foundItem
}
And this to reloadData:
viewModel.dataSource.subscribe(onNext: { [weak self] _ in
self?.outlineView.reloadData()
}).disposed(by: disposeBag)
IMHO autosaving is sort of half-baked feature and it doesn't work as expected. In other words, it's implemented in a way that it restores the state when your application launches (just once) and then you're on your own.
Implement your own one utilizing outlineViewItemDidExpand(_:) & outlineViewItemDidCollapse(_:) (especially when we're reloading, ...).
Couple of tricks you can use if you do not want to implement custom autosaving. But I wouldn't rely on them.
First trick - tell the NSOutlineView to reload persistent state
NSOutlineView inherits from the NSTableView and the autosaveName property documentation says:
If you change the value of this property to a new name, the table reads in any saved information and sets the order and width of this table view’s columns to match. Setting the name to nil removes any previously stored state from the user defaults.
What is inaccurate here - setting it to nil doesn't remove previously stored expanded items state for NSOutlineView. We can use it to force the NSOutlineView to reload expanded items state:
class ViewController: NSViewController, NSOutlineViewDelegate, NSOutlineViewDataSource {
#IBOutlet var outlineView: NSOutlineView!
// It's for testing, to demonstrate the persistent state reloading
private var doNotLoad = true
override func viewDidAppear() {
super.viewDidAppear()
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.doNotLoad = false
let autosaveName = self.outlineView.autosaveName
self.outlineView.autosaveName = nil
self.outlineView.reloadData()
self.outlineView.autosaveName = autosaveName
}
}
func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
if (doNotLoad) {
return 0
}
return item == nil ? data.count : (item as! Node).children.count
}
}
If you'd like to comply with the documentation, do not use nil and set some fake name. But I would expect that once the bug is fixed, the persistent state will be removed if we change the autosaveName or if we set it set to nil.
Second trick - load & expand yourself
Imagine you have the following Node class:
class Node {
let id: Int
let children: [Node]
// ...
}
And your data source implements:
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.firstNode { $0.id == id }
}
The firstNode is not related to this question, but here's the implementation (because it's mentioned in the code):
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
}
}
Then you can reloadData & expand all the items by yourself:
outlineView.reloadData()
if outlineView.autosaveExpandedItems,
let autosaveName = outlineView.autosaveName,
let persistentObjects = UserDefaults.standard.array(forKey: "NSOutlineView Items \(autosaveName)"),
let itemIds = persistentObjects as? [Int] {
itemIds.forEach {
let item = outlineView.dataSource?.outlineView?(self.outlineView, itemForPersistentObject: $0)
self.outlineView.expandItem(item)
}
}
Swift 4, Xcode 9.1
I can only get the first level of items to render in my NSOutlineView. The child items never work. Below is my code with comments that show what gets logged to the console.
class SidebarSection{
var title:String!
var buttonHidden:Bool!
}
class SidebarItem{
var title:String!
}
loadStuff(){
print(sections) //[MyApp.SidebarSection, MyApp.SidebarSection]
print(items) //[[MyApp.SidebarItem, MyApp.SidebarItem, MyApp.SidebarItem], [MyApp.SidebarItem]]
sidebarOutlineView.reloadData()
}
//Over in my NSOutlineViewDelegate/Datasource...
func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
print(item) //nil
print(sections) //[MyApp.SidebarSection, MyApp.SidebarSection]
if let section = item as? SidebarSection{
print("This never gets logged.")
//...
}else{
//Sections
print("Returned: \(sections.count)") //2
return sections.count
}
}
func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
print("\(index) / \(item)") //First time: 0 / nil; Second time: 1 / nil
if let section = item as? SidebarSection{
print("This never gets logged, either.")
//...
}else{
//Items
return sections[index]
}
}
I tried if let section = item as? SidebarSection{} right after my data gets added to the sections array, and it detects the type just fine. I can't figure out what I'm doing wrong.
Any ideas?
In this routine - tableView validateDrop, I need to understand the items returned. The return items array appears to be file ids?
func tableView(tableView: NSTableView, validateDrop info: NSDraggingInfo,
proposedRow row: Int, proposedDropOperation dropOperation: NSTableViewDropOperation) -> NSDragOperation {
print("validate drop \(dropOperation)")
if dropOperation == .Above {
//get the file URLs from the pasteboard
let pasteboard = info.draggingPasteboard()
//list the file type UTIs we want to accept
let options = [NSPasteboardURLReadingFileURLsOnlyKey : true,
NSPasteboardURLReadingContentsConformToTypesKey : [kUTTypeMovie as String]]
let items = pasteboard.readObjectsForClasses([NSURL.classForCoder()],
options: options)
if items!.count > 0 {
for item in items! {
print("item -> \(item)")
}
print("validate Above -> .Copy")
return .Copy;
} else {
print("validate Above -> .Move")
return .Move
}
}
print("validate other -> .None")
return .None
}
which outputs:
item -> file:///.file/id=6571367.34508463
At some point I need to translate this to what I can use - a typical file:// formatted URL.
duh
po item.filePathURL
▿ Optional<NSURL>
- Some : file:///Users/slashlos/Movies/Flight%20to%20Mars.m4v
which I'd probably unescape for user viewing
I've an NSTableView and an NSOutlineView in my app. The user should be able to drag an item from the outline view to the table view; if it's an acceptable operation (i.e. the selected rows indices are in the pasteboard) then the table view's destination row lights up. Everything works except for when the item(s) are initially dragged into the table view.
Each NSTableCellView in the receiving table has these methods:
func dragContainsIndices(pasteboard: NSPasteboard) -> IndexSet {
if let types:[String] = pasteboard.types {
for type in types {
if type == BTVPBoardType { //_1
if let indexData: Data = pasteboard.data(forType: BTVPBoardType) {. //_2
let indicies: IndexSet = NSKeyedUnarchiver.unarchiveObject(with: indexData) as! IndexSet
if indicies.count > 0 {
return indicies
}
}
}
}
}
return IndexSet()
}
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
let pastboard: NSPasteboard = sender.draggingPasteboard()
if self.dragContainsIndices(pasteboard: pastboard).count > 0 {
self.backingIcon.imageColour = .darkGreen //proprietary background colouring method
}
return .link
}
If the user tries to drag row(s) from the outline view to the table view then the draggingEntered(_:) in the table cell view runs but dragContainsIndices(_:) doesn't recognise the NSPasteboard as having any data even though it contains the correct type. The check at //_1 passes but then the code finds no data. So it returns an empty set which causes the background colour not to be changed.
Without releasing anything, if the user drags the item off the cell and then back on to it then the index set will unwrap correctly and the colour will change.
In the NSTableView I provide the drag session with theses methods:
override func mouseDragged(with event: NSEvent) {
let mouseDown = self.mouseDownEvent.locationInWindow
let dragPoint = event.locationInWindow
let dragDistance = hypot(mouseDown.x - dragPoint.x, mouseDown.y - dragPoint.y)
if dragDistance < 3 || self.selectedRowIndexes.count < 1 {
return
}
let pboardItem: NSPasteboardItem = NSPasteboardItem()
if pboardItem.setDataProvider(self, forTypes: [BTVPBoardType]) {
Swift.print ("Set data")
}
let dragItem: NSDraggingItem = NSDraggingItem(pasteboardWriter: pboardItem)
var dragPos = self.convert(event.locationInWindow, from: nil)
dragImage = NSImage.init(named: NSImageNameMultipleDocuments)!
dragPos.x += dragImage.size.width
dragPos.y += dragImage.size.height
dragItem.imageComponentsProvider = {
let component = NSDraggingImageComponent(key: NSDraggingImageComponentIconKey)
component.contents = self.dragImage
component.frame = NSRect(origin: NSPoint(), size: self.dragImage.size)
return [component]
}
beginDraggingSession(with: [dragItem], event: event, source: self)
}
func pasteboard(_ pasteboard: NSPasteboard?, item: NSPasteboardItem, provideDataForType type: String) {
if files.count < 1 {return} //don't bother if there's no
if let pasteboard = pasteboard {
pasteboard.declareTypes([BTVPBoardType], owner: self)
let _: Int = pasteboard.clearContents()
let indices = self.selectedRowIndexes
pasteboard.setData(NSKeyedArchiver.archivedData(withRootObject: indices), forType: BTVPBoardType)
Swift.print("written objects") //always writes indices
}
}
Is the pasteboard(_ pasteboard: NSPasteboard?, item: NSPasteboardItem, provideDataForType type: String) is it something to do with lazy fetching? For some reason I am having a bit of a head block with drag and dropping on Mac.