Search code examples
swiftmacosdrag-and-dropappkit

How can I accept a drop for a dragged item on a window tab in macOS/AppKit?


Mouse cursor dragging bookmark file to a Safari window tab

In Safari, I can drag an item (like a URL or even a .webloc bookmark from the finder) right onto a tab to open.

How do I make the window tab bar item view a drop target in my own AppKit app?

I’d like to be able to accept dropping an NSPasteboard similar to how NSView instances can:

override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
    // Handle drop
}

But the tab bar and the contained NSTabButton instances are provided by the system. Subclassing or extending NSTabButton doesn’t seem to work because it’s private.


Solution

  • Brian Webster’s brilliant answer (thank you!) inspired this solution.

    Window with tabs that can accept a drop

    I added the demo code to a fully working project on GitHub.

    First, we add a custom accessory view to the window’s tab when creating the window. We pass a reference to the NSWindowController so that we can easily notify it whenever something was dropped on the tab item.

    window.tab.accessoryView = TabAccessoryView(windowController: windowController)
    

    This custom accessoryView (TabAccessoryView) is not the view that will accept the drops, because the accessory view is confined to an NSStackView, together with the close button and the title label, covering only a portion of the tab next to the title label.

    So instead, we use the fact that the accessoryView is part of the NSTabButton’s view hierarchy to inject another custom view (TabDropTargetView) behind the NSStackView

    class TabAccessoryView: NSView {
    
        weak private(set) var windowController: NSWindowController?
    
        private let tabDropTargetView: TabDropTargetView
    
        init(windowController: NSWindowController? = nil) {
            self.windowController = windowController
            self.tabDropTargetView = TabDropTargetView(windowController: windowController)
            super.init(frame: .zero)
        }
    
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
    
        override func viewDidMoveToWindow() {
            guard tabDropTargetView.superview == nil else { return }
    
            // DEBUG: Highlight accessory view
            wantsLayer = true
            layer?.backgroundColor = NSColor.red.withAlphaComponent(0.1).cgColor
    
            // The NSTabButton close button, title, and accessory view are contained in a stack view:
            guard let stackView = superview as? NSStackView,
                  let backgroundView = stackView.superview else { return }
    
            // Add the drop target view behind the NSTabButton’s NSStackView and pin it to the edges
            backgroundView.addSubview(tabDropTargetView, positioned: .below, relativeTo: stackView)
            tabDropTargetView.translatesAutoresizingMaskIntoConstraints = false
            tabDropTargetView.leadingAnchor.constraint(equalTo: backgroundView.leadingAnchor).isActive = true
            tabDropTargetView.trailingAnchor.constraint(equalTo: backgroundView.trailingAnchor).isActive = true
            tabDropTargetView.topAnchor.constraint(equalTo: backgroundView.topAnchor).isActive = true
            tabDropTargetView.bottomAnchor.constraint(equalTo: backgroundView.bottomAnchor).isActive = true
        }
    
    }
    

    … which will handle the dropped item:

    class TabDropTargetView: NSView {
        private(set) weak var windowController: NSWindowController?
    
        let allowedDropTypes: Array<NSPasteboard.PasteboardType> = [.URL, .fileContents, .string, .html, .rtf]
    
        init(windowController: NSWindowController? = nil) {
            self.windowController = windowController
            super.init(frame: .zero)
    
            // Tell the system that we accept drops on this view
            registerForDraggedTypes(allowedDropTypes)
        }
    
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
    
        override func viewDidMoveToWindow() {
            // DEBUG: Highlight drop target view
            wantsLayer = true
            layer?.backgroundColor = NSColor.green.withAlphaComponent(0.05).cgColor
        }
    
        override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
            return .copy
        }
    
        override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
            return .copy
        }
    
        override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
            // Optional: Ignore drags from the same window
            guard (sender.draggingSource as? NSView)?.window != window else { return false }
    
            // Check if the dropped item contains text:
            let pasteboard = sender.draggingPasteboard
            guard let availableType = pasteboard.availableType(from: allowedDropTypes),
                  let text = pasteboard.string(forType: availableType) else {
                return false
            }
    
            if let windowController = windowController as? WindowController {
                // Use the reference to the tab’s NSWindowController to pass the dropped item
                windowController.handleDroppedText(text)
            }
    
            return true
        }
    }