commit 98daee6a460db2678bb95bcbddda008d866e3c7a
parent b519835d06d458d99207703f426a5648a16dd12a
Author: Michael Camilleri <[email protected]>
Date: Fri, 13 Mar 2026 19:37:18 +0900
Use NSDraggingSource on macOS
SwiftUI on macOS does not provide the same support for dragging gestures
as AppKit. This commit uses AppKit in the macOS version.
Co-Authored-By: Claude 4.6 Opus <[email protected]>
Diffstat:
2 files changed, 112 insertions(+), 24 deletions(-)
diff --git a/ListlessMac/Helpers/TaskRowDragGesture.swift b/ListlessMac/Helpers/TaskRowDragGesture.swift
@@ -28,27 +28,20 @@ struct TaskRowDragGesture: ViewModifier {
let onLift: () -> Void
let onLiftEnd: () -> Void
- @State private var dragStarted = false
- @State private var mouseUpMonitor: Any?
+ @State private var isLifted = false
+ @State private var dragSource = DragSourceManager()
+ @State private var monitors: [Any] = []
func body(content: Content) -> some View {
if isActive {
content
- .onDrag {
- if !dragStarted {
- dragStarted = true
- onDragStart()
- }
- return NSItemProvider(object: taskID.uuidString as NSString)
- } preview: {
- Color.clear.frame(width: 1, height: 1)
- }
+ .background { DragSourceAnchor(manager: dragSource) }
.simultaneousGesture(
LongPressGesture(minimumDuration: 0.4)
.onEnded { _ in
- dragStarted = false
+ isLifted = true
onLift()
- installMouseUpMonitor()
+ installMonitors()
}
)
} else {
@@ -56,20 +49,112 @@ struct TaskRowDragGesture: ViewModifier {
}
}
- private func installMouseUpMonitor() {
- removeMouseUpMonitor()
- mouseUpMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { event in
- removeMouseUpMonitor()
- dragStarted = false
- onLiftEnd()
- return event
+ private func installMonitors() {
+ removeMonitors()
+
+ dragSource.onDragEnd = {
+ endLift()
}
+
+ // Mouse dragged: begin drag session
+ monitors.append(
+ NSEvent.addLocalMonitorForEvents(matching: .leftMouseDragged) { event in
+ if isLifted && !dragSource.isActive {
+ onDragStart()
+ dragSource.beginDrag(taskID: taskID, event: event)
+ }
+ return event
+ }!
+ )
+
+ // Mouse up: end lift if no drag session started
+ monitors.append(
+ NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { event in
+ if isLifted && !dragSource.isActive {
+ endLift()
+ }
+ return event
+ }!
+ )
+
+ // Escape: cancel lift (during a drag session, AppKit handles
+ // Escape internally and calls draggingSession(_:endedAt:operation:))
+ monitors.append(
+ NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
+ if event.keyCode == 53 && isLifted && !dragSource.isActive {
+ endLift()
+ return nil
+ }
+ return event
+ }!
+ )
+ }
+
+ private func endLift() {
+ removeMonitors()
+ isLifted = false
+ dragSource.isActive = false
+ onLiftEnd()
}
- private func removeMouseUpMonitor() {
- if let monitor = mouseUpMonitor {
+ private func removeMonitors() {
+ for monitor in monitors {
NSEvent.removeMonitor(monitor)
- mouseUpMonitor = nil
}
+ monitors = []
+ }
+}
+
+// MARK: - Drag Source
+
+@MainActor
+class DragSourceManager: NSObject, @preconcurrency NSDraggingSource {
+ weak var sourceView: NSView?
+ var isActive = false
+ var onDragEnd: (() -> Void)?
+
+ func beginDrag(taskID: UUID, event: NSEvent) {
+ guard let sourceView, !isActive else { return }
+ let item = NSDraggingItem(pasteboardWriter: taskID.uuidString as NSString)
+ let image = NSImage(size: NSSize(width: 1, height: 1))
+ item.setDraggingFrame(sourceView.bounds, contents: image)
+ isActive = true
+ sourceView.beginDraggingSession(with: [item], event: event, source: self)
+ }
+
+ func draggingSession(
+ _ session: NSDraggingSession,
+ sourceOperationMaskFor context: NSDraggingContext
+ ) -> NSDragOperation {
+ context == .withinApplication ? .move : []
+ }
+
+ func draggingSession(
+ _ session: NSDraggingSession,
+ endedAt screenPoint: NSPoint,
+ operation: NSDragOperation
+ ) {
+ isActive = false
+ onDragEnd?()
+ }
+}
+
+// MARK: - Drag Source Anchor View
+
+private class DragPassthroughView: NSView {
+ override func hitTest(_ point: NSPoint) -> NSView? { nil }
+}
+
+struct DragSourceAnchor: NSViewRepresentable {
+ let manager: DragSourceManager
+
+ func makeNSView(context: Context) -> NSView {
+ let view = DragPassthroughView()
+ manager.sourceView = view
+ return view
+ }
+
+ func updateNSView(_ nsView: NSView, context: Context) {
+ manager.sourceView = nsView
}
}
diff --git a/ListlessMac/Views/TaskListView.swift b/ListlessMac/Views/TaskListView.swift
@@ -201,7 +201,10 @@ struct TaskListView: View, TaskListViewProtocol {
startDrag(taskID: task.id)
},
onLift: { iState.liftedTaskID = task.id },
- onLiftEnd: { if iState.liftedTaskID == task.id { iState.liftedTaskID = nil } }
+ onLiftEnd: {
+ if iState.liftedTaskID == task.id { iState.liftedTaskID = nil }
+ if draggedTaskID == task.id { clearDragState() }
+ }
)
.scaleEffect(isRowLifted(taskID) ? 1.03 : 1.0)
.shadow(