>绘制一个很好的连接线,如Xcode(带阴影,轮廓和大圆角),
>在多个屏幕上画线,
>使用Cocoa拖放查找拖动目标并支持弹簧加载.
这是我将在这个答案中解释的一个演示:
In this github repo,您可以找到一个Xcode项目,其中包含此答案中的所有代码以及运行演示应用程序所需的剩余粘合代码.
绘制一个很好的连接线,如Xcode的
Xcode的连接线看起来像old-timey barbell.它有一个任意长度的直条,每端有一个圆形钟:
我们对这种形状了解多少?用户通过拖动鼠标来提供起点和终点(钟的中心),我们的用户界面设计器指定钟的半径和条的粗细:
条形的长度是从startPoint到endPoint的距离:length = hypot(endPoint.x – startPoint.x,endPoint.y – startPoint.y).
为了简化为这个形状创建路径的过程,让我们以标准姿势绘制它,左边的钟形和原点与x轴平行.在这个姿势中,这就是我们所知道的:
我们可以通过使圆弧以原点为中心,连接到以(长度,0)为中心的另一个(镜像)圆弧,将此形状创建为路径.要创建这些弧,我们需要这个神秘的角色:
如果我们能找到钟与柱子相遇的任何弧端点,我们就可以找出神秘的角度.具体来说,我们将找到这一点的坐标:
我们对mysteryPoint了解多少?我们知道它位于钟和酒吧顶部的交叉处.所以我们知道距离原点的距离为bellRadius,距离xT轴的距离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来优雅地处理它.
>如果长度为零怎么办?我们需要避免被零除.
这是我创建路径的代码,处理所有这些情况:
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),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,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那样:
以下是在多个屏幕上绘制线条的清单,位于其他窗口的顶部:
>我们需要为每个屏幕创建一个窗口.
>我们需要设置每个窗口以填充其屏幕,并且完全透明,没有阴影.
>我们需要将每个窗口的窗口级别设置为1以使其高于我们的正常窗口(窗口级别为0).
>我们需要告诉每个窗口在关闭时不要释放自己,因为我们不喜欢神秘的自动释放池崩溃.
>每个窗口都需要自己的ConnectionView.
>为了保持坐标系统的一致性,我们将调整每个ConnectionView的边界,使其坐标系与屏幕坐标系匹配.
>我们将告诉每个ConnectionView绘制整个连接线;每个视图都会剪切它绘制到自己边界的内容.
>可能不会发生,但如果屏幕布置发生变化,我们会安排通知.如果发生这种情况,我们将添加/删除/更新窗口以涵盖新安排.
让我们创建一个类来封装所有这些细节.使用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 { $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 } } }
使用Cocoa拖放查找拖动目标并执行弹簧加载
当用户拖动鼠标时,我们需要一种方法来找到连接的(潜在)放下目标.支持弹簧加载也很不错.
如果您不知道,弹簧加载是一个macOS功能,如果您将拖动悬停在容器上片刻,macOS将自动打开容器而不会中断拖动.例子:
>如果拖动到不是最前面窗口的窗口,macOS会将窗口移到前面.
>如果您拖动到Finder文件夹图标,Finder将打开文件夹窗口以允许您拖动到文件夹中的项目.
>如果您在Safari或Chrome中拖动选项卡句柄(位于窗口顶部),浏览器将选择该选项卡,让您将项目拖放到选项卡中.
>如果您控制 – 将Xcode中的连接拖到storyboard或xib菜单栏中的菜单项上,Xcode将打开该项目的菜单.
如果我们使用标准的Cocoa拖放支撑来跟踪阻力并找到掉落目标,那么我们将“免费”获得弹簧加载支持.
为了支持标准的Cocoa拖放,我们需要在某个对象上实现NSDraggingSource协议,因此我们可以从某些东西中拖出来,并将NSDraggingDestination协议拖到其他对象上,这样我们就可以拖拽到某个东西了.我们将在名为ConnectionDragController的类中实现NSDraggingSource,并且我们将在名为DragEndpoint的自定义视图类中实现NSDraggingDestination.
首先,让我们看一下DragEndpoint(一个NSView子类). NSView已经符合NSDraggingDestination,但对它没有太大作用.我们需要实现NSDraggingDestination协议的四种方法.拖动会话将调用这些方法让我们知道拖动进入和离开目的地的时间,拖动完全结束时,以及何时“执行”拖动(假设此目标是拖动实际结束的位置).我们还需要注册我们可以接受的拖动数据类型.
我们要小心两件事:
>我们只想接受一次连接尝试的拖动.我们可以通过检查源是否是我们的自定义拖动源ConnectionDragController来确定拖动是否是连接尝试.
>我们将DragEndpoint看作是拖动源(仅在视觉上,而不是以编程方式).我们不希望让用户将端点连接到自身,因此我们需要确保作为连接源的端点也不能用作连接的目标.我们将使用状态属性来执行此操作,该属性可跟踪此端点是空闲的,充当源还是充当目标.
当用户最终在有效的放置目标上释放鼠标按钮时,拖动会话使目标负责通过发送performDragOperation(_ :)来“执行”拖动.会话不会告诉拖动源最终发生了什么.但我们可能希望在源代码中完成连接(在我们的数据模型中)的工作.想想它在Xcode中是如何工作的:当你控制 – 从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的一部分,因此会话不会绘制任何被拖动的内容.这很好.
>默认情况下,如果拖动在有效目标之外结束,则在通知源拖动已结束之前,会话将动画…无…返回到拖动的开始.在此动画期间,线条覆盖会挂起,冻结.它看起来很破碎.我们告诉会话不要动画回到开头以避免这种情况.
由于这只是一个演示,我们在连接(到:)中连接端点的“工作”只是打印它们的描述.在真实的应用程序中,您实际上是在修改数据模型.
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仓库的链接.