← All repositories

listless

A simple list app for Apple platforms

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


commit 877d4df8a209dfe96d5d4fadaa05b69bf5b2154a
parent dc334b1feb015162fa79bcf4ed4385334eefeb78
Author: Michael Camilleri <[email protected]>
Date:   Thu, 26 Feb 2026 06:03:43 +0900

Add visual lift effect to dragging on macOS

Co-Authored-By: Codex GPT 5.3 <[email protected]>

Diffstat

MListlessMac/Helpers/TaskRowDragGesture.swift | 38++++++++++++++++++++++++++++----------
MListlessMac/Views/TaskListView.swift | 44++++++++++++++++++++++++++++++--------------

2 files changed, 58 insertions(+), 24 deletions(-)

diff --git a/ListlessMac/Helpers/TaskRowDragGesture.swift b/ListlessMac/Helpers/TaskRowDragGesture.swift

@@ -1,3 +1,4 @@
+import AppKit
 import SwiftUI
 import UniformTypeIdentifiers
 
@@ -6,14 +7,16 @@ extension View {
         isActive: Bool,
         taskID: UUID,
         onDragStart: @escaping () -> Void,
-        onDragChanged: @escaping (CGPoint) -> Void = { _ in },
-        onDragEnded: @escaping () -> Void = { }
+        onLift: @escaping () -> Void = {},
+        onLiftEnd: @escaping () -> Void = {}
     ) -> some View {
         self.modifier(
             TaskRowDragGesture(
                 isActive: isActive,
                 taskID: taskID,
-                onDragStart: onDragStart
+                onDragStart: onDragStart,
+                onLift: onLift,
+                onLiftEnd: onLiftEnd
             ))
     }
 }
@@ -22,8 +25,11 @@ struct TaskRowDragGesture: ViewModifier {
     let isActive: Bool
     let taskID: UUID
     let onDragStart: () -> Void
+    let onLift: () -> Void
+    let onLiftEnd: () -> Void
 
     @State private var dragStarted = false
+    @State private var mouseUpMonitor: Any?
 
     func body(content: Content) -> some View {
         if isActive {
@@ -38,15 +44,11 @@ struct TaskRowDragGesture: ViewModifier {
                     Color.clear.frame(width: 1, height: 1)
                 }
                 .simultaneousGesture(
-                    DragGesture(minimumDistance: 2)
-                        .onChanged { _ in
-                            if !dragStarted {
-                                dragStarted = true
-                                onDragStart()
-                            }
-                        }
+                    LongPressGesture(minimumDuration: 0.4)
                         .onEnded { _ in
                             dragStarted = false
+                            onLift()
+                            installMouseUpMonitor()
                         }
                 )
         } else {
@@ -54,4 +56,20 @@ struct TaskRowDragGesture: ViewModifier {
         }
     }
 
+    private func installMouseUpMonitor() {
+        removeMouseUpMonitor()
+        mouseUpMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseUp) { event in
+            removeMouseUpMonitor()
+            dragStarted = false
+            onLiftEnd()
+            return event
+        }
+    }
+
+    private func removeMouseUpMonitor() {
+        if let monitor = mouseUpMonitor {
+            NSEvent.removeMonitor(monitor)
+            mouseUpMonitor = nil
+        }
+    }
 }

diff --git a/ListlessMac/Views/TaskListView.swift b/ListlessMac/Views/TaskListView.swift

@@ -20,6 +20,7 @@ struct TaskListView: View {
 
     struct InteractionStateData {
         var dragState: DragState = .idle
+        var liftedTaskID: UUID?
     }
 
     struct TaskStateData {
@@ -67,6 +68,11 @@ struct TaskListView: View {
         nonmutating set { iState.dragState = newValue }
     }
 
+    var liftedTaskID: UUID? {
+        get { iState.liftedTaskID }
+        nonmutating set { iState.liftedTaskID = newValue }
+    }
+
     var refreshID: UUID {
         get { tState.refreshID }
         nonmutating set { tState.refreshID = newValue }
@@ -173,6 +179,10 @@ struct TaskListView: View {
         self.syncMonitor = syncMonitor
     }
 
+    func isRowLifted(_ taskID: UUID) -> Bool {
+        liftedTaskID == taskID || draggedTaskID == taskID
+    }
+
     func didStartDrag() {}
 
     var body: some View {
@@ -198,8 +208,20 @@ struct TaskListView: View {
                     .taskDragGesture(
                         isActive: !task.isCompleted,
                         taskID: task.id,
-                        onDragStart: { startDrag(taskID: task.id) }
+                        onDragStart: {
+                            liftedTaskID = nil
+                            startDrag(taskID: task.id)
+                        },
+                        onLift: { liftedTaskID = task.id },
+                        onLiftEnd: { if liftedTaskID == task.id { liftedTaskID = nil } }
+                    )
+                    .scaleEffect(isRowLifted(taskID) ? 1.03 : 1.0)
+                    .shadow(
+                        color: isRowLifted(taskID) ? .black.opacity(0.2) : .clear,
+                        radius: 8, y: 3
                     )
+                    .zIndex(isRowLifted(taskID) ? 1 : 0)
+                    .animation(.spring(response: 0.3, dampingFraction: 0.8), value: isRowLifted(taskID))
                     .overlay {
                         if draggedTaskID != nil && draggedTaskID != task.id {
                             VStack(spacing: 0) {
@@ -243,19 +265,6 @@ struct TaskListView: View {
                     }
                 }
 
-                // Drop zone at the end
-                if !activeTasks.isEmpty && draggedTaskID != nil {
-                    Color.clear
-                        .frame(height: 44)
-                        .onDrop(
-                            of: [UTType.text],
-                            delegate: TaskReorderDropDelegate(
-                                onTargeted: { updateVisualOrder(insertAtEnd: true) },
-                                onPerform: { commitCurrentDrag() }
-                            )
-                        )
-                }
-
                 ForEach(completedTasks) { task in
                     let taskID = task.id
                     TaskRowView(
@@ -279,6 +288,13 @@ struct TaskListView: View {
                 )
             )
         }
+        .onDrop(
+            of: [UTType.text],
+            delegate: TaskReorderDropDelegate(
+                onTargeted: {},
+                onPerform: { commitCurrentDrag() }
+            )
+        )
         .contentShape(Rectangle())
         .onTapGesture {
             handleBackgroundTap()