Как воспроизвести эту синюю линию перетаскивания Xcode
3 ответов
я публикую это после того, как вы разместили свой собственный ответ, так что это, вероятно, огромная трата времени. Но ваш ответ охватывает только рисование действительно голой линии на экране и не охватывает кучу других интересных вещей, о которых вам нужно позаботиться, чтобы действительно воспроизвести поведение Xcode и даже выйти за его пределы:
- рисование хорошей линии соединения, такой как Xcode (с тенью, контуром и большими закругленными концами),
- чертеж линии по несколько экранов,
- использование перетаскивания какао, чтобы найти цель перетаскивания и поддержать пружинную загрузку.
вот демонстрация того, что я собираюсь объяснить в этом ответе:
в этом РЕПО github, вы можете найти проект Xcode, содержащий весь код в этом ответе плюс оставшийся код клея, необходимый для запуска демонстрационного приложения.
рисование хорошей линии соединения, как В Xcode
линия соединения Xcode выглядит как старый-Тимей штангой. Он имеет прямой стержень произвольной длины с круглым колоколом на каждом конце:
что мы знаем об этой форме? Пользователь предоставляет начальную и конечную точки (центры колоколов) путем перетаскивания мыши, а наш конструктор пользовательского интерфейса определяет радиус колоколов и толщину бар:
длина бара-это расстояние от startPoint
to 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
через наш выбор обратной функции тригонометрии. Арксин, я выбираю тебя! mysteryAngle = asin(mysteryPoint.y / bellRadius)
.
теперь мы знаем все, что нам нужно создать путь в стандартной позе. Чтобы переместить его из стандартной позы в нужную позу (которая идет от startPoint
to 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)!
}
}
как только у нас есть путь, нам нужно заполнить его правильным цветом, обвести его правильным цветом и шириной линии и нарисовать тень вокруг него. Я использовал Хоппер Disassembler на IDEInterfaceBuilderKit
чтобы выяснить точные размеры и цвета 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)
вот результат:
рисование на нескольких экранах
если у вас есть несколько экранов (дисплеев), подключенных к вашему Mac, и если у вас есть" дисплеи с отдельными пробелами", включенные (по умолчанию) в панели управления полетами ваших системных настроек, то macOS не позволит окно охватывает два экрана. Это означает, что вы не можете использовать одно окно для рисования соединительной линии на нескольких мониторах. Это имеет значение, если вы хотите, чтобы пользователь подключил объект в одном окне к объекту в другом окне, как это делает Xcode:
вот контрольный список для рисования линии, на нескольких экранах, поверх других окон:
- нам нужно создать одно окно на экран.
- нам нужно настроить каждый окно, чтобы заполнить его экран и быть полностью прозрачным без тени.
- нам нужно установить уровень окна каждого окна в 1, чтобы держать его выше наших обычных окон (которые имеют уровень окна 0).
- мы должны сказать каждому окну не чтобы освободить себя при закрытии, потому что нам не нравятся загадочные сбои пула autorelease.
- каждое окно нуждается в своем
ConnectionView
. - для того чтобы держать системы координат равномерными, мы отрегулируем the
bounds
каждогоConnectionView
чтобы его система координат соответствовала системе координат экрана. - мы скажем каждому
ConnectionView
чтобы нарисовать всю соединительную линию; каждый вид будет обрезать то, что он рисует, до своих собственных границ. - этого, вероятно, не произойдет, но мы организуем уведомление, если расположение экрана изменится. Если это произойдет, мы добавим/удалим / обновим windows, чтобы покрыть новое расположение.
давайте сделаем класс, чтобы инкапсулировать все эти подробности. С экземпляром 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 { .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: { .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 { .close() }
}
private func setViewPoints() {
for window in windows {
let view = window.contentView! as! ConnectionView
view.parameters.startPoint = startScreenPoint
view.parameters.endPoint = endScreenPoint
}
}
}
использование какао перетаскивания, чтобы найти цель перетаскивания и выполнить весеннюю загрузку
нам нужен способ найти (потенциальную) цель падения соединения, когда пользователь перетаскивает мышь. Было бы также неплохо поддержать весеннюю загрузку.
в случае, если вы не знаете, весна загрузка-это функция macOS, в которой, если на мгновение навести курсор мыши на контейнер, macOS автоматически откроет контейнер, не прерывая перетаскивания. Примеры:
- если вы перетащите на окно, которое не является самым передним окном, macOS приведет окно к передней части.
- если вы перетащите на значок папки Finder, и Finder откроет окно папки, чтобы позволить вам перетащить на элемент в папке.
- если перетащить на ручку вкладки (на в верхней части окна) в Safari или Chrome браузер выберет вкладку, позволяя вам удалить свой элемент на вкладке.
- если вы управляете-перетащите соединение в Xcode на пункт меню в строке меню раскадровки или xib, Xcode откроет меню элемента.
если мы используем стандартную поддержку перетаскивания какао для отслеживания перетаскивания и поиска цели падения, то мы получим поддержку весенней загрузки "бесплатно".
для поддержки стандартного сопротивления какао и drop, нам нужно реализовать NSDraggingSource
протокол на каком-то объекте, поэтому мы можем перетащить с что-то, а NSDraggingDestination
протокол на каком-то другом объекте, поэтому мы можем перетащить to что-то. Мы реализуем NSDraggingSource
в класс ConnectionDragController
, а мы реализуем NSDraggingDestination
в пользовательском классе представления под названием DragEndpoint
.
во-первых, давайте посмотрим на DragEndpoint
(an NSView
подкласс). NSView
уже соответствует NSDraggingDestination
, но не делает много с ним. Нам нужно реализовать четыре метода NSDraggingDestination
протокол. Сеанс перетаскивания вызовет эти методы, чтобы сообщить нам, когда перетаскивание входит и выходит из места назначения, когда перетаскивание заканчивается полностью, и когда "выполнить" перетаскивание (предполагая, что это место назначения было там, где перетаскивание фактически закончилось). Нам также необходимо зарегистрировать тип перетаскиваемых данных, которые мы можем принять.
мы хотим быть осторожными в двух вещах:
- мы только хотим принять перетаскивание, которое является попыткой подключения. Мы можно выяснить, является ли перетаскивание попыткой подключения, проверив, является ли источник нашим пользовательским источником перетаскивания,
ConnectionDragController
. - мы
DragEndpoint
кажется источником перетаскивания (только визуально, а не программно). Мы не хотим, чтобы пользователь подключал конечную точку к себе, поэтому нам нужно убедиться, что конечная точка, являющаяся источником соединения, также не может использоваться в качестве цели соединения. Мы сделаем это с помощьюstate
свойство, которое отслеживает, является ли эта конечная точка бездействует, действует как источник или как цель.
когда пользователь, наконец, отпускает кнопку мыши над допустимым местом назначения перетаскивания, сеанс перетаскивания делает его ответственность назначения "выполнить" перетаскивание, отправив его performDragOperation(_:)
. Сеанс не сообщает источнику перетаскивания, где произошло падение. Но мы, вероятно, хотим сделать работу по установлению связи (в нашей модели данных) обратно в источник. Подумайте о том, как это работает в Xcode: когда вы контроль-перетащите от кнопки в Main.storyboard
to 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:)
просто печатает их описания. В реальном приложении вы бы фактически изменили свою модель данных.
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, содержащий полный демо-проект.
использование прозрачного окна NSWindow:
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
}
window.contentView!.layer = line
window.contentView!.wantsLayer = true
window.setFrame(NSScreen.main()!.frame, display: true)
window.makeKeyAndOrderFront(nil)
}
пытается принять отличное решение Роба Майоффа выше в интерфейс моего собственного проекта, который основан на NSOutlineView
, я столкнулся с несколькими проблемами. Если это поможет кому-то достичь того же, я подробно расскажу об этих ловушках в этом ответе.
пример кода, предоставленный в решении, обнаруживает начало перетаскивания путем реализации mouseDown(with:)
на посмотреть контроллер, а потом называет hittest()
просмотр содержимого окна в порядок чтобы получить DragEndpoint
subview, где происходит (потенциальное) перетаскивание. При использовании представлений контуров это приводит к двум подводным камням, подробно описанным в следующих разделах.
1. Mouse-Down Event
кажется, что когда представление таблицы или представление контура задействовано, mouseDown(with:)
никогда не вызывается на контроллере представления, и нам нужно вместо этого переопределить этот метод в структуры.
2. Хит Тестирования
NSTableView
-и, NSOutlineView
- переопределяет NSResponder
метод validateProposedFirstResponder(_:for:)
и в результате hittest()
метод для сбоя: он всегда возвращает сам вид контура и все подвиды (включая нашу цель DragEndpoint
subview внутри ячейки) остаются недоступными.
С документация:
вид и элементы управления в таблице иногда нужно отвечать на входящие события. Чтобы определить, должен ли конкретный subview получать текущее событие мыши, представление таблицы звонки
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:
использует следующая логика:
возвращение
YES
для всех предложенных представлений первого ответчика, если они не являются экземпляры или подклассыNSControl
.определите, предлагается ли первым ответчиком является
NSControl
экземпляр или подкласс. Если контроль это