I post this after you posted your own answer, so this is probably a huge waste of time. But your answer only covers drawing really bare bones on the screen and does not cover a bunch of other interesting things that you need to take care to really reproduce the behavior of Xcode and even go beyond it:
- Drawing a nice connection string such as Xcode (with shadow, outline, and large rounded ends)
- line drawing on multiple screens,
- using Cocoa drag to find the drag target and support spring loads.
Here is a demonstration of what I will explain in this answer:

In this github registry, you can find an Xcode project containing all the code in this answer, as well as the remaining glue code needed to run the demo application.
Drawing a nice connection string like Xcode
The Xcode connection string looks like an old-fashioned barbell . It has a straight strip of arbitrary length with a round bell at each end:

What do we know about this form? The user provides the start and end points (bell centers) by dragging the mouse, and our user interface designer determines the radius of the bells and the thickness of the strip:

The line length is the distance from startPoint to endPoint : length = hypot(endPoint.x - startPoint.x, endPoint.y - startPoint.y) .
To simplify the process of creating a path for this figure, let’s draw it in a standard position with the left bell at the origin and a bar parallel to the x axis. In this pose, here is what we know:

We can create this shape as a path by making a circular arc centered at the origin associated with another (mirror image) circular arc centered at (length, 0) . To create these arcs, we need a mysteryAngle :

We can define a mysteryAngle if we find any of the ends of the arc where the bell meets the bar. In particular, we will find the coordinates of this point:

What do we know about this mysteryPoint ? We know this at the intersection of the bell and the top of the bar. Therefore, we know this at a distance of bellRadius from the origin and at a distance of barThickness / 2 from the x axis:

So, right away we know that mysteryPoint.y = barThickness / 2 , and we can use the Pythagorean theorem to calculate mysteryPoint.x = sqrt(bellRadius² - mysteryPoint.y²) .
When placing a mysteryPoint we can compute the mysteryAngle using our selection of the inverse trigonometry function. Archin, I choose you! mysteryAngle = asin(mysteryPoint.y / bellRadius) .
Now we know everything we need to create a path in a standard pose. To move it from a standard pose to a desired pose (which goes from startPoint to endPoint , remember?), We will use the affine transform. The conversion transforms (moves) the path so that the left bell is centered on startPoint and startPoint path so that the right bell ends in endPoint .
When writing code to create the path, we want to be careful with a few things:
What if the length is so short that the bells overlap? We must handle this gracefully by adjusting the mysteryAngle so that the bells can easily be connected without any strange “negative trait” between them.
What if bellRadius less than barThickness / 2 ? We must handle this gracefully by making bellRadius be at least barThickness / 2 .
What if length is zero? We need to avoid dividing by zero.
Here is my path creation code handling all of these cases:
extension CGPath { class func barbell(from start: CGPoint, to end: CGPoint, barThickness proposedBarThickness: CGFloat, bellRadius proposedBellRadius: CGFloat) -> CGPath { let barThickness = max(0, proposedBarThickness) let bellRadius = max(barThickness / 2, proposedBellRadius) let vector = CGPoint(x: end.x - start.x, y: end.y - start.y) let length = hypot(vector.x, vector.y) if length == 0 { return CGPath(ellipseIn: CGRect(origin: start, size: .zero).insetBy(dx: -bellRadius, dy: -bellRadius), transform: nil) } var yOffset = barThickness / 2 var xOffset = sqrt(bellRadius * bellRadius - yOffset * yOffset) let halfLength = length / 2 if xOffset > halfLength { xOffset = halfLength yOffset = sqrt(bellRadius * bellRadius - xOffset * xOffset) } let jointRadians = asin(yOffset / bellRadius) let path = CGMutablePath() path.addArc(center: .zero, radius: bellRadius, startAngle: jointRadians, endAngle: -jointRadians, clockwise: false) path.addArc(center: CGPoint(x: length, y: 0), radius: bellRadius, startAngle: .pi + jointRadians, endAngle: .pi - jointRadians, clockwise: false) path.closeSubpath() let unitVector = CGPoint(x: vector.x / length, y: vector.y / length) var transform = CGAffineTransform(a: unitVector.x, b: unitVector.y, c: -unitVector.y, d: unitVector.x, tx: start.x, ty: start.y) return path.copy(using: &transform)! } }
Once we have the path, we need to fill it with the correct color, stroke it with the correct color and line width, and draw a shadow around it. I used the Hopper Disassembler on the IDEInterfaceBuilderKit to determine the exact dimensions and colors of Xcode. Xcode draws all of this into a graphical context in the drawRect: user view, but we will make our custom look using CAShapeLayer . We will not finish drawing the shadow in exactly the same way as Xcode, but it is close enough.
class ConnectionView: NSView { struct Parameters { var startPoint = CGPoint.zero var endPoint = CGPoint.zero var barThickness = CGFloat(2) var ballRadius = CGFloat(3) } var parameters = Parameters() { didSet { needsLayout = true } } override init(frame: CGRect) { super.init(frame: frame) commonInit() } required init?(coder decoder: NSCoder) { super.init(coder: decoder) commonInit() } let shapeLayer = CAShapeLayer() override func makeBackingLayer() -> CALayer { return shapeLayer } override func layout() { super.layout() shapeLayer.path = CGPath.barbell(from: parameters.startPoint, to: parameters.endPoint, barThickness: parameters.barThickness, bellRadius: parameters.ballRadius) shapeLayer.shadowPath = CGPath.barbell(from: parameters.startPoint, to: parameters.endPoint, barThickness: parameters.barThickness + shapeLayer.lineWidth / 2, bellRadius: parameters.ballRadius + shapeLayer.lineWidth / 2) } private func commonInit() { wantsLayer = true shapeLayer.lineJoin = kCALineJoinMiter shapeLayer.lineWidth = 0.75 shapeLayer.strokeColor = NSColor.white.cgColor shapeLayer.fillColor = NSColor(calibratedHue: 209/360, saturation: 0.83, brightness: 1, alpha: 1).cgColor shapeLayer.shadowColor = NSColor.selectedControlColor.blended(withFraction: 0.2, of: .black)?.withAlphaComponent(0.85).cgColor shapeLayer.shadowRadius = 3 shapeLayer.shadowOpacity = 1 shapeLayer.shadowOffset = .zero } }
We can check this on the playground to make sure it looks good:
import PlaygroundSupport let view = NSView() view.setFrameSize(CGSize(width: 400, height: 200)) view.wantsLayer = true view.layer!.backgroundColor = NSColor.white.cgColor PlaygroundPage.current.liveView = view for i: CGFloat in stride(from: 0, through: 9, by: CGFloat(0.4)) { let connectionView = ConnectionView(frame: view.bounds) connectionView.parameters.startPoint = CGPoint(x: CGFloat(i) * 40 + 15, y: 50) connectionView.parameters.endPoint = CGPoint(x: CGFloat(i) * 40 + 15, y: 50 + CGFloat(i)) view.addSubview(connectionView) } let connectionView = ConnectionView(frame: view.bounds) connectionView.parameters.startPoint = CGPoint(x: 50, y: 100) connectionView.parameters.endPoint = CGPoint(x: 350, y: 150) view.addSubview(connectionView)
Here is the result:

Multi-screen drawing
If you have multiple screens (displays) attached to your Mac, and if you have “Displays have separate gaps” (which are the defaults) on the control panel of the mission of your system settings, then macOS will not allow a two-window window span. This means that you cannot use a single window to draw a connector line on multiple monitors. This matters if you want the user to connect an object in one window to an object in another window, for example Xcode:
Here is a checklist for drawing a line on multiple screens on top of our other windows:
- We need to create one window on the screen.
- We need to configure each window to fill its screen and be completely transparent without a shadow.
- We need to set the window level of each window to 1 so that it is above our normal windows (which have a window level of 0).
- We must not tell each window to free ourselves when it closes, because we do not like the mysterious failures of the autoresist pool.
- Each window requires its
ConnectionView . - To make the coordinate system uniform, we will adjust the
bounds each ConnectionView so that its coordinate system matches the coordinate system of the screen. - We will tell everyone
ConnectionView to draw the entire connecting line; each view will fix what it draws into its own framework. - This probably will not happen, but we will arrange a notification about whether the screen layout will change. If this happens, we will add / remove / update windows to cover the new agreement.
Create a class to encapsulate all of these parts. With the LineOverlay instance LineOverlay we can update the start and end points of the join as needed and remove the overlay from the screen when we are done.
class LineOverlay { init(startScreenPoint: CGPoint, endScreenPoint: CGPoint) { self.startScreenPoint = startScreenPoint self.endScreenPoint = endScreenPoint NotificationCenter.default.addObserver(self, selector: #selector(LineOverlay.screenLayoutDidChange(_:)), name: .NSApplicationDidChangeScreenParameters, object: nil) synchronizeWindowsToScreens() } var startScreenPoint: CGPoint { didSet { setViewPoints() } } var endScreenPoint: CGPoint { didSet { setViewPoints() } } func removeFromScreen() { windows.forEach { $0.close() } windows.removeAll() } private var windows = [NSWindow]() deinit { NotificationCenter.default.removeObserver(self) removeFromScreen() } @objc private func screenLayoutDidChange(_ note: Notification) { synchronizeWindowsToScreens() } private func synchronizeWindowsToScreens() { var spareWindows = windows windows.removeAll() for screen in NSScreen.screens() ?? [] { let window: NSWindow if let index = spareWindows.index(where: { $0.screen === screen}) { window = spareWindows.remove(at: index) } else { let styleMask = NSWindowStyleMask.borderless window = NSWindow(contentRect: .zero, styleMask: styleMask, backing: .buffered, defer: true, screen: screen) window.contentView = ConnectionView() window.isReleasedWhenClosed = false window.ignoresMouseEvents = true } windows.append(window) window.setFrame(screen.frame, display: true)
Using Cocoa drag and drop to find the drag target and perform spring loading
We need a way to find the (potential) target of removing the connection when the user drags the mouse. It would also be nice to support spring boot.
If you do not know, spring loading is a macOS function in which, if you momentarily click and drag a container, macOS will automatically open the container without interrupting the drag and drop. Examples:
- If you drag the window, rather than the very nearest window, macOS will bring the window to the forefront.
- if you drag the Finder icon icon and Finder will open the folder window so you can drag the item to the folder.
- If you drag the pointer over the tab (at the top of the window) in Safari or Chrome, the browser will select the tab, allowing you to drop the item on the tab.
- If you control dragging and dropping a connection in Xcode onto a menu item in the menu bar of your storyboard or xib, Xcode will open the item menu.
If we use Cocoa's standard drag-and-drop support to track drag and drop and find a return target, we will get spring "free download support."
To support Cocoa's standard drag and drop, we need to implement the NSDraggingSource protocol for some object, so we can drag something and the NSDraggingDestination protocol to some other object so that we can drag something, We implement NSDraggingSource in a class called ConnectionDragController , and we we implement NSDraggingDestination in a user class of the form DragEndpoint .
First, consider a DragEndpoint (a subclass of NSView ). NSView already complies with NSDraggingDestination , but has nothing to do with it. We need to implement the four NSDraggingDestination protocol NSDraggingDestination . A session interception will call these methods to tell us when the drag enters and leaves the destination, when the target is completely finished, and when to “perform” the drag (provided that this destination was where the drag ended). We also need to register the type of drag and drop data that we can accept.
We want to be careful about two things:
- We only want to accept the drag and drop, which is an attempt to connect. We can find out if the drag and drop is a connection attempt by checking if the source is our custom drag and drop source,
ConnectionDragController . - We will create a
DragEndpoint as a drag source (only visually, not programmatically). We do not want the user to connect the endpoint to himself, so we need to make sure that the endpoint that is the source of the connection cannot also be used as the connection target. We will do this using the state property, which keeps track of whether this endpoint is inactive, acting as a source or acting as a target.
When the user finally releases the mouse button over a valid destination, the drag-and-drop session does the task of the task to "perform" the drag and drop by sending it performDragOperation(_:) . The session does not tell the drag source where it finally fell. But we probably want to do the work of connecting (in our data model) to the source. Think about how this works in Xcode: when you control a drag from a button in Main.storyboard to ViewController.swift and create an action, the connection is not written to ViewController.swift , where the drag ended; it is written to Main.storyboard as part of the button's persistent data. Therefore, when a drag and drop session tells the recipient to "perform" a drag and drop, we will transfer our destination ( DragEndpoint ) back to the connect(to:) method in the drag source, where the real work can happen.
class DragEndpoint: NSView { enum State { case idle case source case target } var state: State = State.idle { didSet { needsLayout = true } } public override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { guard case .idle = state else { return [] } guard (sender.draggingSource() as? ConnectionDragController)?.sourceEndpoint != nil else { return [] } state = .target return sender.draggingSourceOperationMask() } public override func draggingExited(_ sender: NSDraggingInfo?) { guard case .target = state else { return } state = .idle } public override func draggingEnded(_ sender: NSDraggingInfo?) { guard case .target = state else { return } state = .idle } public override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { guard let controller = sender.draggingSource() as? ConnectionDragController else { return false } controller.connect(to: self) return true } override init(frame: NSRect) { super.init(frame: frame) commonInit() } required init?(coder decoder: NSCoder) { super.init(coder: decoder) commonInit() } private func commonInit() { wantsLayer = true register(forDraggedTypes: [kUTTypeData as String]) }
Now we can implement ConnectionDragController as a drag source and control the drag and drop session and LineOverlay .
- To start a drag and drop session, we must call
beginDraggingSession(with:event:source:) in the view; this will be the DragEndpoint where the mouse-down event occurred. - The session notifies the source when the actual start of the drag, when it moves, and when it ends. We use these notifications to create and update
LineOverlay . - Since we do not provide any images as part of our
NSDraggingItem , the session will not draw anything that needs to be dragged. It's good. - By default, if the drag ends outside the valid destination, the session will animate ... nothing ... return to the start of the drag before notifying the source of the completion of the drag. During this animation, the frozen overlay line is superimposed. He looks broken. We tell the session that it does not come to life before the start, in order to avoid this.
Since this is just a demo, the “work” we do to connect the endpoints in connect(to:) just prints their descriptions. In a real application, you really change your data model.
class ConnectionDragController: NSObject, NSDraggingSource { var sourceEndpoint: DragEndpoint? func connect(to target: DragEndpoint) { Swift.print("Connect \(sourceEndpoint!) to \(target)") } func trackDrag(forMouseDownEvent mouseDownEvent: NSEvent, in sourceEndpoint: DragEndpoint) { self.sourceEndpoint = sourceEndpoint let item = NSDraggingItem(pasteboardWriter: NSPasteboardItem(pasteboardPropertyList: "\(view)", ofType: kUTTypeData as String)!) let session = sourceEndpoint.beginDraggingSession(with: [item], event: mouseDownEvent, source: self) session.animatesToStartingPositionsOnCancelOrFail = false } func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation { switch context { case .withinApplication: return .generic case .outsideApplication: return [] } } func draggingSession(_ session: NSDraggingSession, willBeginAt screenPoint: NSPoint) { sourceEndpoint?.state = .source lineOverlay = LineOverlay(startScreenPoint: screenPoint, endScreenPoint: screenPoint) } func draggingSession(_ session: NSDraggingSession, movedTo screenPoint: NSPoint) { lineOverlay?.endScreenPoint = screenPoint } func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { lineOverlay?.removeFromScreen() sourceEndpoint?.state = .idle } func ignoreModifierKeys(for session: NSDraggingSession) -> Bool { return true } private var lineOverlay: LineOverlay? }
That is all you need. As a reminder, you can find the link at the top of this answer to the github repository containing the full demo project.