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