← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


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        // 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
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// MARK: - Drag Source Anchor View
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}