(Swift) 拖放 NSOutlineview 时出现奇怪的反馈

(Swift) Strange feedback when drag&drop NSOutlineview

首先,对不起我的英语,我会尽力说清楚。(根据@Chip Jarred 的建议编辑,我做了一些更改以简化我的问题)

我成功实现了NSOutlineview的拖拽方式,使用.gap风格(我就是想用这个风格!):

outlineView.draggingDestinationFeedbackStyle = .gap

然后出现了问题,用gif描述会更简单:

https://i.stack.imgur.com/0avhJ.gif

您可以看到拖放可以 运行 部分正确。但问题是:当我将一个节点拖到列表的底部时,在 Node4 下面一点,它会被拖到列表的顶部。

我试图修复它,所以我在 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        
    }
}

终端告诉我,当我将一个节点放到列表顶部或列表底部时,它返回相同的索引:

item:nil,index:0

https://i.stack.imgur.com/7pI7s.gif

如果我删除 .gap 样式:

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

一切都变得正常了。所以可以推断这可能不是我的“移动”方法的问题。

再次抱歉英语,如果有任何帮助,我将不胜感激。

这是我的代码的重要部分:


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


经过一番研究后,我发现多份关于使用 .gap 拖动方式时 NSTableView 错误的报告。 NSOutlineView 似乎继承了该错误。无论如何,我找到了解决方法。

问题是,当您拖动到最后一个顶级项目下方时,传递给 outlineView(_:acceptDrop:item:childIndex)itemchildIndex 始终是 nil0,这与拖动到列表顶部时得到的值完全相同。我能找到区分这两种情况的唯一方法是使用 NSDraggingInfo 中的 draggingLocation 与第一项的单元格框架进行比较,并使用它来翻译 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)
}

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

由于其他拖动方式似乎也有效,我只在设置为 .gap 时才这样做,所以为了测试,我的 outlineView(_:validateDrop:proposedItem:proposedChildIndex:) 看起来像这样:

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
}

但是,与其每次都将其设置为 .gap,不如在大纲视图中设置数据源时只设置一次。

我对Item的定义应该等同于你的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
    }
}

这是我的数据源的完整实现:​​

//------------------------------
@objc class OVDataSource: NSObject, NSOutlineViewDataSource
{
    //------------------------------
    // Just creating some items programmatically for testing
    var items: [Item] =
    {
        trace()
        let items = (1...4).map { Item([=14=]) }
        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)
    }
}

trace() 调用仅用于调试。要么删除它们,要么实施它:

func trace(
    _ message: @autoclosure () -> String = "",
    function: StaticString = #function,
    line: UInt = #line)
{
    #if DEBUG
    print("\(function):\(line): \(message())")
    #endif
}