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