How to play this blue Xcode transition line

I would like to play the blue Xcode transition line in my application.

Do you know how to code this?

Xcode blue drag line

I know how to draw a line using Core Graphics ... But this line should be on top of all other elements (on the screen).

+5
source share
3 answers

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:

demo

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:

basic form

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:

givens

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:

givens at origin

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 :

secret corner

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:

secret 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:

mystery point givens

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:

playground 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) // Make the view geometry match the screen geometry for simplicity. let view = window.contentView! var rect = view.bounds rect = view.convert(rect, to: nil) rect = window.convertToScreen(rect) view.bounds = rect window.backgroundColor = .clear window.isOpaque = false window.hasShadow = false window.isOneShot = true window.level = 1 window.contentView?.needsLayout = true window.orderFront(nil) } spareWindows.forEach { $0.close() } } private func setViewPoints() { for window in windows { let view = window.contentView! as! ConnectionView view.parameters.startPoint = startScreenPoint view.parameters.endPoint = endScreenPoint } } } 

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]) } // Drawing code omitted here but is in my github repo. } 

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.

+18
source

Using transparent NSWindow:

enter image description here

 var window: NSWindow! func createLinePath(from: NSPoint, to: NSPoint) -> CGPath { let path = CGMutablePath() path.move(to: from) path.addLine(to: to) return path } override func viewDidLoad() { super.viewDidLoad() //Transparent window window = NSWindow() window.styleMask = .borderless window.backgroundColor = .clear window.isOpaque = false window.hasShadow = false //Line let line = CAShapeLayer() line.path = createLinePath(from: NSPoint(x: 0, y: 0), to: NSPoint(x: 100, y: 100)) line.lineWidth = 10.0 line.strokeColor = NSColor.blue.cgColor //Update NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) { let newPos = NSEvent.mouseLocation() line.path = self.createLinePath(from: NSPoint(x: 0, y: 0), to: newPos) return $0 } window.contentView!.layer = line window.contentView!.wantsLayer = true window.setFrame(NSScreen.main()!.frame, display: true) window.makeKeyAndOrderFront(nil) } 
+3
source

Trying to make Rob Mayoff's excellent solution above in my own project interface, which is based on NSOutlineView , I ran into several problems. In case this helps anyone who is trying to achieve the same, I will detail these traps in this answer.

The sample code provided in the solution detects the start of a drag by implementing mouseDown(with:) on the view controller and then calling hittest() on the window's content view to get a DragEndpoint subview where (potential) resistance arises. When using contour representations, this leads to two traps, described in detail in the following sections.

1. Mouse event

It seems that when a table or schema view is considered, mouseDown(with:) never called in the view controller, and we should instead override this method in the view itself . .. p>

2. Hit testing

NSTableView - and by extension NSOutlineView - overrides the NSResponder validateProposedFirstResponder(_:for:) method, and this causes the hittest() method to fail: it always returns the schema view itself and all sub-items (including our target DragEndpoint subview inside the cell) remain inaccessible.

From the documentation :

Views or controls in the table sometimes need to respond to incoming Events. To determine if the current mouse event should call the table view validateProposedFirstResponder:forEvent: in the hitTest implementation. If you subclass the table, you can override validateProposedFirstResponder:forEvent: to indicate which views can become the first responder. This way you get mouse events.

First I tried to override:

 override func validateProposedFirstResponder(_ responder: NSResponder, for event: NSEvent?) -> Bool { if responder is DragEndpoint { return true } return super.validateProposedFirstResponder(responder, for: event) } 

... and it worked, but reading the documentation further suggests a more reasonable, less intrusive approach:

The standard implementation of NSTableView validateProposedFirstResponder:forEvent: uses the following logic:

  • Return YES for all suggested views of the first responder if they are not instances or subclasses of NSControl .

  • Determine if the first responder is an instance or subclass of NSControl . If the control is an NSButton object, return YES . If the control is not an NSButton , call the hitTestForEvent:inRect:ofView: to see if the remote area is being tracked (i.e. NSCellHitTrackableArea ) or is an editable area of ​​text (i.e. NSCellHitEditableTextArea ) and return the appropriate value. Note that if a text area is hit, NSTableView also delays the action of the first responder.

(my emphasis)

... which is strange, because it seems that he should say:

  • Return NO for all proposed representations of the first responder if they are not NSControl instances or subclasses.

Rob, DragEndpoint NSControl ( NSView ), .

3.

NSOutlineView ( ), , , NSDraggingSource . draggingSession(_:willBeginAt:) ( ).

mouseDown(with:) DragEndpoint : (, , ). , , springloading "".

ConnectionDragController : tackDrag() , DragEndpoint NSDraggingSource .

Ideally, I would like to avoid subclassification NSOutlineView(this is discouraged) and instead implement this behavior more purely, exclusively through the delegate / data source of the schema and / or external classes (for example, the original ConnectionDragController), but it seems that this is impossible.

I don't have a spring-loaded part yet (it worked in an instant, but not now, so I still look at it ...).


I also made a sample project, but I'm still fixing minor issues. I will send a link to the GiHub repository as soon as it is ready.

+2
source

Source: https://habr.com/ru/post/1267301/


All Articles