16

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.

Frank R
  • 841
  • 5
  • 13
  • 1
    I didn't downvote, but at first glance the question looks like a "I don't want to research/learn, give me code to copy" question. – Willeke Mar 07 '23 at 13:18
  • 3
    Weird. I am definitely not asking for code, I’m looking for a *technique* to approach this problem. If anyone has an approach to share, I’d be happy to try it and post the code here. – Frank R Mar 07 '23 at 18:25

2 Answers2

3

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
    }
}
Frank R
  • 841
  • 5
  • 13
1

I haven't tried this so I don't know if it will work, but it's worth a try.

The idea would be to use the NSWindowTab object associated with your window (see NSWindow.tab) and set an accessoryView on the tab that could be registered to accept the drag. You would set constraints on your accessory view to try to have it fill the entire tab - you probably need to override updateConstraints in the NSView subclass you use for your accessory view since to pin it to its superview once AppKit actually inserts it into the view hierarchy of the tab itself.

What I don't know is whether AppKit will always force your accessory view to only fill up space not already taken up by the title that gets drawn for you in the tab. If it does force it to the side, then you might need to also set an empty title for the NSWindowTab instance and then have your own text field in your custom view that displays the title instead. Or it's possible that there won't be any way to force your view to take up the entire tab, in which case the user could only drop onto whatever space your accessory view is taking up.

If you have a chance to try, I'll be curious to see if it actually works!

Brian Webster
  • 11,915
  • 4
  • 44
  • 58