ListlessMac/Helpers/ItemRowDragGesture.swift (4499B)
1import AppKit
2import SwiftUI
3import UniformTypeIdentifiers
4
5extension 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
24struct 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
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
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
81
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
109
110@MainActor
111class 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
143
144private class DragPassthroughView: NSView {
145 override func hitTest(_ point: NSPoint) -> NSView? { nil }
146}
147
148struct 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}