如何重现此Xcode蓝色拖动线 [英] How to reproduce this Xcode blue drag line

查看:124
本文介绍了如何重现此Xcode蓝色拖动线的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

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



您知道一种对此进行编码的方法吗?





我知道如何使用Core Graphics绘制线...
但是此行必须位于所有其他项的顶部(在屏幕上)。

解决方案

我在此之后发布您已经发布了自己的答案,因此这可能会浪费大量时间。但是您的答案仅涉及在屏幕上绘制一条真正的准绳,而没有涉及一堆其他有趣的东西,您需要照顾这些东西才能真正复制Xcode的行为,甚至超越它:




  • 绘制一条类似Xcode的漂亮连接线(带有阴影,轮廓和大的圆形末端),

  • 绘制

  • 使用可可拖放来找到拖动目标并支持弹簧加载。



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







我们对该形状了解什么?用户通过拖动鼠标来提供起点和终点(钟形的中心),我们的用户界面设计人员可以指定钟形的半径和条形的厚度:





该条的长度是从 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 为零怎么办?我们需要避免被零除。




这是我的代码来创建路径,处理所有这些情况:

 扩展名CGPath {
class func barbell(从开始:CGPoint,到结束:CGPoint,barThickness建议BarThickness:CGFloat,bellRadius建议BellRadius :CGFloat)-> CGPath {
let barThickness = max(0,proposalBarThickness)
let bellRadius = max(barThickness / 2,proposalBellRadius)

let vector = CGPoint(x:end.x-开始.x,y:end.y-start.y)
let length = hypot(vector.x,vector.y)

如果length == 0 {
返回CGPath (ellipseIn:CGRect(来源:开始,大小:.zero).insetBy(dx:-bellRadius,dy:-bellRadius),转换:nil)
}

var yOffset = barThickness / 2
var xOffset = sqrt(bellRadius * bellRadius-yOffset * yOffset)
let halfLength = length / 2
如果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,顺时针:false)
path.addArc(center:CGPoint(x :长度,y:0),半径:bellRadius,startAngle:.pi + jointRadians,endAngle:.pi-jointRadians,顺时针:false)
path.closeSubpath()

let unitVector = CGPoint(x:vector.x /长度,y:vector.y /长度)
var transform = CGAffineTransform(a:unitVector.x,b:unitVector.y,c:-unitVector.y,d:unitVector。 x,tx:start.x,ty:start.y)
返回path.copy(使用:& transform)!
}
}

一旦有了路径,我们需要填写使用正确的颜色,使用正确的颜色和线宽对其进行描边,并在其周围绘制阴影。我在 IDEInterfaceBuilderKit 上使用了Hopper Disassembler来弄清楚Xcode的确切大小和颜色。 Xcode将所有内容绘制到自定义视图的 drawRect:的图形上下文中,但是我们将使自定义视图使用 CAShapeLayer 。我们最终不会像Xcode一样精确地绘制阴影 ,但它已经足够接近了。

 类ConnectionView:NSView {
struct参数{
var startPoint = CGPoint.zero
var endPoint = CGPoint.zero
var barThickness = CGFloat(2)
var ballRadius = CGFloat(3)
}

var参数= Parameters(){didSet {needsLayout = true}}

覆盖init(frame:CGRect){
super.init(frame:frame)
commonInit()
}

是否需要init?(编码解码器:NSCoder){
super.init(coder:解码器)
commonInit()
}

让shapeLayer = CAShapeLayer()
覆盖func makeBackingLayer()-> CALayer {return shapeLayer}

覆盖func layout(){
super.layout()

shapeLayer.path = CGPath.barbell(来自:parameters.startPoint,收件人:parameters.endpoint,barThickness:parameters.barThickness,bellRadius:parameters.ballRadius) lineWidth / 2,bellRadius:parameters.ballRadius + shapeLayer.lineWidth / 2)
}

私有函数commonInit(){
wantLayer = true

shapeLayer.lineJoin = kCALineJoinMiter
shapeLayer.lineWidth = 0.75
shapeLayer.strokeColor = NSColor.white.cgColor
shapeLayer.fillColor = NSColor(calibratedHue:209/360,饱和度:0.83,亮度: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 =为我查看

:CGFloat大步前进(从:0,通过:9,通过: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)
}

让connectionView = ConnectionView(frame:view.bounds)
connectionView.parame ters.startPoint = CGPoint(x:50,y:100)
connectionView.parameters.endPoint = CGPoint(x:350,y:150)
view.addSubview(connectionView)

结果如下:





跨多个屏幕绘制



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





以下是在其他窗口上方跨多个屏幕绘制线条的清单:




  • 我们需要每个屏幕创建一个窗口。

  • 我们需要设置每个窗口以填充其屏幕并完全透明且没有阴影。

  • 我们需要将每个窗口的窗口级别设置为1,以使其保持在正常窗口(窗口级别为0)之上。

  • 我们需要告诉每个窗口在关闭时释放自身,因为我们不喜欢神秘的自动释放池崩溃。

  • 每个窗口都需要自己的 ConnectionView

  • 要保持坐标系统一,我们将调整每个 ConnectionView 边界 c>使其坐标系与屏幕坐标系匹配。

  • 我们将告诉每个 ConnectionView 绘制整条连接线;每个视图都会将其绘制的内容剪裁到自己的边界。
  • 它可能不会发生,但是如果屏幕布置发生变化,我们会通知您。如果发生这种情况,我们将添加/删除/更新窗口以涵盖新的安排。



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

  class LineOverlay {

init(startScreenPoint:CGPoint,endScreenPoint:CGPoint){
self.startScreenPoint = startScreenPoint
self.endScreenPoint = endScreenPoint

NotificationCenter.default.addObserver(self,选择器:#selector(LineOverlay.screenLayoutDidChange(_ :)),名称:.NSApplicationDidChangeScreenParameters,对象:nil)
syncnizeWindowsToScreens()
}

var startScreenPoint:CGPoint {didSet {setSetPoints()}}}

var endScreenPoint:CGPoint {didSet {setViewPoints()} }

func removeFromScreen(){
windows.forEach {$ 0.close()}
windows.removeAll()
}

私有变量窗口= [NSWindow]()

deinit {
N otificationCenter.default.removeObserver(self)
removeFromScreen()
}

@objc private func screenLayoutDidChange(_ note:Notification){
syncnizeWindowsToScreens()
}

私人函数syncnizeWindowsToScreens(){
var spareWindows = windows
windows.removeAll()
for 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)

//为简化起见,使视图的几何形状与屏幕的几何形状匹配。
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 =假
window.hasShadow =假
window.isOneShot =真
window.level = 1

window.contentView?.needsLayout = true
window.orderFront(nil)
}

spareWindows.forEach {$ 0.close()}
}

私有函数setViewPoints(){
用于窗口中的窗口{
let view = window.contentView!如! ConnectionView
view.parameters.startPoint = startScreenPoint
view.parameters.endPoint = endScreenPoint
}
}

}



使用可可拖放来找到拖动目标并执行弹簧加载



我们需要一种在用户拖动鼠标时找到连接的(潜在)放置目标的方法。



如果您不知道,则春季加载是macOS的一项功能,如果您将拖曳悬停在容器上,有一会儿,macOS将自动打开容器,而不会中断拖动。示例:




  • 如果拖动到不是最前面的窗口的窗口,macOS会将窗口置于最前面。

  • 如果拖动到Finder文件夹图标上,Finder会打开文件夹窗口,让您拖动到文件夹中的某个项目上。

  • 如果拖动到Safari或Chrome浏览器中的标签句柄(位于窗口顶部),浏览器将选择该标签,让您将项目拖放到该标签中。

  • 如果您将Xcode中的连接拖放到情节提要或xib的菜单栏中,则Xcode将打开该项目的菜单。



如果我们使用标准的可可拖放支持来跟踪拖动并找到放置目标,那么我们将免费获得弹簧加载支持。



要支持标准可可拖放,我们需要在某些对象上实现 NSDraggingSource 协议,因此我们可以拖动来自,然后在其他对象上使用 NSDraggingDestination 协议,因此我们可以将拖到。我们将在名为 ConnectionDragController 的类中实现 NSDraggingSource ,并实现 NSDraggingDestination 在名为 DragEndpoint 的自定义视图类中。



首先,让我们看一下 DragEndpoint (一个 NSView 子类)。 NSView 已经符合 NSDraggingDestination ,但并没有做很多事情。我们需要实现 NSDraggingDestination 协议的四种方法。拖动会话将调用这些方法,以使我们知道拖动何时进入和离开目标,何时完全结束拖动以及何时执行拖动(假设此目标是拖动实际结束的位置)。我们还需要注册我们可以接受的拖动数据的类型。



我们要注意两点:




  • 我们只想接受作为连接尝试的拖动。我们可以通过检查源是否是我们的自定义拖动源 ConnectionDragController

  • 我们来确定拖动是否是连接尝试。将使 DragEndpoint 似乎是拖动源(仅在视觉上,而不是在程序上)。我们不想让用户将端点连接到自身,因此我们需要确保作为连接源的端点也不能用作连接目标。我们将使用 state 属性来执行此操作,该属性可跟踪此端点是空闲的,用作源还是用作目标。



当用户最终将鼠标按钮释放到有效放置目标上时,拖动会话将其发送给 performDragOperation(_:)。会话并没有告诉拖动源放置的最终位置。但是我们可能想要做将连接(在我们的数据模型中)返回源中的工作。考虑一下它在Xcode中的工作方式:当您从 Main.storyboard 中的按钮控制拖动到 ViewController.swift 并创建一个动作,该连接不会记录在拖动结束的 ViewController.swift 中;它记录在 Main.storyboard 中,作为按钮的持久数据的一部分。因此,当拖动会话告诉目标执行拖动时,我们将目标( DragEndpoint )传递回 connect(

  class DragEndpoint:NSView {

枚举State {
闲置
案例来源
案例目标
}

var state:State = State。闲置{didSet {needsLayout = true}}

公共优先功能fung draggingEntered(_ sender:NSDraggingInfo)-> NSDragOperation {
保护案例.idle =状态else {return []}
保护(sender.draggingSource()as?ConnectionDragController)?. sourceEndpoint!= nil else {return []}
状态= .target
return sender.draggingSourceOperationMask()
}

公共优先功能fung draggingExited(_ sender:NSDraggingInfo?){
保护案例.target =状态{返回}
状态= .idle
}

公共优先功能fung draggingEnded(_ sender:NSDraggingInfo?){
防护情况.target =状态else {return}
state = .idle
}

公共优先功能func performDragOperation(_ sender:NSDraggingInfo)->布尔{{
保护让控制器= sender.draggingSource()为? ConnectionDragController else {return false}
controller.connect(to:self)
返回true
}

覆盖init(frame:NSRect){
super .init(frame:frame)
commonInit()
}

是否需要init?(编码器解码器:NSCoder){
super.init(编码器:解码器)
commonInit()
}

私有函数commonInit(){
wantLayer = true
register(forDraggedTypes:[kUTTypeData as String])
}

//此处省略绘图代码,但在我的github存储库中。
}

现在我们可以实现 ConnectionDragController 作为拖动源并管理拖动会话和 LineOverlay




  • 要开始拖动会话,我们必须在视图上调用 beginDraggingSession(with:event:source:);鼠标按下事件发生的地方是 DragEndpoint

  • 会话在拖动实际开始时,何时何时通知源它移动,直到结束。我们使用这些通知来创建和更新 LineOverlay

  • 由于我们没有提供任何图像作为<$ c的一部分$ c> 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(_会话:NSDraggingSession,sourceOperationMaskFor上下文:NSDraggingContext)-> NSDragOperation {
切换上下文{
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,moveToscreenPoint:NSPoint){
lineOverlay?.endScreenPoint = screenPoint
}

func draggingSession(_ session:NSDraggingSession,endAt screenPoint :NSPoint,操作:NSDragOperation){
lineOverlay?.removeFromScreen()
sourceEndpoint?.state = .idle
}

func ignoreModifierKeys(for session:NSDraggingSession) ->布尔{return true}

private var lineOverlay:LineOverlay?

}

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


I'd like to reproduce the Xcode blue drag line in my app.

Do you know a way to code this ?

I know how to draw a line using Core Graphics ... But this line has to be over the top of all other items (on the screen).

解决方案

I'm posting this after you've posted your own answer, so this is probably a huge waste of time. But your answer only covers drawing a really bare-bones line on the screen and doesn't cover a bunch of other interesting stuff that you need to take care of to really replicate Xcode's behavior and even go beyond it:

  • drawing a nice connection line like Xcode's (with a shadow, an outline, and big rounded ends),
  • drawing the line across multiple screens,
  • using Cocoa drag and drop to find the drag target and to support spring-loading.

Here's a demo of what I'm going to explain in this answer:

In this github repo, you can find an Xcode project containing all the code in this answer plus the remaining glue code necessary to run a demo app.

Drawing a nice connection line like Xcode's

Xcode's connection line looks like an old-timey barbell. It has a straight bar of arbitrary length, with a circular bell at each end:

What do we know about that shape? The user provides the start and end points (the centers of the bells) by dragging the mouse, and our user interface designer specifies the radius of the bells and the thickness of the bar:

The length of the bar is the distance from startPoint to endPoint: length = hypot(endPoint.x - startPoint.x, endPoint.y - startPoint.y).

To simplify the process of creating a path for this shape, let's draw it in a standard pose, with the left bell at the origin and the bar parallel to the x axis. In this pose, here's what we know:

We can create this shape as a path by making a circular arc centered at the origin, connected to another (mirror image) circular arc centered at (length, 0). To create these arcs, we need this mysteryAngle:

We can figure out mysteryAngle if we can find any of the arc endpoints where the bell meets the bar. Specifically, we'll find the coordinates of this point:

What do we know about that mysteryPoint? We know it's at the intersection of the bell and the top of the bar. So we know it's at distance bellRadius from the origin, and at distance barThickness / 2 from the x axis:

So immediately we know that mysteryPoint.y = barThickness / 2, and we can use the Pythagorean theorem to compute mysteryPoint.x = sqrt(bellRadius² - mysteryPoint.y²).

With mysteryPoint located, we can compute mysteryAngle using our choice of inverse trigonometry function. Arcsine, I choose you! mysteryAngle = asin(mysteryPoint.y / bellRadius).

We now know everything we need to create the path in the standard pose. To move it from the standard pose to the desired pose (which goes from startPoint to endPoint, remember?), we'll apply an affine transform. The transform will translate (move) the path so the left bell is centered at startPoint and rotate the path so the right bell ends up at endPoint.

In writing the code to create the path, we want to be careful of a few things:

  • What if the length is so short that the bells overlap? We should handle that gracefully by adjusting mysteryAngle so the bells connect seamlessly with no weird "negative bar" between them.

  • What if bellRadius is smaller than barThickness / 2? We should handle that gracefully by forcing bellRadius to be at least barThickness / 2.

  • What if length is zero? We need to avoid division by zero.

Here's my code to create the path, handling all those cases:

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

Once we have the path, we need to fill it with the correct color, stroke it with the correct color and line width, and draw a shadow around it. I used Hopper Disassembler on IDEInterfaceBuilderKit to figure out Xcode's exact sizes and colors. Xcode draws it all into a graphics context in a custom view's drawRect:, but we'll make our custom view use a CAShapeLayer. We won't end up drawing the shadow precisely the same as Xcode, but it's close enough.

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

We can test this in a playground to make sure it looks good:

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)

Here's the result:

Drawing across multiple screens

If you have multiple screens (displays) attached to your Mac, and if you have "Displays have separate Spaces" turned on (which is the default) in the Mission Control panel of your System Preferences, then macOS will not let a window span two screens. This means that you can't use a single window to draw the connecting line across multiple monitors. This matters if you want to let the user connect an object in one window to an object in another window, like Xcode does:

Here's the checklist for drawing the line, across multiple screens, on top of our other windows:

  • We need to create one window per screen.
  • We need to set up each window to fill its screen and be completely transparent with no shadow.
  • 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
        }
    }

}

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

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.
  • By default, if the drag ends outside of a valid destination, the session will animate… nothing… back to the start of the drag, before notifying the source that the drag has ended. During this animation, the line overlay hangs around, frozen. It looks broken. We tell the session not to animate back to the start to avoid this.

Since this is just a demo, the "work" we do to connect the endpoints in connect(to:) is just printing their descriptions. In a real app, you'd actually modify your data model.

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?

}

That's all you need. As a reminder, you can find a link at the top of this answer to a github repo containing a complete demo project.

这篇关于如何重现此Xcode蓝色拖动线的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆