listless

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

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