swiftdrag-and-dropnsoutlineview

(Swift) Strange feedback when drag&drop NSOutlineview


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
    }
}



Solution

  • 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.

    enter image description here

    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
    }