San*_*aus 8 cocoa core-graphics swift
我想在我的应用程序中重现Xcode蓝色拖动线.
你知道一种编码方式吗?
我知道如何使用Core Graphics绘制一条线......但是这条线必须超过所有其他项目的顶部(在屏幕上).
rob*_*off 24
在你发布自己的答案后我发布了这个,所以这可能是浪费时间.但是你的回答只包括在屏幕上绘制一个非常简单的线条,并没有涵盖一些你需要注意真正复制Xcode行为甚至超越它的其他有趣的东西:
这是我将在这个答案中解释的一个演示:
在这个github仓库中,您可以找到一个Xcode项目,其中包含此答案中的所有代码以及运行演示应用程序所需的剩余胶水代码.
Xcode的连接线看起来像是一个老式的杠铃.它有一个任意长度的直条,每端有一个圆形钟:
我们对这种形状了解多少?用户通过拖动鼠标来提供起点和终点(钟的中心),我们的用户界面设计器指定钟的半径和条的粗细:
棒的长度为从距离startPoint到endPoint: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那样:
这是在我们的其他窗口之上绘制线条的清单,横跨多个屏幕:
ConnectionView.bounds of each ConnectionView so that its coordinate system matches the screen coordinate system.ConnectionView to draw the entire connecting line; each view will clip what it draws to its own bounds.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)
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 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:
ConnectionDragController.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.
beginDraggingSession(with:event:source:) on a view; it'll be the DragEndpoint where the mouse-down event happened.LineOverlay.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仓库的链接.