如何重现此 Xcode 蓝色拖线
How to reproduce this Xcode blue drag line
我想在我的应用程序中重现 Xcode 蓝色拖动线。
你知道编码的方法吗?
我知道如何使用 Core Graphics 画线 ...
但是这一行必须在所有其他项目的顶部(在屏幕上)。
使用透明的 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 [=10=]
}
window.contentView!.layer = line
window.contentView!.wantsLayer = true
window.setFrame(NSScreen.main()!.frame, display: true)
window.makeKeyAndOrderFront(nil)
}
我会在您发布自己的答案后发布此内容,所以这可能会浪费大量时间。但是您的答案仅涵盖在屏幕上绘制一条真正的准系统,而没有涵盖您需要注意的一堆其他有趣的东西,以真正复制 Xcode 的行为,甚至超越它:
- 像 Xcode 一样画一条漂亮的连接线(有阴影、轮廓和大圆头),
- 跨多个屏幕画线,
- 使用Cocoa拖拽寻找拖拽目标,支持spring加载
这是我将在这个答案中解释的演示:
In this github repo,您可以找到一个 Xcode 项目,其中包含此答案中的所有代码以及 运行 演示应用程序所需的剩余粘合代码。
画一条像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 将不会让 window 跨越两个屏幕。这意味着您不能使用单个 window 来绘制跨多个显示器的连接线。如果您想让用户将一个 window 中的对象连接到另一个 window 中的对象,这很重要,就像 Xcode 所做的那样:
这是在我们的其他 windows 之上跨多个屏幕画线的清单:
- 我们需要为每个屏幕创建一个 window。
- 我们需要设置每个 window 来填满它的屏幕并且完全透明,没有阴影。
- 我们需要将每个 window 的 window 级别设置为 1,以使其高于我们的正常 windows(window 级别为 0)。
- 我们需要告诉每个 window not 在关闭时释放自己,因为我们不喜欢神秘的自动释放池崩溃。
- 每个 window 需要自己的
ConnectionView
。
- 为了保持坐标系统一,我们将调整每个
ConnectionView
的 bounds
,使其坐标系与屏幕坐标系匹配。
- 我们会告诉每个
ConnectionView
绘制整条连接线;每个视图都会将其绘制的内容裁剪到自己的边界。
- 这可能不会发生,但如果屏幕排列发生变化,我们会安排通知。如果发生这种情况,我们将 add/remove/update windows 涵盖新安排。
让我们做一个class来封装所有这些细节。使用 LineOverlay
的实例,我们可以根据需要更新连接的起点和终点,并在完成后从屏幕上移除覆盖。
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 { [=13=].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: { [=13=].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 { [=13=].close() }
}
private func setViewPoints() {
for window in windows {
let view = window.contentView! as! ConnectionView
view.parameters.startPoint = startScreenPoint
view.parameters.endPoint = endScreenPoint
}
}
}
使用Cocoa拖拽找到拖拽目标并执行spring-loading
我们需要一种方法来在用户拖动鼠标时找到连接的(潜在)放置目标。支持spring加载也很好。
如果您不知道,spring加载是 macOS 的一项功能,如果您将拖动鼠标悬停在容器上片刻,macOS 会自动打开容器而不会中断拖动。示例:
- 如果您拖到不是最前面的 window 上 window,macOS 会将 window 移到最前面。
- 如果您拖到 Finder 文件夹图标上,Finder 将打开文件夹 window 让您拖到文件夹中的项目上。
- 如果您在 Safari 或 Chrome 中拖动到选项卡句柄(在 window 的顶部),浏览器将 select 选项卡,让您将项目放入选项卡。
- 如果您按住控制键将 Xcode 中的连接拖到故事板或 xib 菜单栏中的菜单项上,Xcode 将打开该项目的菜单。
如果我们使用标准 Cocoa 拖放支持来跟踪拖放并找到放置目标,那么我们将“免费”获得 spring 加载支持。
为了支持标准的Cocoa拖放,我们需要在某些对象上实现NSDraggingSource
协议,这样我们就可以从中拖放东西,并且其他对象上的 NSDraggingDestination
协议,因此我们可以将 拖到 某些东西。我们将在名为 ConnectionDragController
的 class 中实现 NSDraggingSource
,并在名为 DragEndpoint
的自定义视图 class 中实现 NSDraggingDestination
。
首先,让我们看一下DragEndpoint
(一个NSView
子class)。 NSView
已经符合 NSDraggingDestination
,但并没有做太多事情。我们需要实现 NSDraggingDestination
协议的四个方法。拖动会话将调用这些方法来让我们知道拖动何时进入和离开目的地、拖动何时完全结束以及何时“执行”拖动(假设该目的地是拖动实际结束的地方)。我们还需要注册我们可以接受的拖动数据类型。
我们要注意两件事:
- 我们只想接受作为连接尝试的拖动。我们可以通过检查源是否是我们自定义的拖动源来判断拖动是否是连接尝试,
ConnectionDragController
.
- 我们将使
DragEndpoint
看起来像是拖动源(仅在视觉上,而不是以编程方式)。我们不想让用户将端点连接到自己,因此我们需要确保作为连接源的端点不能同时用作连接的目标。我们将使用 state
属性 来跟踪此端点是空闲的、充当源还是充当目标。
当用户最终在有效的放置目标上释放鼠标按钮时,拖动会话使目标负责通过发送拖动来“执行”拖动 performDragOperation(_:)
。会话不会告诉拖动源最终发生放置的位置。但我们可能希望在源中完成建立连接(在我们的数据模型中)的工作。想想它在 Xcode 中是如何工作的:当您从 Main.storyboard
中的一个按钮按住 control 并拖动到 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:)
中连接端点所做的“工作”只是打印它们的描述。在真实的应用程序中,您实际上会修改数据模型。
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?
}
这就是您所需要的。提醒一下,您可以在包含完整演示项目的 github 存储库的此答案顶部找到 link。
尝试将 应用到我自己的项目界面中,该界面基于 NSOutlineView
,我 运行 遇到了一些问题。如果它能帮助任何试图实现相同目标的人,我将在这个答案中详细说明这些陷阱。
解决方案中提供的示例代码通过在 视图控制器 上实现 mouseDown(with:)
,然后在window 的内容视图,以便获得(潜在的)拖动来源的 DragEndpoint
子视图。使用大纲视图时,这会导致两个陷阱,将在下一节中详细介绍。
1。鼠标按下事件
似乎当涉及 table 视图或大纲视图时,mouseDown(with:)
永远不会在视图控制器上调用,我们需要在 中覆盖该方法大纲视图本身。
2。命中测试
NSTableView
- 通过扩展,NSOutlineView
- 覆盖 NSResponder
方法 validateProposedFirstResponder(_:for:)
,这会导致 hittest()
方法失败:它总是returns 大纲视图本身和所有子视图(包括单元格内我们的目标 DragEndpoint
子视图)仍然无法访问。
来自 documentation:
Views or controls in a table sometimes need to respond to incoming
events. To determine whether a particular subview should receive the
current mouse event, a table view calls
validateProposedFirstResponder:forEvent:
in its implementation of
hitTest
. If you create a table view subclass, you can override
validateProposedFirstResponder:forEvent:
to specify which views can
become the first responder. In this way, you receive mouse events.
起初我尝试覆盖:
override func validateProposedFirstResponder(_ responder: NSResponder, for event: NSEvent?) -> Bool {
if responder is DragEndpoint {
return true
}
return super.validateProposedFirstResponder(responder, for: event)
}
...它奏效了,但进一步阅读文档表明了一种更智能、侵入性更小的方法:
The default NSTableView
implementation of
validateProposedFirstResponder:forEvent:
uses the following logic:
Return YES
for all proposed first responder views unless they are
instances or subclasses of NSControl
.
Determine whether the proposed
first responder is an NSControl
instance or subclass. If the control
is an NSButton
object, return YES
. If the control is not an NSButton
,
call the control’s hitTestForEvent:inRect:ofView:
to see whether the
hit area is trackable (that is, NSCellHitTrackableArea
) or is an
editable text area (that is, NSCellHitEditableTextArea
), and return
the appropriate value. Note that if a text area is hit, NSTableView
also delays the first responder action.
(强调我的)
...这很奇怪,因为感觉应该说:
- Return
NO
for all proposed first responder views unless they are
instances or subclasses of NSControl
.
,但无论如何,我改为修改 Rob 的代码,使 DragEndpoint
成为 NSControl
的子 class(而不仅仅是 NSView
),这也有效。
3。管理拖动会话
因为 NSOutlineView
仅通过其 数据源 协议公开了有限数量的拖放事件(拖放会话本身无法从中进行有意义的修改数据源端),似乎完全控制拖动会话是不可能的,除非我们 subclass 大纲视图并覆盖 NSDraggingSource
方法。
只有在大纲视图本身覆盖 draggingSession(_:willBeginAt:)
才能防止调用 superclass 实现并开始实际的项目拖动(显示拖动的行图像)。
我们可以从 DragEndpoint
子视图的 mouseDown(with:)
方法开始一个单独的拖动会话:实现时,它在 before 上调用相同的方法大纲视图(这又是触发拖动会话开始的原因)。但是,如果我们将拖动会话从大纲视图中移开,则在可扩展项目上方拖动时,似乎不可能有 springloading "for free"。
因此,我放弃了 ConnectionDragController
class 并将其所有逻辑移动到大纲视图 subclass:tackDrag()
方法,活动 DragEndpoint
属性,以及NSDraggingSource
协议的所有方法进入大纲视图。
理想情况下,我希望避免 subclassing NSOutlineView
(不鼓励这样做),而是更干净地实现此行为,完全通过大纲视图的 delegate/data source and/or外置class是的(像原来的ConnectionDragController
),不过好像是不可能的
我还没有让 springloading 部分工作(它 是 工作,但不是现在所以我还在研究它...)。
我也做了一个示例项目,但我仍在修复小问题。一旦准备就绪,我将 post 发送 link 到 GiHub 存储库。
我想在我的应用程序中重现 Xcode 蓝色拖动线。
你知道编码的方法吗?
我知道如何使用 Core Graphics 画线 ... 但是这一行必须在所有其他项目的顶部(在屏幕上)。
使用透明的 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 [=10=]
}
window.contentView!.layer = line
window.contentView!.wantsLayer = true
window.setFrame(NSScreen.main()!.frame, display: true)
window.makeKeyAndOrderFront(nil)
}
我会在您发布自己的答案后发布此内容,所以这可能会浪费大量时间。但是您的答案仅涵盖在屏幕上绘制一条真正的准系统,而没有涵盖您需要注意的一堆其他有趣的东西,以真正复制 Xcode 的行为,甚至超越它:
- 像 Xcode 一样画一条漂亮的连接线(有阴影、轮廓和大圆头),
- 跨多个屏幕画线,
- 使用Cocoa拖拽寻找拖拽目标,支持spring加载
这是我将在这个答案中解释的演示:
In this github repo,您可以找到一个 Xcode 项目,其中包含此答案中的所有代码以及 运行 演示应用程序所需的剩余粘合代码。
画一条像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 将不会让 window 跨越两个屏幕。这意味着您不能使用单个 window 来绘制跨多个显示器的连接线。如果您想让用户将一个 window 中的对象连接到另一个 window 中的对象,这很重要,就像 Xcode 所做的那样:
这是在我们的其他 windows 之上跨多个屏幕画线的清单:
- 我们需要为每个屏幕创建一个 window。
- 我们需要设置每个 window 来填满它的屏幕并且完全透明,没有阴影。
- 我们需要将每个 window 的 window 级别设置为 1,以使其高于我们的正常 windows(window 级别为 0)。
- 我们需要告诉每个 window not 在关闭时释放自己,因为我们不喜欢神秘的自动释放池崩溃。
- 每个 window 需要自己的
ConnectionView
。 - 为了保持坐标系统一,我们将调整每个
ConnectionView
的bounds
,使其坐标系与屏幕坐标系匹配。 - 我们会告诉每个
ConnectionView
绘制整条连接线;每个视图都会将其绘制的内容裁剪到自己的边界。 - 这可能不会发生,但如果屏幕排列发生变化,我们会安排通知。如果发生这种情况,我们将 add/remove/update windows 涵盖新安排。
让我们做一个class来封装所有这些细节。使用 LineOverlay
的实例,我们可以根据需要更新连接的起点和终点,并在完成后从屏幕上移除覆盖。
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 { [=13=].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: { [=13=].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 { [=13=].close() }
}
private func setViewPoints() {
for window in windows {
let view = window.contentView! as! ConnectionView
view.parameters.startPoint = startScreenPoint
view.parameters.endPoint = endScreenPoint
}
}
}
使用Cocoa拖拽找到拖拽目标并执行spring-loading
我们需要一种方法来在用户拖动鼠标时找到连接的(潜在)放置目标。支持spring加载也很好。
如果您不知道,spring加载是 macOS 的一项功能,如果您将拖动鼠标悬停在容器上片刻,macOS 会自动打开容器而不会中断拖动。示例:
- 如果您拖到不是最前面的 window 上 window,macOS 会将 window 移到最前面。
- 如果您拖到 Finder 文件夹图标上,Finder 将打开文件夹 window 让您拖到文件夹中的项目上。
- 如果您在 Safari 或 Chrome 中拖动到选项卡句柄(在 window 的顶部),浏览器将 select 选项卡,让您将项目放入选项卡。
- 如果您按住控制键将 Xcode 中的连接拖到故事板或 xib 菜单栏中的菜单项上,Xcode 将打开该项目的菜单。
如果我们使用标准 Cocoa 拖放支持来跟踪拖放并找到放置目标,那么我们将“免费”获得 spring 加载支持。
为了支持标准的Cocoa拖放,我们需要在某些对象上实现NSDraggingSource
协议,这样我们就可以从中拖放东西,并且其他对象上的 NSDraggingDestination
协议,因此我们可以将 拖到 某些东西。我们将在名为 ConnectionDragController
的 class 中实现 NSDraggingSource
,并在名为 DragEndpoint
的自定义视图 class 中实现 NSDraggingDestination
。
首先,让我们看一下DragEndpoint
(一个NSView
子class)。 NSView
已经符合 NSDraggingDestination
,但并没有做太多事情。我们需要实现 NSDraggingDestination
协议的四个方法。拖动会话将调用这些方法来让我们知道拖动何时进入和离开目的地、拖动何时完全结束以及何时“执行”拖动(假设该目的地是拖动实际结束的地方)。我们还需要注册我们可以接受的拖动数据类型。
我们要注意两件事:
- 我们只想接受作为连接尝试的拖动。我们可以通过检查源是否是我们自定义的拖动源来判断拖动是否是连接尝试,
ConnectionDragController
. - 我们将使
DragEndpoint
看起来像是拖动源(仅在视觉上,而不是以编程方式)。我们不想让用户将端点连接到自己,因此我们需要确保作为连接源的端点不能同时用作连接的目标。我们将使用state
属性 来跟踪此端点是空闲的、充当源还是充当目标。
当用户最终在有效的放置目标上释放鼠标按钮时,拖动会话使目标负责通过发送拖动来“执行”拖动 performDragOperation(_:)
。会话不会告诉拖动源最终发生放置的位置。但我们可能希望在源中完成建立连接(在我们的数据模型中)的工作。想想它在 Xcode 中是如何工作的:当您从 Main.storyboard
中的一个按钮按住 control 并拖动到 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:)
中连接端点所做的“工作”只是打印它们的描述。在真实的应用程序中,您实际上会修改数据模型。
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?
}
这就是您所需要的。提醒一下,您可以在包含完整演示项目的 github 存储库的此答案顶部找到 link。
尝试将 NSOutlineView
,我 运行 遇到了一些问题。如果它能帮助任何试图实现相同目标的人,我将在这个答案中详细说明这些陷阱。
解决方案中提供的示例代码通过在 视图控制器 上实现 mouseDown(with:)
,然后在window 的内容视图,以便获得(潜在的)拖动来源的 DragEndpoint
子视图。使用大纲视图时,这会导致两个陷阱,将在下一节中详细介绍。
1。鼠标按下事件
似乎当涉及 table 视图或大纲视图时,mouseDown(with:)
永远不会在视图控制器上调用,我们需要在 中覆盖该方法大纲视图本身。
2。命中测试
NSTableView
- 通过扩展,NSOutlineView
- 覆盖 NSResponder
方法 validateProposedFirstResponder(_:for:)
,这会导致 hittest()
方法失败:它总是returns 大纲视图本身和所有子视图(包括单元格内我们的目标 DragEndpoint
子视图)仍然无法访问。
来自 documentation:
Views or controls in a table sometimes need to respond to incoming events. To determine whether a particular subview should receive the current mouse event, a table view calls
validateProposedFirstResponder:forEvent:
in its implementation ofhitTest
. If you create a table view subclass, you can overridevalidateProposedFirstResponder:forEvent:
to specify which views can become the first responder. In this way, you receive mouse events.
起初我尝试覆盖:
override func validateProposedFirstResponder(_ responder: NSResponder, for event: NSEvent?) -> Bool {
if responder is DragEndpoint {
return true
}
return super.validateProposedFirstResponder(responder, for: event)
}
...它奏效了,但进一步阅读文档表明了一种更智能、侵入性更小的方法:
The default
NSTableView
implementation ofvalidateProposedFirstResponder:forEvent:
uses the following logic:
Return
YES
for all proposed first responder views unless they are instances or subclasses ofNSControl
.Determine whether the proposed first responder is an
NSControl
instance or subclass. If the control is anNSButton
object, returnYES
. If the control is not anNSButton
, call the control’shitTestForEvent:inRect:ofView:
to see whether the hit area is trackable (that is,NSCellHitTrackableArea
) or is an editable text area (that is,NSCellHitEditableTextArea
), and return the appropriate value. Note that if a text area is hit,NSTableView
also delays the first responder action.
(强调我的)
...这很奇怪,因为感觉应该说:
- Return
NO
for all proposed first responder views unless they are instances or subclasses ofNSControl
.
,但无论如何,我改为修改 Rob 的代码,使 DragEndpoint
成为 NSControl
的子 class(而不仅仅是 NSView
),这也有效。
3。管理拖动会话
因为 NSOutlineView
仅通过其 数据源 协议公开了有限数量的拖放事件(拖放会话本身无法从中进行有意义的修改数据源端),似乎完全控制拖动会话是不可能的,除非我们 subclass 大纲视图并覆盖 NSDraggingSource
方法。
只有在大纲视图本身覆盖 draggingSession(_:willBeginAt:)
才能防止调用 superclass 实现并开始实际的项目拖动(显示拖动的行图像)。
我们可以从 DragEndpoint
子视图的 mouseDown(with:)
方法开始一个单独的拖动会话:实现时,它在 before 上调用相同的方法大纲视图(这又是触发拖动会话开始的原因)。但是,如果我们将拖动会话从大纲视图中移开,则在可扩展项目上方拖动时,似乎不可能有 springloading "for free"。
因此,我放弃了 ConnectionDragController
class 并将其所有逻辑移动到大纲视图 subclass:tackDrag()
方法,活动 DragEndpoint
属性,以及NSDraggingSource
协议的所有方法进入大纲视图。
理想情况下,我希望避免 subclassing NSOutlineView
(不鼓励这样做),而是更干净地实现此行为,完全通过大纲视图的 delegate/data source and/or外置class是的(像原来的ConnectionDragController
),不过好像是不可能的
我还没有让 springloading 部分工作(它 是 工作,但不是现在所以我还在研究它...)。
我也做了一个示例项目,但我仍在修复小问题。一旦准备就绪,我将 post 发送 link 到 GiHub 存储库。