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

San*_*aus 8 cocoa core-graphics swift

我想在我的应用程序中重现Xcode蓝色拖动线.

你知道一种编码方式吗?

Xcode蓝色拖线

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

rob*_*off 24

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

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

这是我将在这个答案中解释的一个演示:

演示

在这个github仓库中,您可以找到一个Xcode项目,其中包含此答案中的所有代码以及运行演示应用程序所需的剩余胶水代码.

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

Xcode的连接线看起来像是一个老式的杠铃.它有一个任意长度的直条,每端有一个圆形钟:

基本形状

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

吉文斯

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

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

原产地

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

神秘的角度

我们可以弄清楚我们mysteryAngle是否可以找到钟与杆相交的任何弧端点.具体来说,我们将找到这一点的坐标:

神秘点

我们对此了解mysteryPoint多少?我们知道它位于钟和酒吧顶部的交叉处.所以我们知道距离bellRadius原点的距离是barThickness / 2远离x轴的距离:

神秘点的恩惠

所以我们立即知道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)!
    }
}
Run Code Online (Sandbox Code Playgroud)

一旦我们有了路径,我们需要用正确的颜色填充它,用正确的颜色和线宽划动它,并在它周围画一个阴影.我使用Hopper Disassembler IDEInterfaceBuilderKit来计算出Xcode的确切尺寸和颜色.Xcode将它全部绘制到自定义视图中的图形上下文中drawRect:,但我们将使我们的自定义视图使用a 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
    }
}
Run Code Online (Sandbox Code Playgroud)

我们可以在操场上测试它,以确保它看起来不错:

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)
Run Code Online (Sandbox Code Playgroud)

这是结果:

游乐场结果

绘制多个屏幕

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

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

  • 我们需要为每个屏幕创建一个窗口.
  • 我们需要设置每个窗口以填充其屏幕,并且完全透明,没有阴影.
  • We need to set the window level of each window to 1 to keep it above our normal windows (which have a window level of 0).
  • We need to tell each window not to release itself when closed, because we don't like mysterious autorelease pool crashes.
  • Each window needs its own ConnectionView.
  • To keep the coordinate systems uniform, we'll adjust the bounds of each ConnectionView so that its coordinate system matches the screen coordinate system.
  • We'll tell each ConnectionView to draw the entire connecting line; each view will clip what it draws to its own bounds.
  • It probably won't happen, but we'll arrange to be notified if the screen arrangement changes. If that happens, we'll add/remove/update windows to cover the new arrangement.

Let's make a class to encapsulate all these details. With an instance of LineOverlay, we can update the start and end points of the connection as needed, and remove the overlay from the screen when we're 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'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
        }
    }

}
Run Code Online (Sandbox Code Playgroud)

Using Cocoa drag and drop to find the drag target and perform spring-loading

We need a way to find the (potential) drop target of the connection as the user drags the mouse around. It would also be nice to support spring loading.

In case you don't know, spring loading is a macOS feature in which, if you hover a drag over a container for a moment, macOS will automatically open the container without interrupting the drag. Examples:

  • If you drag onto a window that's not the frontmost window, macOS will bring the window to the front.
  • if you drag onto a Finder folder icon, and the Finder will open the folder window to let you drag onto an item in the folder.
  • If you drag onto a tab handle (at the top of the window) in Safari or Chrome, the browser will select the tab, letting you drop your item in the tab.
  • If you control-drag a connection in Xcode onto a menu item in the menu bar in your storyboard or xib, Xcode will open the item's menu.

If we use the standard Cocoa drag and drop support to track the drag and find the drop target, then we'll get spring loading support "for free".

To support standard Cocoa drag and drop, we need to implement the NSDraggingSource protocol on some object, so we can drag from something, and the NSDraggingDestination protocol on some other object, so we can drag to something. We'll implement NSDraggingSource in a class called ConnectionDragController, and we'll implement NSDraggingDestination in a custom view class called DragEndpoint.

First, let's look at DragEndpoint (an NSView subclass). NSView already conforms to NSDraggingDestination, but doesn't do much with it. We need to implement four methods of the NSDraggingDestination protocol. The drag session will call these methods to let us know when the drag enters and leaves the destination, when the drag ends entirely, and when to "perform" the drag (assuming this destination was where the drag actually ended). We also need to register the type of dragged data that we can accept.

We want to be careful of two things:

  • We only want to accept a drag that is a connection attempt. We can figure out whether a drag is a connection attempt by checking whether the source is our custom drag source, ConnectionDragController.
  • We'll make DragEndpoint appear to be the drag source (visually only, not programmatically). We don't want to let the user connect an endpoint to itself, so we need to make sure the endpoint that is the source of the connection cannot also be used as the target of the connection. We'll do that using a state property that tracks whether this endpoint is idle, acting as the source, or acting as the target.

When the user finally releases the mouse button over a valid drop destination, the drag session makes it the destination's responsibility to "perform" the drag by sending it performDragOperation(_:). The session doesn't tell the drag source where the drop finally happened. But we probably want to do the work of making the connection (in our data model) back in the source. Think about how it works in Xcode: when you control-drag from a button in Main.storyboard to ViewController.swift and create an action, the connection is not recorded in ViewController.swift where the drag ended; it's recorded in Main.storyboard, as part of the button's persistent data. So when the drag session tells the destination to "perform" the drag, we'll make our destination (DragEndpoint) pass itself back to a connect(to:) method on 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.
}
Run Code Online (Sandbox Code Playgroud)

Now we can implement ConnectionDragController to act as the drag source and to manage the drag session and the LineOverlay.

  • To start a drag session, we have to call beginDraggingSession(with:event:source:) on a view; it'll be the DragEndpoint where the mouse-down event happened.
  • The session notifies the source when the drag actually starts, when it moves, and when it ends. We use those notifications to create and update the LineOverlay.
  • Since we're not providing any images as part of our NSDraggingItem, the session won't draw anything being dragged. This is good.
  • 默认情况下,如果拖动在有效目标之外结束,则会在通知源拖动已结束之前,会话将动画...无...返回到拖动的开始.在此动画期间,线条覆盖图会挂起,冻结.它看起来很破碎.我们告诉会话不要动画回到开头以避免这种情况.

由于这只是一个演示,我们用来连接端点的"工作" 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?

}
Run Code Online (Sandbox Code Playgroud)

这就是你所需要的.提醒一下,您可以在此答案的顶部找到包含完整演示项目的github仓库的链接.

  • 这样一个惊人而鼓舞人心的答案.非常感谢 ! (4认同)