如何重现这个Xcode蓝色拖拽线

时间:2017-04-28 20:31:19

标签: swift cocoa core-graphics

我想在我的应用中重现Xcode蓝色拖拽线。

你知道一种编码方法吗?

Xcode blue drag line

我知道如何使用Core Graphics绘制一条线... 但是这一行必须超过所有其他项目的顶部(在屏幕上)。

3 个答案:

答案 0 :(得分:21)

我发布了你自己的答案之后发布这个,所以这可能是浪费时间。但是你的答案只包括在屏幕上绘制一个非常简单的线条,并没有涵盖一些你需要注意的真正复制Xcode行为的其他有趣的东西,甚至超越它:

  • 绘制一个很好的连接线,如Xcode&#s;(带阴影,轮廓和大圆角),
  • 在多个屏幕上绘制线条,
  • 使用Cocoa拖放查找拖动目标并支持弹簧加载。

以下是我将在此答案中解释的内容演示:

demo

In this github repo,您可以找到一个Xcode项目,其中包含此答案中的所有代码以及运行演示应用程序所需的剩余粘合代码。

绘制一个很好的连接线,如Xcode'

Xcode的连接线看起来像old-timey barbell。它有一个任意长度的直条,每端有一个圆形钟:

basic shape

我们对这种形状了解多少?用户通过拖动鼠标来提供起点和终点(钟的中心),我们的用户界面设计师指定钟的半径和条的粗细:

givens

条形的长度是从startPointendPoint的距离:length = hypot(endPoint.x - startPoint.x, endPoint.y - startPoint.y)

为了简化为此形状创建路径的过程,让我们以标准姿势绘制它,左边的钟形和原点与x轴平行。在这个姿势中,这就是我们所知道的:

givens at origin

我们可以通过使圆弧以原点为中心,连接到以(length, 0)为中心的另一个(镜像)圆弧,将此形状创建为路径。要创建这些弧,我们需要mysteryAngle

mystery angle

我们可以找出mysteryAngle,如果我们能找到钟与柱子相遇的任何弧端点。具体来说,我们将找到这一点的坐标:

mystery point

我们对mysteryPoint了解多少?我们知道它在钟和酒吧顶部的交叉处。所以我们知道距离原点距离bellRadius,距离x轴距离barThickness / 2

mystery point givens

所以我们立即知道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)

结果如下:

playground result

跨多个屏幕绘图

如果您的Mac上连接了多个屏幕(显示器),并且如果您在“系统偏好设置”的“任务控制”面板中打开了“显示器具有单独的空间”(这是默认设置),那么macOS将不会让窗口跨越两个屏幕。这意味着您无法使用单个窗口在多个监视器上绘制连接线。如果你想让用户将一个窗口中的对象连接到另一个窗口中的对象,就像Xcode那样,这很重要:

这是在我们的其他窗口之上绘制线条的清单,横跨多个屏幕:

  • 我们需要为每个屏幕创建一个窗口。
  • 我们需要设置每个窗口以填充其屏幕,并且完全透明,没有阴影。
  • 我们需要将每个窗口的窗口级别设置为1以使其高于我们的正常窗口(窗口级别为0)。
  • 我们需要告诉每个窗口在关闭时释放自己,因为我们不喜欢神秘的自动释放池崩溃。
  • 每个窗口都需要自己的ConnectionView
  • 为了保持坐标系统的统一,我们会调整每个bounds的{​​{1}},使其坐标系与屏幕坐标系匹配。
  • 我们会告诉每个ConnectionView画出整条连线;每个视图都会剪切它绘制的内容。
  • 可能不会发生,但如果屏幕排列发生变化,我们会安排收到通知。如果发生这种情况,我们将添加/删除/更新窗口以涵盖新安排。

让我们创建一个类来封装所有这些细节。使用ConnectionView的实例,我们可以根据需要更新连接的起点和终点,并在我们完成后从屏幕中删除叠加层。

LineOverlay

使用Cocoa拖放查找拖动目标并执行弹簧加载

当用户拖动鼠标时,我们需要一种方法来找到连接的(潜在)放下目标。支持弹簧加载也很不错。

如果您不知道,弹簧加载是一项macOS功能,如果您将拖动悬停在容器上片刻,macOS将自动打开容器而不会中断拖动。例子:

  • 如果您拖动到不是最前面窗口的窗口,macOS会将窗口移到前面。
  • 如果您拖到Finder文件夹图标上,Finder将打开文件夹窗口,让您拖动到文件夹中的项目。
  • 如果您在Safari或Chrome中拖动到标签句柄(位于窗口顶部),浏览器将选择该标签,让您将该项目拖放到标签中。
  • 如果你控制 - 将Xcode中的连接拖到storyboard或xib菜单栏中的菜单项上,Xcode将打开该项目的菜单。

如果我们使用标准的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

首先,让我们看看DragEndpointDragEndpoint子类)。 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:

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)
}

答案 2 :(得分:3)

尝试将Rob Mayoff's excellent solution above引入我自己的项目界面,该界面基于NSOutlineView,我遇到了一些问题。如果它有助于任何人试图达到同样的目的,我将在这个答案中详述这些陷阱。

解决方案中提供的示例代码通过在视图控制器上实施mouseDown(with:),然后在窗口上调用hittest()来检测拖动的开始内容视图,以获取(潜在)拖动所源自的DragEndpoint子视图。使用大纲视图时,这会导致下一节中详述的两个陷阱。

1。鼠标按下事件

当涉及表格视图或大纲视图时,似乎永远不会在视图控制器上调用mouseDown(with:),我们需要在大纲视图本身中覆盖该方法。

2。命中测试

NSTableView - 以及NSOutlineView - 覆盖NSResponder方法validateProposedFirstResponder(_:for:),这会导致hittest()方法失败:它始终返回大纲视图本身,以及所有子视图(包括单元格内的目标DragEndpoint子视图)仍然无法访问。

来自documentation

  

表格中的视图或控件有时需要响应传入   事件。确定特定子视图是否应该接收   当前鼠标事件,表视图调用   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:使用以下逻辑:

     
      
  1. 为所有提议的第一响应者视图返回YES,除非它们是   NSControl的实例或子类。

  2.   
  3. 确定是否建议   第一响应者是NSControl实例或子类。如果控制   是一个NSButton对象,返回YES。如果控件不是NSButton,   调用控件的hitTestForEvent:inRect:ofView:来查看是否   命中区域是可追踪的(即NSCellHitTrackableArea)或是   可编辑的文本区域(即NSCellHitEditableTextArea),然后返回   适当的价值。请注意,如果点击了文字区域,NSTableView   也推迟了第一响应者的行动。

  4.   

(强调我的)

......这很奇怪,因为它应该说:

  
      
  1. 为所有提议的第一响应者视图返回 NO ,除非它们是   NSControl
  2. 的实例或子类   

,但无论如何,我修改了Rob的代码,使DragEndpoint成为NSControl的子类(不仅仅是NSView),而且也有效。

3。管理拖动会话

因为NSOutlineView仅通过其数据源协议公开了有限数量的拖放事件(并且拖动会话本身无法从数据源进行有意义的修改&#39 ; s侧),似乎除非我们继承大纲视图并覆盖NSDraggingSource方法,否则无法完全控制拖动会话。 只有在大纲视图本身覆盖draggingSession(_:willBeginAt:),才能阻止调用超类实现并启动实际项目拖动(显示拖动的行图像)。

我们可以从mouseDown(with:)子视图的DragEndpoint方法开始一个单独的拖动会话:实现时,在大纲视图上的相同方法之前称为反过来是触发拖动会话启动的原因。但是如果我们将拖动会话从大纲视图中移开,那么似乎不可能免费 springloading ""在可展开项目上方拖动时。

相反,我放弃了ConnectionDragController类并将其所有逻辑移动到大纲视图子类:tackDrag()方法,活动DragEndpoint属性以及{{{{}}的所有方法1}}协议进入大纲视图。

理想情况下,我本来希望避免继承NSDraggingSource(不鼓励),而是通过大纲视图的委托/数据源和/或外部类(如原来的NSOutlineView),但似乎不可能。

我还没有让弹簧加载部件工作( 正在工作,但现在不行,所以我还在调查......)。 / p>

我也做了一个示例项目,但我仍然在解决小问题。我会在准备好后立即发布一个指向GiHub存储库的链接。