ItemRowDragGesture.swift (4499B)
1 import AppKit 2 import SwiftUI 3 import UniformTypeIdentifiers 4 5 extension View { 6 func itemDragGesture( 7 isActive: Bool, 8 itemID: UUID, 9 onDragStart: @escaping () -> Void, 10 onLift: @escaping () -> Void = {}, 11 onLiftEnd: @escaping () -> Void = {} 12 ) -> some View { 13 self.modifier( 14 ItemRowDragGesture( 15 isActive: isActive, 16 itemID: itemID, 17 onDragStart: onDragStart, 18 onLift: onLift, 19 onLiftEnd: onLiftEnd 20 )) 21 } 22 } 23 24 struct ItemRowDragGesture: ViewModifier { 25 let isActive: Bool 26 let itemID: UUID 27 let onDragStart: () -> Void 28 let onLift: () -> Void 29 let onLiftEnd: () -> Void 30 31 @State private var isLifted = false 32 @State private var dragSource = DragSourceManager() 33 @State private var monitors: [Any] = [] 34 35 func body(content: Content) -> some View { 36 if isActive { 37 content 38 .background { DragSourceAnchor(manager: dragSource) } 39 .simultaneousGesture( 40 LongPressGesture(minimumDuration: 0.4) 41 .onEnded { _ in 42 isLifted = true 43 onLift() 44 installMonitors() 45 } 46 ) 47 } else { 48 content 49 } 50 } 51 52 private func installMonitors() { 53 removeMonitors() 54 55 dragSource.onDragEnd = { 56 endLift() 57 } 58 59 // Mouse dragged: begin drag session 60 monitors.append( 61 NSEvent.addLocalMonitorForEvents(matching: .leftMouseDragged) { event in 62 if isLifted && !dragSource.isActive { 63 onDragStart() 64 dragSource.beginDrag(itemID: itemID, event: event) 65 } 66 return event 67 }! 68 ) 69 70 // Mouse up: end lift if no drag session started 71 monitors.append( 72 NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { event in 73 if isLifted && !dragSource.isActive { 74 endLift() 75 } 76 return event 77 }! 78 ) 79 80 // Escape: cancel lift (during a drag session, AppKit handles 81 // Escape internally and calls draggingSession(_:endedAt:operation:)) 82 monitors.append( 83 NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in 84 if event.keyCode == 53 && isLifted && !dragSource.isActive { 85 endLift() 86 return nil 87 } 88 return event 89 }! 90 ) 91 } 92 93 private func endLift() { 94 removeMonitors() 95 isLifted = false 96 dragSource.isActive = false 97 onLiftEnd() 98 } 99 100 private func removeMonitors() { 101 for monitor in monitors { 102 NSEvent.removeMonitor(monitor) 103 } 104 monitors = [] 105 } 106 } 107 108 // MARK: - Drag Source 109 110 @MainActor 111 class DragSourceManager: NSObject, NSDraggingSource { 112 weak var sourceView: NSView? 113 var isActive = false 114 var onDragEnd: (() -> Void)? 115 116 func beginDrag(itemID: UUID, event: NSEvent) { 117 guard let sourceView, !isActive else { return } 118 let item = NSDraggingItem(pasteboardWriter: itemID.uuidString as NSString) 119 let image = NSImage(size: NSSize(width: 1, height: 1)) 120 item.setDraggingFrame(sourceView.bounds, contents: image) 121 isActive = true 122 sourceView.beginDraggingSession(with: [item], event: event, source: self) 123 } 124 125 func draggingSession( 126 _ session: NSDraggingSession, 127 sourceOperationMaskFor context: NSDraggingContext 128 ) -> NSDragOperation { 129 context == .withinApplication ? .move : [] 130 } 131 132 func draggingSession( 133 _ session: NSDraggingSession, 134 endedAt screenPoint: NSPoint, 135 operation: NSDragOperation 136 ) { 137 isActive = false 138 onDragEnd?() 139 } 140 } 141 142 // MARK: - Drag Source Anchor View 143 144 private class DragPassthroughView: NSView { 145 override func hitTest(_ point: NSPoint) -> NSView? { nil } 146 } 147 148 struct DragSourceAnchor: NSViewRepresentable { 149 let manager: DragSourceManager 150 151 func makeNSView(context: Context) -> NSView { 152 let view = DragPassthroughView() 153 manager.sourceView = view 154 return view 155 } 156 157 func updateNSView(_ nsView: NSView, context: Context) { 158 manager.sourceView = nsView 159 } 160 }