← All repositories

listless

A simple list app for Apple platforms

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


commit d2a80337b3b9cdb11e8a8968a5ca74d05b643938
parent 5440590cea2a0f4a76416299f58be67d92b4a362
Author: Michael Camilleri <[email protected]>
Date:   Fri, 13 Feb 2026 17:01:21 +0900

Fix swipe actions in iOS app

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

Diffstat

MListless.xcodeproj/project.pbxproj | 4++--
MListless.xcodeproj/xcshareddata/xcschemes/Listless macOS.xcscheme | 12+++++++++---
MListless/Views/TaskListView.swift | 2+-
MListlessiOS/Views/TaskRowSwipeGesture.swift | 207++++++++++++++++++++-----------------------------------------------------------
MListlessiOS/Views/TaskRowView.swift | 43+++++++++++++++++++++++++++----------------

5 files changed, 91 insertions(+), 177 deletions(-)

diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj

@@ -498,7 +498,7 @@
 				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
 				GCC_WARN_UNUSED_FUNCTION = YES;
 				GCC_WARN_UNUSED_VARIABLE = YES;
-				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+				IPHONEOS_DEPLOYMENT_TARGET = 18.0;
 				MACOSX_DEPLOYMENT_TARGET = 14.0;
 				MTL_ENABLE_DEBUG_INFO = NO;
 				MTL_FAST_MATH = YES;
@@ -579,7 +579,7 @@
 				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
 				GCC_WARN_UNUSED_FUNCTION = YES;
 				GCC_WARN_UNUSED_VARIABLE = YES;
-				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+				IPHONEOS_DEPLOYMENT_TARGET = 18.0;
 				MACOSX_DEPLOYMENT_TARGET = 14.0;
 				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
 				MTL_FAST_MATH = YES;

diff --git a/Listless.xcodeproj/xcshareddata/xcschemes/Listless macOS.xcscheme b/Listless.xcodeproj/xcshareddata/xcschemes/Listless macOS.xcscheme

@@ -1,10 +1,11 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <Scheme
    LastUpgradeVersion = "1430"
-   version = "1.3">
+   version = "1.7">
    <BuildAction
       parallelizeBuildables = "YES"
-      buildImplicitDependencies = "YES">
+      buildImplicitDependencies = "YES"
+      runPostActionsOnFailure = "NO">
       <BuildActionEntries>
          <BuildActionEntry
             buildForTesting = "YES"
@@ -26,7 +27,8 @@
       buildConfiguration = "Debug"
       selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
       selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
-      shouldUseLaunchSchemeArgsEnv = "YES">
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      onlyGenerateCoverageForSpecifiedTargets = "NO">
       <MacroExpansion>
          <BuildableReference
             BuildableIdentifier = "primary"
@@ -59,6 +61,8 @@
             ReferencedContainer = "container:Listless.xcodeproj">
          </BuildableReference>
       </BuildableProductRunnable>
+      <CommandLineArguments>
+      </CommandLineArguments>
    </LaunchAction>
    <ProfileAction
       buildConfiguration = "Release"
@@ -76,6 +80,8 @@
             ReferencedContainer = "container:Listless.xcodeproj">
          </BuildableReference>
       </BuildableProductRunnable>
+      <CommandLineArguments>
+      </CommandLineArguments>
    </ProfileAction>
    <AnalyzeAction
       buildConfiguration = "Debug">

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

@@ -230,7 +230,7 @@ struct TaskListView: View {
 
         if isTaskFocused || selectedTaskID != nil {
             selectedTaskID = nil
-            // Focus repair will set to .scrollView if needed
+            focusedField = nil
         } else {
             createNewTask()
             // Trigger focus resolution by setting to nil

diff --git a/ListlessiOS/Views/TaskRowSwipeGesture.swift b/ListlessiOS/Views/TaskRowSwipeGesture.swift

@@ -51,7 +51,7 @@ struct TaskRowSwipeGesture: ViewModifier {
     private let offsetDamping: CGFloat = 0.9  // Damping factor for responsive feel
 
     func body(content: Content) -> some View {
-        return ZStack(alignment: .leading) {
+        ZStack(alignment: .leading) {
             // Background stays in place
             swipeBackground
                 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
@@ -62,23 +62,21 @@ struct TaskRowSwipeGesture: ViewModifier {
                 .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
-                }
         }
+        .gesture(
+            SwipePanGesture(
+                onChanged: { translation in
+                    guard isActive, !isEditing, !isDragging else { return }
+                    handleDragChanged(
+                        horizontalTranslation: translation.x,
+                        verticalTranslation: abs(translation.y)
+                    )
+                },
+                onEnded: {
+                    handleDragEnded()
+                }
+            )
+        )
         .onDisappear {
             resetSwipeState()
         }
@@ -91,14 +89,16 @@ struct TaskRowSwipeGesture: ViewModifier {
             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)))
+            Color.red.opacity(backgroundOpacity(offset: swipeOffset))
+                .overlay {
+                    HStack {
+                        Spacer()
+                        Image(systemName: "trash.fill")
+                            .font(.system(size: 24))
+                            .foregroundStyle(.white)
+                            .padding(.trailing, 20)
+                    }
+                }
         }
     }
 
@@ -181,146 +181,42 @@ struct TaskRowSwipeGesture: ViewModifier {
     }
 }
 
-private struct SwipePanGestureInstaller: UIViewRepresentable {
-    let isEnabled: Bool
+// MARK: - UIKit Pan Gesture via UIGestureRecognizerRepresentable
+
+/// A UIPanGestureRecognizer bridged into SwiftUI. Each row gets its own
+/// recognizer; SwiftUI manages the lifecycle automatically — no manual
+/// UIView host-finding or marker-based hit-testing needed.
+private struct SwipePanGesture: UIGestureRecognizerRepresentable {
     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)
+    func makeUIGestureRecognizer(context: Context) -> UIPanGestureRecognizer {
+        let pan = UIPanGestureRecognizer()
+        pan.cancelsTouchesInView = false
+        pan.delaysTouchesBegan = false
+        pan.maximumNumberOfTouches = 1
+        pan.delegate = context.coordinator
+        return pan
     }
 
-    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
+    func handleUIGestureRecognizerAction(
+        _ recognizer: UIPanGestureRecognizer, context: Context
+    ) {
+        switch recognizer.state {
+        case .began, .changed:
+            onChanged(recognizer.translation(in: recognizer.view))
+        case .ended, .cancelled, .failed:
+            onEnded()
+        default:
+            break
         }
-
-        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
-        }
+    func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator {
+        Coordinator()
     }
 
-        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)
-        }
-
+    final class Coordinator: NSObject, UIGestureRecognizerDelegate {
         func gestureRecognizer(
             _ gestureRecognizer: UIGestureRecognizer,
             shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
@@ -329,3 +225,4 @@ private struct SwipePanGestureInstaller: UIViewRepresentable {
         }
     }
 }
+

diff --git a/ListlessiOS/Views/TaskRowView.swift b/ListlessiOS/Views/TaskRowView.swift

@@ -67,20 +67,32 @@ struct TaskRowView: View {
                 .strikethrough(task.isCompleted, color: .secondary)
                 .disabled(task.isCompleted)
                 .frame(maxWidth: .infinity, alignment: .leading)
+                .onSubmit {
+                    isCurrentlyEditing = false
+                    onEndEdit(taskID, true)
+                }
         }
         .padding(.vertical, 8)
         .padding(.horizontal, 16)
         .frame(maxWidth: .infinity, alignment: .leading)
         .contentShape(Rectangle())
-        .simultaneousGesture(
-            TapGesture()
-                .onEnded {
-                    onSelect(taskID)
-                    if !task.isCompleted {
-                        focusedField = .task(taskID)
-                    }
-                }
-        )
+        .onTapGesture {
+            // .onTapGesture (not .simultaneousGesture) lets the child Button suppress this
+            // gesture for its own hit area, so circle button taps don't also fire here.
+            // If tapping a completed row while another row is being edited, preserve
+            // the current focus/selection.
+            if task.isCompleted,
+               let field = focusedField,
+               case .task(let id) = field,
+               id != taskID
+            {
+                return
+            }
+            onSelect(taskID)
+            if !task.isCompleted {
+                focusedField = .task(taskID)
+            }
+        }
         .background(selectionBackground)
         .onAppear {
             editingTitle = task.title
@@ -116,13 +128,12 @@ struct TaskRowView: View {
         )
     }
 
-    @ViewBuilder
     private var selectionBackground: some View {
-        if isSelected {
-            RoundedRectangle(cornerRadius: 6, style: .continuous)
-                .fill(Color.accentColor.opacity(0.2))
-        } else {
-            Color(uiColor: .systemBackground)
-        }
+        Color(uiColor: .systemBackground)
+            .overlay {
+                if isSelected {
+                    Color.accentColor.opacity(0.2)
+                }
+            }
     }
 }