Drag and Drop no macOS: Como Evitar Drop Zones Competindo Entre Si

Mário
Mário
13 de janeiro de 2026

O problema: drop zones competindo

Quando você implementa drag and drop em uma hierarquia de views no macOS, é comum criar drop targets tanto no container (view pai) quanto nos itens individuais (views filhas). O objetivo parece lógico: o container aceita drops para adicionar novos itens, e cada item aceita drops para reordenação.

O problema surge quando ambas as views registram os mesmos drag types:

// Container view
class ContainerView: NSView {
    override init(frame: NSRect) {
        super.init(frame: frame)
        registerForDraggedTypes([.myDragType])
    }
}

// Item view (filho)
class ItemView: NSView {
    override init(frame: NSRect) {
        super.init(frame: frame)
        registerForDraggedTypes([.myDragType]) // Mesmo tipo!
    }
}

O resultado: comportamento inconsistente. Às vezes o item recebe o drop, às vezes o container. Quando o mouse está no gap entre itens, nenhum dos dois responde corretamente. O usuário vê a drop indicator piscando ou sumindo em momentos inesperados.


A arquitetura correta: centralizar no parent

A solução é seguir o padrão que o próprio NSOutlineView e NSTableView da Apple usam: apenas o container é um drop target. Os itens filhos são apenas drag sources.

// Item view: apenas SOURCE, não destination
final class ItemView: NSView, NSDraggingSource {
    // Posição do item (para o container calcular drop position)
    var itemIndex: Int = 0

    // NÃO registra para drag types
    // NÃO implementa draggingEntered/Updated/Exited
    // NÃO implementa performDragOperation

    func draggingSession(
        _ session: NSDraggingSession,
        sourceOperationMaskFor context: NSDraggingContext
    ) -> NSDragOperation {
        context == .withinApplication ? .move : []
    }
}

O container fica responsável por:

  1. Receber todos os eventos de drag
  2. Descobrir qual item está sob o mouse
  3. Calcular se o drop seria acima ou abaixo desse item
  4. Mostrar o drop indicator na posição correta
  5. Executar a operação de drop

Implementação do container

Estrutura básica

final class ContainerView: NSView {
    private var itemViews: [ItemView] = []

    // Drop indicator (linha horizontal entre itens)
    private let dropLineView: NSView = {
        let view = NSView()
        view.wantsLayer = true
        view.layer?.backgroundColor = NSColor.controlAccentColor.cgColor
        view.layer?.cornerRadius = 1.5
        view.translatesAutoresizingMaskIntoConstraints = false
        view.isHidden = true
        return view
    }()

    // Constraint para posicionar a linha (atualizada dinamicamente)
    private var dropLineYConstraint: NSLayoutConstraint?

    // Info do drop pendente (para quando o drop acontece no gap)
    private var pendingDropItemIndex: Int?
    private var pendingDropPosition: DropPosition?

    override init(frame: NSRect) {
        super.init(frame: frame)
        registerForDraggedTypes([.myDragType])
        setupDropLine()
    }

    private func setupDropLine() {
        addSubview(dropLineView)

        let yConstraint = dropLineView.topAnchor.constraint(
            equalTo: topAnchor,
            constant: 0
        )
        dropLineYConstraint = yConstraint

        NSLayoutConstraint.activate([
            dropLineView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
            dropLineView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
            dropLineView.heightAnchor.constraint(equalToConstant: 3),
            yConstraint
        ])
    }
}

enum DropPosition {
    case above
    case below
}

Encontrando o item sob o mouse

O método draggingUpdated é chamado continuamente enquanto o usuário arrasta sobre a view. Aqui você encontra qual item está sob o mouse e calcula a posição do drop:

extension ContainerView {
    override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
        guard decodePayload(from: sender) != nil else {
            return []
        }

        let locationInSelf = convert(sender.draggingLocation, from: nil)

        // Encontra o item sob o mouse
        for itemView in itemViews {
            let itemFrame = itemView.convert(itemView.bounds, to: self)

            if itemFrame.contains(locationInSelf) {
                // Determina se o mouse está na metade superior ou inferior
                let midY = itemFrame.midY
                let position: DropPosition = locationInSelf.y > midY ? .above : .below

                updateDropLine(for: itemView, position: position)
                return .move
            }
        }

        // Mouse não está sobre nenhum item (está no gap)
        // Mantém a última posição válida da drop line
        return .move
    }
}

Coordenadas no NSView (non-flipped)

Um detalhe crucial que causa muita confusão: NSView usa coordenadas non-flipped por padrão. Isso significa:

  • Y = 0 está na parte inferior da view
  • Y aumenta para cima
  • frame.minY é a borda inferior visual
  • frame.maxY é a borda superior visual

Isso é o oposto do iOS (UIView) e de muitos outros sistemas gráficos.

// NSView non-flipped: Y=0 embaixo, Y aumenta para cima
//
//     maxY ─────────────────── (topo visual)
//          │                 │
//          │    Item View    │
//          │                 │
//     minY ─────────────────── (base visual)
//
// Se locationInSelf.y > midY, o mouse está na metade SUPERIOR

private func updateDropLine(for itemView: ItemView, position: DropPosition) {
    pendingDropItemIndex = itemView.itemIndex
    pendingDropPosition = position

    let itemFrame = itemView.convert(itemView.bounds, to: self)

    // Gap entre itens = 4pt (definido no stack view)
    // Drop line height = 3pt
    // Queremos centralizar a linha no gap

    let offsetFromTop: CGFloat
    switch position {
    case .above:
        // Linha acima do item: no gap entre este item e o anterior
        // Gap center = itemFrame.maxY + 2 (metade do gap de 4pt)
        // Line top = gap_center + 1.5 (metade da altura da linha)
        offsetFromTop = bounds.height - itemFrame.maxY - 3.5

    case .below:
        // Linha abaixo do item: no gap entre este item e o próximo
        // Gap center = itemFrame.minY - 2
        // Line top = gap_center + 1.5
        offsetFromTop = bounds.height - itemFrame.minY + 0.5
    }

    // Atualiza apenas o .constant, não cria nova constraint
    dropLineYConstraint?.constant = offsetFromTop
    dropLineView.isHidden = false
}

Por que atualizar .constant em vez de criar nova constraint?

Se você criar uma nova constraint a cada draggingUpdated, vai gerar conflitos de Auto Layout:

// ERRADO: cria constraints conflitantes
func updateDropLine(...) {
    NSLayoutConstraint.activate([
        dropLineView.topAnchor.constraint(equalTo: topAnchor, constant: newY)
    ])
}

// CORRETO: atualiza a constraint existente
func updateDropLine(...) {
    dropLineYConstraint?.constant = newY
}

Handling de gaps entre itens

Quando o mouse está no gap entre dois itens, draggingUpdated não encontra nenhum item (o loop não entra em nenhum if itemFrame.contains).

A solução: não esconda a drop line quando isso acontecer. Mantenha a última posição válida:

override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
    // ...

    for itemView in itemViews {
        let itemFrame = itemView.convert(itemView.bounds, to: self)
        if itemFrame.contains(locationInSelf) {
            updateDropLine(for: itemView, position: position)
            return .move
        }
    }

    // NÃO faça isso:
    // dropLineView.isHidden = true

    // A linha permanece na última posição válida
    return .move
}

A linha só é escondida quando o drag sai completamente da view:

override func draggingExited(_ sender: NSDraggingInfo?) {
    dropLineView.isHidden = true
    pendingDropItemIndex = nil
    pendingDropPosition = nil
}

Executando o drop

O performDragOperation usa as informações pendentes para executar a reordenação:

override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
    dropLineView.isHidden = true

    guard let payload = decodePayload(from: sender),
          let itemIndex = pendingDropItemIndex,
          let position = pendingDropPosition else {
        clearPendingInfo()
        return false
    }

    // Calcula o índice de inserção
    let insertIndex = position == .above ? itemIndex : itemIndex + 1

    // Executa a reordenação
    reorderItem(payload.itemID, toIndex: insertIndex)

    clearPendingInfo()
    return true
}

private func clearPendingInfo() {
    pendingDropItemIndex = nil
    pendingDropPosition = nil
}

Resumo da arquitetura

  • Item View — Apenas drag SOURCE. Armazena itemIndex para o container ler.
  • Container View — Drop TARGET único. Encontra item sob mouse, calcula posição, mostra drop line, executa drop.
  • Drop Line — View simples com constraint Y atualizável. Permanece visível nos gaps.
  • Pending Info — Armazena último item/posição válidos para drops em gaps.

Código completo de exemplo

ItemView (drag source)

final class ItemView: NSView, NSDraggingSource {
    let itemID: UUID
    var itemIndex: Int = 0

    private var mouseDownLocation: NSPoint?
    private var isDragging = false
    private static let dragThreshold: CGFloat = 3.0

    init(itemID: UUID) {
        self.itemID = itemID
        super.init(frame: .zero)
        // NÃO registra para drag types
    }

    override func mouseDown(with event: NSEvent) {
        mouseDownLocation = convert(event.locationInWindow, from: nil)
        isDragging = false
    }

    override func mouseDragged(with event: NSEvent) {
        guard let downLocation = mouseDownLocation else { return }

        let currentLocation = convert(event.locationInWindow, from: nil)
        let distance = hypot(
            currentLocation.x - downLocation.x,
            currentLocation.y - downLocation.y
        )

        if !isDragging && distance >= Self.dragThreshold {
            isDragging = true
            startDragSession(with: event)
        }
    }

    private func startDragSession(with event: NSEvent) {
        let pasteboardItem = NSPasteboardItem()

        let payload = DragPayload(itemID: itemID)
        guard let data = try? JSONEncoder().encode(payload) else { return }
        pasteboardItem.setData(data, forType: .myDragType)

        let draggingItem = NSDraggingItem(pasteboardWriter: pasteboardItem)
        draggingItem.setDraggingFrame(bounds, contents: snapshot())

        beginDraggingSession(with: [draggingItem], event: event, source: self)
    }

    func draggingSession(
        _ session: NSDraggingSession,
        sourceOperationMaskFor context: NSDraggingContext
    ) -> NSDragOperation {
        context == .withinApplication ? .move : []
    }

    private func snapshot() -> NSImage {
        let image = NSImage(size: bounds.size)
        image.lockFocus()
        if let context = NSGraphicsContext.current?.cgContext {
            layer?.render(in: context)
        }
        image.unlockFocus()
        return image
    }
}

ContainerView (drop target)

final class ContainerView: NSView {
    private var itemViews: [ItemView] = []
    private let dropLineView = NSView()
    private var dropLineYConstraint: NSLayoutConstraint?
    private var pendingDropItemIndex: Int?
    private var pendingDropPosition: DropPosition?

    var onReorderItem: ((UUID, Int) -> Void)?

    override init(frame: NSRect) {
        super.init(frame: frame)
        registerForDraggedTypes([.myDragType])
        setupDropLine()
    }

    // ... setup code ...

    override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
        decodePayload(from: sender) != nil ? .move : []
    }

    override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
        guard decodePayload(from: sender) != nil else { return [] }

        let location = convert(sender.draggingLocation, from: nil)

        for itemView in itemViews {
            let frame = itemView.convert(itemView.bounds, to: self)
            if frame.contains(location) {
                let position: DropPosition = location.y > frame.midY ? .above : .below
                updateDropLine(for: itemView, position: position)
                return .move
            }
        }

        return .move
    }

    override func draggingExited(_ sender: NSDraggingInfo?) {
        dropLineView.isHidden = true
        clearPendingInfo()
    }

    override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
        dropLineView.isHidden = true

        guard let payload = decodePayload(from: sender),
              let index = pendingDropItemIndex,
              let position = pendingDropPosition else {
            clearPendingInfo()
            return false
        }

        let insertIndex = position == .above ? index : index + 1
        onReorderItem?(payload.itemID, insertIndex)

        clearPendingInfo()
        return true
    }

    private func updateDropLine(for itemView: ItemView, position: DropPosition) {
        pendingDropItemIndex = itemView.itemIndex
        pendingDropPosition = position

        let frame = itemView.convert(itemView.bounds, to: self)
        let offset: CGFloat = position == .above
            ? bounds.height - frame.maxY - 3.5
            : bounds.height - frame.minY + 0.5

        dropLineYConstraint?.constant = offset
        dropLineView.isHidden = false
    }

    private func clearPendingInfo() {
        pendingDropItemIndex = nil
        pendingDropPosition = nil
    }

    private func decodePayload(from info: NSDraggingInfo) -> DragPayload? {
        guard let data = info.draggingPasteboard.data(forType: .myDragType) else {
            return nil
        }
        return try? JSONDecoder().decode(DragPayload.self, from: data)
    }
}

Tipos auxiliares

struct DragPayload: Codable {
    let itemID: UUID
}

extension NSPasteboard.PasteboardType {
    static let myDragType = NSPasteboard.PasteboardType("com.myapp.drag")
}

enum DropPosition {
    case above
    case below
}

Referências