答案 0 :(得分:21)
我发布了你自己的答案之后发布这个,所以这可能是浪费时间。但是你的答案只包括在屏幕上绘制一个非常简单的线条,并没有涵盖一些你需要注意的真正复制Xcode行为的其他有趣的东西,甚至超越它:
以下是我将在此答案中解释的内容演示:
In this github repo,您可以找到一个Xcode项目,其中包含此答案中的所有代码以及运行演示应用程序所需的剩余粘合代码。
Xcode的连接线看起来像old-timey barbell。它有一个任意长度的直条,每端有一个圆形钟:
我们对这种形状了解多少?用户通过拖动鼠标来提供起点和终点(钟的中心),我们的用户界面设计师指定钟的半径和条的粗细:
条形的长度是从startPoint
到endPoint
的距离:length = hypot(endPoint.x - startPoint.x, endPoint.y - startPoint.y)
。
为了简化为此形状创建路径的过程,让我们以标准姿势绘制它,左边的钟形和原点与x轴平行。在这个姿势中,这就是我们所知道的:
我们可以通过使圆弧以原点为中心,连接到以(length, 0)
为中心的另一个(镜像)圆弧,将此形状创建为路径。要创建这些弧,我们需要mysteryAngle
:
我们可以找出mysteryAngle
,如果我们能找到钟与柱子相遇的任何弧端点。具体来说,我们将找到这一点的坐标:
我们对mysteryPoint
了解多少?我们知道它在钟和酒吧顶部的交叉处。所以我们知道距离原点距离bellRadius
,距离x轴距离barThickness / 2
:
所以我们立即知道mysteryPoint.y = barThickness / 2
,我们可以使用毕达哥拉斯定理来计算mysteryPoint.x = sqrt(bellRadius² - mysteryPoint.y²)
。
找到mysteryPoint
后,我们可以使用我们选择的反三角函数来计算mysteryAngle
。 Arcsine,我选择你了! mysteryAngle = asin(mysteryPoint.y / bellRadius)
。
我们现在知道在标准姿势中创建路径所需的一切。要将它从标准姿势移动到所需姿势(从startPoint
变为endPoint
,请记住吗?),我们将应用仿射变换。变换将转换(移动)路径,以便左钟以startPoint
为中心并旋转路径,使右边的铃声在endPoint
处结束。
在编写创建路径的代码时,我们要注意以下几点:
如果长度太短以至于铃铛重叠怎么办?我们应该通过调整mysteryAngle
来优雅地处理它,以便铃声无缝连接,它们之间没有奇怪的“负面条”。
如果bellRadius
小于barThickness / 2
怎么办?我们应该通过强制bellRadius
至少barThickness / 2
来优雅地处理。
如果length
为零怎么办?我们需要避免被零除。
这是我创建路径的代码,处理所有这些情况:
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)!
}
}
一旦我们有了路径,我们需要用正确的颜色填充它,用正确的颜色和线宽划过它,并在它周围画一个阴影。我在IDEInterfaceBuilderKit
上使用了Hopper Disassembler来确定Xcode的确切大小和颜色。 Xcode将其全部绘制到自定义视图drawRect:
中的图形上下文中,但我们会使自定义视图使用CAShapeLayer
。我们最终不会像Xcode一样完全绘制阴影 ,但它足够接近。
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
}
}
我们可以在操场上测试它,以确保它看起来不错:
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)
结果如下:
如果您的Mac上连接了多个屏幕(显示器),并且如果您在“系统偏好设置”的“任务控制”面板中打开了“显示器具有单独的空间”(这是默认设置),那么macOS将不会让窗口跨越两个屏幕。这意味着您无法使用单个窗口在多个监视器上绘制连接线。如果你想让用户将一个窗口中的对象连接到另一个窗口中的对象,就像Xcode那样,这很重要:
这是在我们的其他窗口之上绘制线条的清单,横跨多个屏幕:
ConnectionView
。bounds
的{{1}},使其坐标系与屏幕坐标系匹配。ConnectionView
画出整条连线;每个视图都会剪切它绘制的内容。让我们创建一个类来封装所有这些细节。使用ConnectionView
的实例,我们可以根据需要更新连接的起点和终点,并在我们完成后从屏幕中删除叠加层。
LineOverlay
当用户拖动鼠标时,我们需要一种方法来找到连接的(潜在)放下目标。支持弹簧加载也很不错。
如果您不知道,弹簧加载是一项macOS功能,如果您将拖动悬停在容器上片刻,macOS将自动打开容器而不会中断拖动。例子:
如果我们使用标准的Cocoa拖放支撑来跟踪阻力并找到掉落目标,那么我们将“免费”获得弹簧加载支持。
为了支持标准的Cocoa拖放,我们需要在某个对象上实现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's 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
}
}
}
协议,因此我们可以从中拖动,并在某些对象上拖动NSDraggingSource
协议其他对象,所以我们可以将拖到的东西。我们将在名为NSDraggingDestination
的类中实施NSDraggingSource
,并在名为ConnectionDragController
的自定义视图类中实现NSDraggingDestination
。
首先,让我们看看DragEndpoint
(DragEndpoint
子类)。 NSView
已经符合NSView
,但对此并没有做多少。我们需要实现NSDraggingDestination
协议的四种方法。拖动会话将调用这些方法让我们知道拖动进入和离开目的地的时间,拖动完全结束时,以及何时“执行”拖动(假设此目的地是拖动实际结束的位置)。我们还需要注册我们可以接受的拖动数据类型。
我们要小心两件事:
NSDraggingDestination
来确定拖动是否为连接尝试。ConnectionDragController
看作是拖动源(仅在视觉上,而不是以编程方式)。我们不想让用户将端点连接到自身,因此我们需要确保作为连接源的端点也不能用作连接的目标。我们使用DragEndpoint
属性执行此操作,该属性可跟踪此端点是空闲,充当源还是充当目标。当用户最终在有效的放置目的地上释放鼠标按钮时,拖动会话使目标的责任是通过发送state
来“执行”拖动。会话没有告诉拖动源最终发生了什么。但我们可能希望在源代码中完成连接(在我们的数据模型中)的工作。想想它在Xcode中的工作原理:当你控制 - 从performDragOperation(_:)
中的按钮拖动到Main.storyboard
并创建动作时,连接不会记录在拖动结束的ViewController.swift
中;它作为按钮的持久数据的一部分记录在ViewController.swift
中。因此,当拖动会话告诉目标“执行”拖动时,我们会将目标(Main.storyboard
)传递回拖动源上的DragEndpoint
方法,其中实际工作可以发生。
connect(to:)
现在我们可以实施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.
}
作为拖动源并管理拖动会话和ConnectionDragController
。
LineOverlay
;它将是发生鼠标事件的beginDraggingSession(with:event:source:)
。DragEndpoint
。LineOverlay
中提供任何图片,因此会话无法绘制任何被拖动的内容。这很好。由于这只是一个演示,我们在NSDraggingItem
中连接端点所做的“工作”只是打印它们的描述。在真实应用中,您实际上是在修改数据模型。
connect(to:)
这就是你所需要的一切。提醒一下,您可以在此答案的顶部找到包含完整演示项目的github仓库的链接。
答案 1 :(得分:3)
使用透明的NSWindow:
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)
}
答案 2 :(得分:3)
尝试将Rob Mayoff's excellent solution above引入我自己的项目界面,该界面基于NSOutlineView
,我遇到了一些问题。如果它有助于任何人试图达到同样的目的,我将在这个答案中详述这些陷阱。
解决方案中提供的示例代码通过在视图控制器上实施mouseDown(with:)
,然后在窗口上调用hittest()
来检测拖动的开始内容视图,以获取(潜在)拖动所源自的DragEndpoint
子视图。使用大纲视图时,这会导致下一节中详述的两个陷阱。
当涉及表格视图或大纲视图时,似乎永远不会在视图控制器上调用mouseDown(with:)
,我们需要在大纲视图本身中覆盖该方法。
NSTableView
- 以及NSOutlineView
- 覆盖NSResponder
方法validateProposedFirstResponder(_:for:)
,这会导致hittest()
方法失败:它始终返回大纲视图本身,以及所有子视图(包括单元格内的目标DragEndpoint
子视图)仍然无法访问。
表格中的视图或控件有时需要响应传入 事件。确定特定子视图是否应该接收 当前鼠标事件,表视图调用
validateProposedFirstResponder:forEvent:
执行中hitTest
。如果创建表视图子类,则可以覆盖validateProposedFirstResponder:forEvent:
指定哪些视图可以 成为第一响应者。这样,您就会收到鼠标事件。
起初我尝试重写:
override func validateProposedFirstResponder(_ responder: NSResponder, for event: NSEvent?) -> Bool {
if responder is DragEndpoint {
return true
}
return super.validateProposedFirstResponder(responder, for: event)
}
......并且它有效,但阅读文档进一步表明了一种更智能,更少侵入性的方法:
默认
NSTableView
的实现validateProposedFirstResponder:forEvent:
使用以下逻辑:
为所有提议的第一响应者视图返回
YES
,除非它们是NSControl
的实例或子类。- 醇>
确定是否建议 第一响应者是
NSControl
实例或子类。如果控制 是一个NSButton
对象,返回YES
。如果控件不是NSButton
, 调用控件的hitTestForEvent:inRect:ofView:
来查看是否 命中区域是可追踪的(即NSCellHitTrackableArea
)或是 可编辑的文本区域(即NSCellHitEditableTextArea
),然后返回 适当的价值。请注意,如果点击了文字区域,NSTableView
也推迟了第一响应者的行动。
(强调我的)
......这很奇怪,因为它应该说:
- 为所有提议的第一响应者视图返回
的实例或子类 醇>NO
,除非它们是NSControl
。
,但无论如何,我修改了Rob的代码,使DragEndpoint
成为NSControl
的子类(不仅仅是NSView
),而且也有效。
因为NSOutlineView
仅通过其数据源协议公开了有限数量的拖放事件(并且拖动会话本身无法从数据源进行有意义的修改&#39 ; s侧),似乎除非我们继承大纲视图并覆盖NSDraggingSource
方法,否则无法完全控制拖动会话。
只有在大纲视图本身覆盖draggingSession(_:willBeginAt:)
,才能阻止调用超类实现并启动实际项目拖动(显示拖动的行图像)。
我们可以从mouseDown(with:)
子视图的DragEndpoint
方法开始一个单独的拖动会话:实现时,在大纲视图上的相同方法之前称为反过来是触发拖动会话启动的原因。但是如果我们将拖动会话从大纲视图中移开,那么似乎不可能免费 springloading ""在可展开项目上方拖动时。
相反,我放弃了ConnectionDragController
类并将其所有逻辑移动到大纲视图子类:tackDrag()
方法,活动DragEndpoint
属性以及{{{{}}的所有方法1}}协议进入大纲视图。
理想情况下,我本来希望避免继承NSDraggingSource
(不鼓励),而是通过大纲视图的委托/数据源和/或外部类(如原来的NSOutlineView
),但似乎不可能。
我还没有让弹簧加载部件工作( 正在工作,但现在不行,所以我还在调查......)。 / p>
我也做了一个示例项目,但我仍然在解决小问题。我会在准备好后立即发布一个指向GiHub存储库的链接。