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.sstatic.net/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.sstatic.net/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.sstatic.net/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
}