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:
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()