listless

A simple list app for Apple platforms
Log | Files | Refs | README | LICENSE

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 }