listless

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

commit 5440590cea2a0f4a76416299f58be67d92b4a362
parent 554b423b2765660f82247aa6d88f7ec9fecb9b81
Author: Michael Camilleri <[email protected]>
Date:   Thu, 12 Feb 2026 09:58:18 +0900

Improving simultaneous support for gestures and tap to focus

Co-Authored-By: Claude 4.5 Sonnet <[email protected]>

Diffstat:
MListless.xcodeproj/project.pbxproj | 8++++++++
MListless.xcodeproj/xcshareddata/xcschemes/Listless macOS.xcscheme | 12+++---------
MListless/Views/TaskListView.swift | 55+++++++++++++++++++++++++++++++++++++------------------
AListlessiOS/Views/TaskRowSwipeGesture.swift | 331+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AListlessiOS/Views/TaskRowTapGesture.swift | 39+++++++++++++++++++++++++++++++++++++++
MListlessiOS/Views/TaskRowView.swift | 131++++++++++++++++++++++++-------------------------------------------------------
6 files changed, 457 insertions(+), 119 deletions(-)

diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 614FCCA450EC0BFFD8B40640 /* ListlessMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA467DF2E59BDBE6EEF6A7D /* ListlessMacApp.swift */; }; 69D1909CB6D2368CF90065C7 /* ColorExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBDF292392702661DBB94D06 /* ColorExtensions.swift */; }; 6C252050E62AED3A0A684EBF /* KeyboardNavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7B37EF6A2389656D105FF8 /* KeyboardNavigationModifier.swift */; }; + 7B2CD636BA3B63A586F93E31 /* TaskRowSwipeGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44C16D36971A140364159FB9 /* TaskRowSwipeGesture.swift */; }; 7E366008FF9335A77FE1636D /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 199CC2F58DD7CBA3F2229366 /* TaskRowView.swift */; }; 83378A0575640417ED41FC25 /* ClickableTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F2BB7ABCABA4EB67180A200 /* ClickableTextField.swift */; }; 8E08F4C82D6F9E67667CB20A /* TaskListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 537A913AC421BAEF60D26D9C /* TaskListView.swift */; }; @@ -35,6 +36,7 @@ D878CD3A552C6A9685A30AA8 /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF6B0195EA0176B72DE9B092 /* PlatformTextFieldWidthModifier.swift */; }; D8EFF49E9156083D675D47F0 /* KeyboardNavigationModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7B37EF6A2389656D105FF8 /* KeyboardNavigationModifier.swift */; }; D9DA553485AD0CA28D5BE0C8 /* TaskListView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93FF5B9F5B979D54D5DEE192 /* TaskListView+Toolbar.swift */; }; + DAB0D2F8881B234DDE2B0BF6 /* TaskRowTapGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8829E89B1F50224FCAE10F84 /* TaskRowTapGesture.swift */; }; DC71C45D92524C3893BF9FDB /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */; }; DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14858BDFD1FD5119F1F24A6 /* PersistenceController.swift */; }; ECD5E7EA05AE1C00B38C939E /* TaskStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 967F7ECEB3915CEDCE584872 /* TaskStoreTests.swift */; }; @@ -65,6 +67,7 @@ 2D3A2DDCE24E54ABCCFBBD4C /* TaskStoreOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreOrderingTests.swift; sourceTree = "<group>"; }; 3313FEDB101EECA4B344EEF4 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 431B0EA72D590AF0CC868515 /* TaskListView+Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Toolbar.swift"; sourceTree = "<group>"; }; + 44C16D36971A140364159FB9 /* TaskRowSwipeGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowSwipeGesture.swift; sourceTree = "<group>"; }; 48AE1AE43296C1692FA6F755 /* PlatformScrollIndicatorsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformScrollIndicatorsModifier.swift; sourceTree = "<group>"; }; 4FC64B9F9370041BEDBD1E14 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; }; 537A913AC421BAEF60D26D9C /* TaskListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListView.swift; sourceTree = "<group>"; }; @@ -75,6 +78,7 @@ 7C73E9D4C42CCABBF0F33543 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; }; 81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; }; 82A3509AD32A54434BCC8017 /* HoverCursorModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoverCursorModifier.swift; sourceTree = "<group>"; }; + 8829E89B1F50224FCAE10F84 /* TaskRowTapGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowTapGesture.swift; sourceTree = "<group>"; }; 917597025B3D5D18E33982D3 /* ColorExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtensions.swift; sourceTree = "<group>"; }; 9262207DAC21619BD9EDEE15 /* Listless.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Listless.entitlements; sourceTree = "<group>"; }; 93FF5B9F5B979D54D5DEE192 /* TaskListView+Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+Toolbar.swift"; sourceTree = "<group>"; }; @@ -137,6 +141,8 @@ 81880970CCFC2CB00B65047E /* PlatformTextFieldWidthModifier.swift */, 93FF5B9F5B979D54D5DEE192 /* TaskListView+Toolbar.swift */, 1B3D0B4710C7A0EE4F227851 /* TaskRowDragGesture.swift */, + 44C16D36971A140364159FB9 /* TaskRowSwipeGesture.swift */, + 8829E89B1F50224FCAE10F84 /* TaskRowTapGesture.swift */, 199CC2F58DD7CBA3F2229366 /* TaskRowView.swift */, ); path = Views; @@ -381,6 +387,8 @@ D9DA553485AD0CA28D5BE0C8 /* TaskListView+Toolbar.swift in Sources */, 42E4CDE1D17463554CC4F41F /* TaskListView.swift in Sources */, D6B3DBDD6A3F0A6E166CFFD5 /* TaskRowDragGesture.swift in Sources */, + 7B2CD636BA3B63A586F93E31 /* TaskRowSwipeGesture.swift in Sources */, + DAB0D2F8881B234DDE2B0BF6 /* TaskRowTapGesture.swift in Sources */, 7E366008FF9335A77FE1636D /* TaskRowView.swift in Sources */, 91EDF52C7C5C0B35E9D8B51E /* TaskStore.swift in Sources */, ); diff --git a/Listless.xcodeproj/xcshareddata/xcschemes/Listless macOS.xcscheme b/Listless.xcodeproj/xcshareddata/xcschemes/Listless macOS.xcscheme @@ -1,11 +1,10 @@ <?xml version="1.0" encoding="UTF-8"?> <Scheme LastUpgradeVersion = "1430" - version = "1.7"> + version = "1.3"> <BuildAction parallelizeBuildables = "YES" - buildImplicitDependencies = "YES" - runPostActionsOnFailure = "NO"> + buildImplicitDependencies = "YES"> <BuildActionEntries> <BuildActionEntry buildForTesting = "YES" @@ -27,8 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES" - onlyGenerateCoverageForSpecifiedTargets = "NO"> + shouldUseLaunchSchemeArgsEnv = "YES"> <MacroExpansion> <BuildableReference BuildableIdentifier = "primary" @@ -61,8 +59,6 @@ ReferencedContainer = "container:Listless.xcodeproj"> </BuildableReference> </BuildableProductRunnable> - <CommandLineArguments> - </CommandLineArguments> </LaunchAction> <ProfileAction buildConfiguration = "Release" @@ -80,8 +76,6 @@ ReferencedContainer = "container:Listless.xcodeproj"> </BuildableReference> </BuildableProductRunnable> - <CommandLineArguments> - </CommandLineArguments> </ProfileAction> <AnalyzeAction buildConfiguration = "Debug"> diff --git a/Listless/Views/TaskListView.swift b/Listless/Views/TaskListView.swift @@ -22,6 +22,7 @@ struct TaskListView: View { @State var selectedTaskID: UUID? @State private var refreshID = UUID() @State private var draggedTaskID: UUID? + @State private var swipingTaskID: UUID? @State private var visualOrder: [UUID]? @State private var pendingFocus: FocusField? @@ -40,17 +41,16 @@ struct TaskListView: View { index: index, totalTasks: displayActiveTasks.count, isSelected: selectedTaskID == taskID, - isEditing: editingTaskID == taskID, focusedField: $focusedField, - onToggle: toggleCompletion(_:), - onTitleChange: updateTitle(_:_:), - onDelete: deleteTask(_:), - onSelect: selectTask(_:), - onStartEdit: startEditing(_:), - onEndEdit: endEditing(_:shouldCreateNewTask:) + onToggle: { toggleCompletion($0) }, + onTitleChange: { updateTitle($0, $1) }, + onDelete: { deleteTask($0) }, + onSelect: { selectTask($0) }, + onStartEdit: { startEditing($0) }, + onEndEdit: { endEditing($0, shouldCreateNewTask: $1) } ) .taskDragGesture( - isActive: !task.isCompleted, + isActive: !task.isCompleted && swipingTaskID == nil, taskID: task.id, onDragStart: { startDrag(taskID: task.id) } ) @@ -116,14 +116,11 @@ struct TaskListView: View { task: task, taskID: taskID, isSelected: selectedTaskID == taskID, - isEditing: editingTaskID == taskID, focusedField: $focusedField, - onToggle: toggleCompletion(_:), - onTitleChange: updateTitle(_:_:), - onDelete: deleteTask(_:), - onSelect: selectTask(_:), - onStartEdit: startEditing(_:), - onEndEdit: endEditing(_:shouldCreateNewTask:) + onToggle: { toggleCompletion($0) }, + onTitleChange: { updateTitle($0, $1) }, + onDelete: { deleteTask($0) }, + onSelect: { selectTask($0) } ) } } @@ -297,6 +294,20 @@ struct TaskListView: View { } } + private func handleSwipeComplete(_ taskID: UUID) { + guard let task = tasks.first(where: { $0.id == taskID }) else { return } + toggleCompletion(task) + } + + private func handleSwipeDelete(_ taskID: UUID) { + guard let task = tasks.first(where: { $0.id == taskID }) else { return } + deleteTask(task) + } + + private func handleSwipeActiveChanged(_ taskID: UUID, _ isActive: Bool) { + swipingTaskID = isActive ? taskID : nil + } + private func selectTask(_ taskID: UUID) { selectedTaskID = taskID } @@ -443,9 +454,17 @@ struct TaskListView: View { print("🟢 endEditing() creating new task") createNewTask() } else { - print("🟢 endEditing() keeping task selected, returning to navigation") - selectedTaskID = taskID - // Focus repair will set to .scrollView if needed + print("🟢 endEditing() done, selection unchanged") + // Do NOT restore selectedTaskID = taskID here. + // + // On macOS: selectedTaskID is already taskID (set by startEditing when editing + // began), and AppKit fires controlTextDidEndEditing synchronously before any + // SwiftUI tap gesture handler runs, so nothing has changed it yet. The line + // would be a no-op. + // + // On iOS: everything flows through onChange(of: focusedField), so onStartEdit + // on the new row may have already updated selectedTaskID before this fires. + // Restoring it here would overwrite the new selection. } print("🟢 endEditing() completed, final focus: \(String(describing: focusedField))") diff --git a/ListlessiOS/Views/TaskRowSwipeGesture.swift b/ListlessiOS/Views/TaskRowSwipeGesture.swift @@ -0,0 +1,331 @@ +import SwiftUI +import UIKit + +extension View { + func taskSwipeGesture( + isActive: Bool, + isEditing: Bool, + isDragging: Bool, + swipeOffset: Binding<CGFloat>, + swipeDirection: Binding<TaskRowSwipeGesture.SwipeDirection>, + isTriggered: Binding<Bool>, + onComplete: @escaping () -> Void, + onDelete: @escaping () -> Void, + onSwipeActiveChanged: @escaping (Bool) -> Void = { _ in } + ) -> some View { + self.modifier( + TaskRowSwipeGesture( + isActive: isActive, + isEditing: isEditing, + isDragging: isDragging, + swipeOffset: swipeOffset, + swipeDirection: swipeDirection, + isTriggered: isTriggered, + onComplete: onComplete, + onDelete: onDelete, + onSwipeActiveChanged: onSwipeActiveChanged + )) + } +} + +struct TaskRowSwipeGesture: ViewModifier { + let isActive: Bool + let isEditing: Bool + let isDragging: Bool + @Binding var swipeOffset: CGFloat + @Binding var swipeDirection: SwipeDirection + @Binding var isTriggered: Bool + let onComplete: () -> Void + let onDelete: () -> Void + let onSwipeActiveChanged: (Bool) -> Void + + enum SwipeDirection: Equatable { + case left + case right + case none + } + + private let completeThreshold: CGFloat = 40 // Pixels to swipe right before triggering complete + private let deleteThreshold: CGFloat = 80 // Pixels to swipe left before triggering delete + private let horizontalBufferPt: CGFloat = 10 // Horizontal movement must exceed vertical by this amount + private let offsetDamping: CGFloat = 0.9 // Damping factor for responsive feel + + func body(content: Content) -> some View { + return ZStack(alignment: .leading) { + // Background stays in place + swipeBackground + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .allowsHitTesting(false) + + // Only the content moves + content + .offset(x: swipeOffset) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: swipeOffset) + .contentShape(Rectangle()) + .background { + #if canImport(UIKit) + SwipePanGestureInstaller( + isEnabled: isActive && !isEditing && !isDragging, + onChanged: { translation in + handleDragChanged( + horizontalTranslation: translation.x, + verticalTranslation: abs(translation.y) + ) + }, + onEnded: { + handleDragEnded() + } + ) + #endif + } + } + .onDisappear { + resetSwipeState() + } + } + + @ViewBuilder + private var swipeBackground: some View { + if swipeDirection == .right { + // Complete action — plain green background + Color.green.opacity(backgroundOpacity(offset: swipeOffset)) + } else if swipeDirection == .left { + // Delete action (red background, trash icon) + HStack { + Spacer() + Image(systemName: "trash.fill") + .font(.system(size: 24)) + .foregroundStyle(.white) + .padding(.trailing, 20) + } + .background(Color.red.opacity(backgroundOpacity(offset: swipeOffset))) + } + } + + private func handleDragChanged(horizontalTranslation: CGFloat, verticalTranslation: CGFloat) { + // Require horizontal > vertical + buffer to activate swipe + guard abs(horizontalTranslation) > verticalTranslation + horizontalBufferPt else { + return + } + + // Determine direction and notify that swipe is active + if swipeDirection == .none { + onSwipeActiveChanged(true) + } + + if horizontalTranslation > 0 { + swipeDirection = .right + } else if horizontalTranslation < 0 { + swipeDirection = .left + } + + // Update offset with damping + swipeOffset = horizontalTranslation * offsetDamping + + // Track whether threshold is currently crossed — reversible until release + if swipeDirection == .right { + isTriggered = swipeOffset >= completeThreshold + } else if swipeDirection == .left { + isTriggered = abs(swipeOffset) >= deleteThreshold + } + } + + private func handleDragEnded() { + if isTriggered { + if swipeDirection == .right { + // Complete: spring back and let SwiftUI animate the row to the completed section + triggerAction(action: onComplete) + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + resetSwipeState() + } + } else { + // Delete: slide off screen + triggerAction(action: onDelete) + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + swipeOffset = -400 + } + } + } else { + // Released below threshold — spring back with no action + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + resetSwipeState() + } + } + } + + private func triggerAction(action: @escaping () -> Void) { + isTriggered = true + + // Trigger haptic feedback + let generator = UIImpactFeedbackGenerator(style: .medium) + generator.impactOccurred() + + // Execute action after a brief delay to show visual feedback + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + action() + } + } + + private func resetSwipeState() { + if swipeDirection != .none { + onSwipeActiveChanged(false) + } + swipeOffset = 0 + swipeDirection = .none + isTriggered = false + } + + private func backgroundOpacity(offset: CGFloat) -> CGFloat { + let threshold = offset >= 0 ? completeThreshold : deleteThreshold + return min(abs(offset) / threshold, 1.0) + } +} + +private struct SwipePanGestureInstaller: UIViewRepresentable { + let isEnabled: Bool + let onChanged: (CGPoint) -> Void + let onEnded: () -> Void + + func makeUIView(context: Context) -> InstallerView { + let view = InstallerView() + view.onMoveToSuperview = { installerView in + context.coordinator.attach(to: hostView(for: installerView), marker: installerView) + } + return view + } + + func updateUIView(_ uiView: InstallerView, context: Context) { + context.coordinator.isEnabled = isEnabled + context.coordinator.onChanged = onChanged + context.coordinator.onEnded = onEnded + context.coordinator.attach(to: hostView(for: uiView), marker: uiView) + } + + static func dismantleUIView(_ uiView: InstallerView, coordinator: Coordinator) { + coordinator.detach() + } + + func makeCoordinator() -> Coordinator { + Coordinator(isEnabled: isEnabled, onChanged: onChanged, onEnded: onEnded) + } + + private func hostView(for installerView: UIView) -> UIView? { + // Climb toward the row container (above the background host, below the ScrollView). + var current = installerView.superview + var candidate: UIView? + + while let view = current { + if view is UIScrollView { + break + } + candidate = view + current = view.superview + } + + return candidate ?? installerView.superview + } + + final class InstallerView: UIView { + var onMoveToSuperview: ((UIView) -> Void)? + + override func didMoveToSuperview() { + super.didMoveToSuperview() + onMoveToSuperview?(self) + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + false + } + } + + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + var isEnabled: Bool + var onChanged: (CGPoint) -> Void + var onEnded: () -> Void + + private weak var attachedView: UIView? + private weak var markerView: UIView? + private var panRecognizer: UIPanGestureRecognizer? + + init(isEnabled: Bool, onChanged: @escaping (CGPoint) -> Void, onEnded: @escaping () -> Void) { + self.isEnabled = isEnabled + self.onChanged = onChanged + self.onEnded = onEnded + } + + func attach(to view: UIView?, marker: UIView) { + markerView = marker + + guard let view else { + detach() + return + } + + if attachedView === view { + panRecognizer?.isEnabled = isEnabled + return + } + + detach() + + let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) + pan.delegate = self + pan.cancelsTouchesInView = false + pan.delaysTouchesBegan = false + pan.maximumNumberOfTouches = 1 + pan.isEnabled = isEnabled + view.addGestureRecognizer(pan) + + attachedView = view + panRecognizer = pan + } + + func detach() { + if let panRecognizer, let attachedView { + attachedView.removeGestureRecognizer(panRecognizer) + } + panRecognizer = nil + attachedView = nil + markerView = nil + } + + @objc + private func handlePan(_ recognizer: UIPanGestureRecognizer) { + guard isEnabled else { return } + let translation = recognizer.translation(in: recognizer.view) + + switch recognizer.state { + case .began, .changed: + onChanged(translation) + case .ended, .cancelled, .failed: + onEnded() + default: + break + } + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard + isEnabled, + let pan = gestureRecognizer as? UIPanGestureRecognizer, + let attachedView, + let markerView + else { + return false + } + + // Multiple row recognizers may be attached to a shared host view. + // Only allow the recognizer whose row contains the touch to begin. + let markerFrame = markerView.convert(markerView.bounds, to: attachedView) + let touchPoint = pan.location(in: attachedView) + return markerFrame.contains(touchPoint) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } + } +} diff --git a/ListlessiOS/Views/TaskRowTapGesture.swift b/ListlessiOS/Views/TaskRowTapGesture.swift @@ -0,0 +1,39 @@ +import SwiftUI + +extension View { + func taskTapGesture( + onCheckboxTap: @escaping () -> Void, + onTextTap: @escaping () -> Void + ) -> some View { + self.modifier( + TaskRowTapGesture( + onCheckboxTap: onCheckboxTap, + onTextTap: onTextTap + )) + } +} + +struct TaskRowTapGesture: ViewModifier { + let onCheckboxTap: () -> Void + let onTextTap: () -> Void + + private let checkboxZoneWidth: CGFloat = 48 // Checkbox + padding area + + func body(content: Content) -> some View { + content + .contentShape(Rectangle()) + .onTapGesture(coordinateSpace: .local) { location in + handleTap(at: location) + } + } + + private func handleTap(at location: CGPoint) { + if location.x <= checkboxZoneWidth { + // Tap in checkbox zone + onCheckboxTap() + } else { + // Tap in text zone - TextField will handle focus natively + onTextTap() + } + } +} diff --git a/ListlessiOS/Views/TaskRowView.swift b/ListlessiOS/Views/TaskRowView.swift @@ -3,10 +3,7 @@ import SwiftUI struct TaskRowView: View { let task: TaskItem let taskID: UUID - let index: Int - let totalTasks: Int let isSelected: Bool - let isEditing: Bool let onToggle: (TaskItem) -> Void let onTitleChange: (TaskItem, String) -> Void let onDelete: (TaskItem) -> Void @@ -17,15 +14,9 @@ struct TaskRowView: View { @State private var editingTitle: String = "" @State private var isCurrentlyEditing: Bool = false - @State private var submittedViaReturn = false - - private let horizontalPadding: CGFloat = 16 - private let checkboxTextSpacing: CGFloat = 12 - @ScaledMetric private var checkboxSize: CGFloat = 20 - - private var dividerInset: CGFloat { - horizontalPadding + checkboxSize + checkboxTextSpacing - } + @State private var swipeOffset: CGFloat = 0 + @State private var swipeDirection: TaskRowSwipeGesture.SwipeDirection = .none + @State private var isSwipeTriggered: Bool = false init( task: TaskItem, @@ -44,10 +35,7 @@ struct TaskRowView: View { ) { self.task = task self.taskID = taskID - self.index = index - self.totalTasks = totalTasks self.isSelected = isSelected - self.isEditing = isEditing self.onToggle = onToggle self.onTitleChange = onTitleChange self.onDelete = onDelete @@ -62,94 +50,70 @@ struct TaskRowView: View { Button { onToggle(task) } label: { - Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle") - .foregroundStyle(task.isCompleted ? .secondary : .primary) + // When a right-swipe is past the threshold, preview the toggled state + let previewCompleted = isSwipeTriggered && swipeDirection == .right + ? !task.isCompleted + : task.isCompleted + Image(systemName: previewCompleted ? "checkmark.circle.fill" : "circle") + .foregroundStyle(previewCompleted ? Color.secondary : Color.primary) .font(.system(size: 17)) .fontWeight(.thin) } .buttonStyle(.borderless) - .accessibilityIdentifier("task-checkbox") - .accessibilityValue(task.isCompleted ? "checkmark.circle.fill" : "circle") - TextField("New task", text: $editingTitle, axis: .vertical) - .textFieldStyle(.plain) - .font(.body) - .lineLimit(1...5) + TextField("Task", text: $editingTitle) .focused($focusedField, equals: .task(taskID)) - .onSubmit { - // Return key pressed - mark for new task creation - submittedViaReturn = true - focusedField = nil - } + .foregroundStyle(task.isCompleted ? Color.secondary : Color.primary) + .strikethrough(task.isCompleted, color: .secondary) .disabled(task.isCompleted) .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityIdentifier( - isCurrentlyEditing ? "task-textfield" : "task-text-\(task.title)") } .padding(.vertical, 8) .padding(.horizontal, 16) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) - .onTapGesture { - onSelect(taskID) - } + .simultaneousGesture( + TapGesture() + .onEnded { + onSelect(taskID) + if !task.isCompleted { + focusedField = .task(taskID) + } + } + ) .background(selectionBackground) - .overlay(alignment: .bottom) { - // Hairline border between rows, inset to align with text - // Only show for active (non-completed) tasks - if !task.isCompleted { - Rectangle() - .fill(.separator) - .frame(height: 0.5) - .padding(.leading, dividerInset) - } - } - .contextMenu { - Button(task.isCompleted ? "Mark as Incomplete" : "Mark as Complete") { - onToggle(task) - } - Divider() - Button("Cut") { - cutToPasteboard() - } - Button("Copy") { - copyToPasteboard() - } - Button("Paste") { - pasteFromPasteboard() - } - Divider() - Button("Delete", role: .destructive) { - onDelete(task) - } + .onAppear { + editingTitle = task.title } .onChange(of: editingTitle) { guard !task.isCompleted else { return } onTitleChange(task, editingTitle) } .onChange(of: task.title) { _, newValue in - // Keep editingTitle in sync with task.title when not editing if !isCurrentlyEditing { editingTitle = newValue } } .onChange(of: focusedField) { _, newValue in - let isFocused = newValue == .task(taskID) - if isFocused { - // Focus gained + let isNowEditing = newValue == .task(taskID) + if isNowEditing && !isCurrentlyEditing { isCurrentlyEditing = true onStartEdit(taskID) - } else if isCurrentlyEditing { - // Focus lost - check if it was via Return key + } else if !isNowEditing && isCurrentlyEditing { isCurrentlyEditing = false - onEndEdit(taskID, submittedViaReturn) - submittedViaReturn = false + onEndEdit(taskID, false) } } - .onAppear { - // Initialize editingTitle - editingTitle = task.title - } + .taskSwipeGesture( + isActive: true, + isEditing: isCurrentlyEditing, + isDragging: false, + swipeOffset: $swipeOffset, + swipeDirection: $swipeDirection, + isTriggered: $isSwipeTriggered, + onComplete: { onToggle(task) }, + onDelete: { onDelete(task) } + ) } @ViewBuilder @@ -157,25 +121,8 @@ struct TaskRowView: View { if isSelected { RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(Color.accentColor.opacity(0.2)) + } else { + Color(uiColor: .systemBackground) } } - - private func cutToPasteboard() { - copyToPasteboard() - onDelete(task) - } - - private func copyToPasteboard() { - let text = isEditing ? editingTitle : task.title - guard !text.isEmpty else { return } - UIPasteboard.general.string = text - } - - private func pasteFromPasteboard() { - guard let string = UIPasteboard.general.string else { return } - if isEditing { - editingTitle = string - } - onTitleChange(task, string) - } }