listless

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

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