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

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:
- Receber todos os eventos de drag
- Descobrir qual item está sob o mouse
- Calcular se o drop seria acima ou abaixo desse item
- Mostrar o drop indicator na posição correta
- 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 = 0está na parte inferior da viewYaumenta para cimaframe.minYé a borda inferior visualframe.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
itemIndexpara 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
Posts Relacionados
CoreData: debug: WAL checkpoint - O que significa esse log?
Explicação técnica sobre os logs 'CoreData: debug: WAL checkpoint: Database did checkpoint' que aparecem durante operações com CoreData e SwiftData no iOS e macOS.
Como remover o Liquid Glass no iOS 26 (SwiftUI e UIKit)
Guia prático para remover o fundo circular de vidro dos botões de navegação no iOS 26, com exemplos em SwiftUI e UIKit