Как воспроизвести эту синюю линию перетаскивания Xcode

Я хотел бы воспроизвести синюю линию перетаскивания Xcode в моем приложении.

вы знаете, как это закодировать ?

Xcode blue drag line

Я знаю, как нарисовать линию с помощью Core Graphics ... Но эта линия должна быть поверх всех других элементов (на экране).

3 ответов


я публикую это после того, как вы разместили свой собственный ответ, так что это, вероятно, огромная трата времени. Но ваш ответ охватывает только рисование действительно голой линии на экране и не охватывает кучу других интересных вещей, о которых вам нужно позаботиться, чтобы действительно воспроизвести поведение Xcode и даже выйти за его пределы:

  • рисование хорошей линии соединения, такой как Xcode (с тенью, контуром и большими закругленными концами),
  • чертеж линии по несколько экранов,
  • использование перетаскивания какао, чтобы найти цель перетаскивания и поддержать пружинную загрузку.

вот демонстрация того, что я собираюсь объяснить в этом ответе:

demo

в этом РЕПО github, вы можете найти проект Xcode, содержащий весь код в этом ответе плюс оставшийся код клея, необходимый для запуска демонстрационного приложения.

рисование хорошей линии соединения, как В Xcode

линия соединения Xcode выглядит как старый-Тимей штангой. Он имеет прямой стержень произвольной длины с круглым колоколом на каждом конце:

basic shape

что мы знаем об этой форме? Пользователь предоставляет начальную и конечную точки (центры колоколов) путем перетаскивания мыши, а наш конструктор пользовательского интерфейса определяет радиус колоколов и толщину бар:

givens

длина бара-это расстояние от startPoint to endPoint: length = hypot(endPoint.x - startPoint.x, endPoint.y - startPoint.y).

чтобы упростить процесс создания пути для этой фигуры, давайте нарисуем ее в стандартной позе, с левым колоколом в начале координат и полосой, параллельной оси X. В этой позе, вот что мы знаем:

givens at origin

мы можем создать эту форму как путь, сделав круговую дугу центрированная в начале координат, подключенная к другой (зеркальной) дуге окружности с центром в (length, 0). Чтобы создать эти дуги, нам нужно это mysteryAngle:

mystery angle

мы можем вычислить mysteryAngle если мы можем найти любую из конечных точек дуги, где колокол встречается с баром. В частности, мы найдем координаты этой точки:

mystery point

что мы знаем об этом mysteryPoint? Мы знаем, что это пересечение колокола и верхней части бара. Так что мы знаем, что это на расстоянии bellRadius от начала и на расстоянии barThickness / 2 от оси x:

mystery point givens

так сразу мы знаем, что 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)

вот результат:

playground result

рисование на нескольких экранах

если у вас есть несколько экранов (дисплеев), подключенных к вашему 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:

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 
    }

    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: использует следующая логика:

  1. возвращение YES для всех предложенных представлений первого ответчика, если они не являются экземпляры или подклассы NSControl.

  2. определите, предлагается ли первым ответчиком является NSControl экземпляр или подкласс. Если контроль это