← All repositories

listless

A simple list app for Apple platforms

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


commit b988b41f1334626252b1c366355685c6bd16c3d8
parent 30f6bcb307a09d3c09303a59ac3e1720a35fa6f2
Author: Michael Camilleri <[email protected]>
Date:   Sat, 21 Mar 2026 06:09:02 +0900

Improve gestures in iOS version

This commit improves certain gestures in the iOS version, namely
creation gestures now cause the instructional 'pull to create' message
to hide and a cycle that occurred during drag reordering has been
eliminated. The elimination required the use of a coordinator and is a
bit ugly.

Co-Authored-By: Claude 4.6 Opus <[email protected]>

Diffstat

MAGENTS.md | 6+++---
MListlessiOS/Extensions/TaskListView+Drag.swift | 8++++----
MListlessiOS/Extensions/TaskListView+PullToClear.swift | 4++--
MListlessiOS/Helpers/TappableTextField.swift | 34++++++++++++++++++++++++++++++++--
MListlessiOS/Helpers/TaskRowDragGesture.swift | 11+++++++++++
MListlessiOS/Helpers/TaskRowSwipeGesture.swift | 13++++++++-----
MListlessiOS/Views/TaskListView.swift | 63++++++++++++++++++++++++++++++++++-----------------------------
MListlessiOS/Views/TaskRowView.swift | 1+

8 files changed, 95 insertions(+), 45 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md

@@ -109,14 +109,14 @@ Listless is a to-do list app for Apple platforms. It is intended to run on iPhon
 - **Selection on iOS/iPadOS is intentionally limited**: iOS supports single-task cursor navigation via arrow keys (for marking complete/incomplete) but does not support multi-select, Select All (Cmd+A), or Cmd+Click toggling. Full selection semantics (range select, multi-select, select all) are macOS-only. Do not add selection features to the iOS target.
 
 ## SwiftUI Implementation Notes
-- **TaskListView architecture**: Declared separately per platform (`ListlessiOS/Views/TaskListView.swift`, `ListlessMac/Views/TaskListView.swift`, `ListlessWatch/Views/TaskListView.swift`). iOS and macOS conform to `TaskListViewProtocol`; watchOS is standalone (does not use the protocol or shared extensions). State is grouped by concern: `fState` (focus), `iState` (interaction), `tState` (task/view-local). Shared logic lives in `TaskListView+Logic.swift` as an extension on `TaskListViewProtocol`. Because `private` is file-scoped, stored properties accessed from extensions must be `internal`. Platform-specific extensions that would return `EmptyView()` on the other platform are simply omitted.
+- **TaskListView architecture**: Declared separately per platform (`ListlessiOS/Views/TaskListView.swift`, `ListlessMac/Views/TaskListView.swift`, `ListlessWatch/Views/TaskListView.swift`). iOS and macOS conform to `TaskListViewProtocol`; watchOS is standalone (does not use the protocol or shared extensions). State is grouped by concern: `fState` (focus), `iState` (interaction), `pState` (pull gestures, iOS only), `isDragging` (iOS only, separate `@State` to avoid dirtying `iState`), and `layoutStorage` (non-reactive `LayoutStorage` class holding `rowFrames` and `contentBottomY` — a plain class under `@State` so SwiftUI holds a stable reference without tracking field mutations). macOS uses `tState` (task/view-local) instead of `pState`/`isDragging`/`layoutStorage`. Shared logic lives in `TaskListView+Logic.swift` as an extension on `TaskListViewProtocol`. Because `private` is file-scoped, stored properties accessed from extensions must be `internal`. Platform-specific extensions that would return `EmptyView()` on the other platform are simply omitted.
 - **Selection pattern**: Parent owns `@State var selectedTaskID`; children receive `isSelected: Bool` + `onSelect: () -> Void` callback (avoids SwiftUI ForEach update issues with @Binding). macOS supports Cmd+Click to toggle individual items in/out of the selection, creating discontinuous selections tracked via `FocusStateData.inactiveSelections`. Shift+Arrow after Cmd+Click preserves inactive items and merges them when the active range becomes adjacent. Read modifiers via `NSApp.currentEvent?.modifierFlags` (not the `NSEvent.modifierFlags` class property) so CGEvent-based UI tests can set modifier flags on synthesised mouse events.
 - **Focus management**: Single `@FocusState` enum (`FocusField` in `TaskListTypes.swift`) with `.task(UUID)` and `.scrollView` cases. Never use multiple @FocusState variables for related focus. Keyboard handlers return `.ignored` when wrong focus state.
   - **iOS focus cleanup**: On iOS, always dismiss focus by setting `focusedField = nil`, never directly to `.scrollView`. The `onChange(of: focusedFieldBinding)` handler intercepts `nil` to run cleanup (e.g. `deleteIfEmpty` for empty tasks) before redirecting to `.scrollView`. Skipping `nil` desyncs SwiftUI focus state from UIKit first responder and can cause crashes.
   - **Pending focus**: Set both `pendingFocus` and `focusedField` in `createNewTask()`. Do NOT resolve in `.onAppear` (race conditions). Clear `pendingFocus` in `startEditing()`. Guard `deleteIfEmpty()` against `pendingFocus` matches.
   - **macOS focus gating via `WindowCoordinator.allowedFocusTarget`**: When Return/Escape ends editing via `makeFirstResponder(nil)`, SwiftUI processes the `@FocusState` change asynchronously. During reconciliation it can traverse the view hierarchy and make the first `NSTextField` become first responder, overriding the `.scrollView` assignment. The per-window `allowedFocusTarget` property on `WindowCoordinator` prevents this: `doCommandBy` sets it to `.scrollView` before `makeFirstResponder(nil)`, and `ClickableNSTextField.acceptsFirstResponder` checks it — rejecting focus unless the field matches the allowed target. Each `ClickableNSTextField` carries a `taskID` for this comparison. The gate is cleared in `onChange(of: focusedFieldBinding)` once `newValue` is non-nil (reconciliation complete). When `pendingFocus` is set (e.g. revealing a draft row), the gate is updated to allow that specific target, and `ClickableNSTextField.viewDidMoveToWindow()` claims focus when the view enters the window hierarchy (handles the case where the NSView doesn't exist yet when focus is requested).
-- **Text editing**: iOS uses `TappableTextField` (UIViewRepresentable wrapping `UITextView`); macOS uses `ClickableTextField` (NSViewRepresentable wrapping `NSTextField`). Both use delegate/coordinator patterns to bridge to SwiftUI. Key gotcha: `onEditingChanged` callbacks from UIKit may arrive during a SwiftUI update pass — defer via `DispatchQueue.main.async`.
-- **Drag-and-drop**: Both platforms maintain `visualOrder` during drag and commit via `store.moveTask()` on release. macOS uses `onDrag` + `dropDestination`; iOS uses `LongPressGesture.sequenced(before: DragGesture)` via `.simultaneousGesture()`.
+- **Text editing**: iOS uses `TappableTextField` (UIViewRepresentable wrapping `UITextView`); macOS uses `ClickableTextField` (NSViewRepresentable wrapping `NSTextField`). Both use delegate/coordinator patterns to bridge to SwiftUI. Key gotcha: `onEditingChanged` callbacks from UIKit may arrive during a SwiftUI update pass — defer via `DispatchQueue.main.async`. On iOS, `TappableTextField` defers `isDragging`-driven changes to `isEditable`/`isSelectable` via the coordinator's `setDragging(_:)` method (called from `Task { @MainActor in }` in `updateUIView`) — setting these properties synchronously in `updateUIView` causes UITextView to call `invalidateIntrinsicContentSize()`, creating a layout-to-state backward edge that triggers AttributeGraph cycle warnings.
+- **Drag-and-drop**: Both platforms maintain `visualOrder` during drag and commit via `store.moveTask()` on release. macOS uses `onDrag` + `dropDestination`; iOS uses `UIGestureRecognizerRepresentable` (`TaskRowDragGesture`) with a long-press recognizer. The drag gesture is gated so it won't activate on a focused (editing) row — prevents the drag from stealing touches from the text cursor/loupe. The gesture's delegate uses `shouldBeRequiredToFailBy` to make UITextView gestures wait for the drag to fail, preventing the loupe from appearing during drag-reorder. Swipe gestures (`TaskRowSwipeGesture`) are also disabled on the focused row via an `isEditing` parameter, so horizontal swiping doesn't interfere with text selection.
 - **Keyboard navigation**: macOS uses dictionary-based keybindings in `KeyboardNavigationModifier.swift`. iOS uses `KeyCommandBridge` (UIViewRepresentable) because iPadOS `@FocusState` silently fails with hardware keyboards — the iOS `body` does **not** use `.focusable()`, `.focused(equals: .scrollView)`, or `.keyboardNavigation()`.
 - Avoid `Spacer` inside `ScrollView` (causes unwanted scrollbar).
 - **Escape hatches** (`GeometryReader`, `PreferenceKey`, `UIViewRepresentable`/`NSViewRepresentable`): only after exhausting SwiftUI-native alternatives (`.frame`, `.overlay`, `.background`, `alignmentGuide`).

diff --git a/ListlessiOS/Extensions/TaskListView+Drag.swift b/ListlessiOS/Extensions/TaskListView+Drag.swift

@@ -5,7 +5,7 @@ extension TaskListView {
         guard let draggedID = draggedTaskID,
               var order = visualOrder,
               let currentIndex = order.firstIndex(of: draggedID),
-              let draggedFrame = iState.rowFrames[draggedID] else { return }
+              let draggedFrame = layoutStorage.rowFrames[draggedID] else { return }
 
         let threshold = draggedFrame.height * 0.2
 
@@ -32,17 +32,17 @@ extension TaskListView {
               let order = visualOrder,
               let finalIndex = order.firstIndex(of: draggedID) else {
             clearDragState()
-            iState.isDragging = false
+            isDragging = false
             return
         }
         do {
             try store.moveTask(taskID: draggedID, toIndex: finalIndex)
             clearDragState()
-            iState.isDragging = false
+            isDragging = false
         } catch {
             withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
                 clearDragState()
-                iState.isDragging = false
+                isDragging = false
             }
             presentStoreError(error)
         }

diff --git a/ListlessiOS/Extensions/TaskListView+PullToClear.swift b/ListlessiOS/Extensions/TaskListView+PullToClear.swift

@@ -2,8 +2,8 @@ import SwiftUI
 
 extension TaskListView {
     @ViewBuilder var pullToClearIndicatorRow: some View {
-        if iState.pullUpOffset > 0 && !completedTasks.isEmpty {
-            PullToClearIndicator(pullOffset: iState.pullUpOffset)
+        if pState.pullUpOffset > 0 && !completedTasks.isEmpty {
+            PullToClearIndicator(pullOffset: pState.pullUpOffset)
         }
     }
 }

diff --git a/ListlessiOS/Helpers/TappableTextField.swift b/ListlessiOS/Helpers/TappableTextField.swift

@@ -38,6 +38,7 @@ struct TappableTextField: UIViewRepresentable {
             placeholder.topAnchor.constraint(equalTo: textView.topAnchor),
         ])
 
+        context.coordinator.textView = textView
         return textView
     }
 
@@ -57,8 +58,20 @@ struct TappableTextField: UIViewRepresentable {
             textView.reloadInputViews()
         }
         textView.accessibilityIdentifier = uiAccessibilityIdentifier
-        textView.isEditable = !isCompleted && !isDragging
-        textView.isSelectable = !isCompleted && !isDragging
+        textView.isEditable = !isCompleted
+        textView.isSelectable = !isCompleted
+        // Defer isDragging updates to break an AttributeGraph cycle: setting
+        // isEditable/isSelectable during updateUIView causes UITextView to
+        // invalidate its intrinsic content size, creating a layout-to-state
+        // backward edge that SwiftUI's dependency graph flags as a cycle.
+        // Deferring moves the UIView mutation outside of the evaluation pass.
+        let dragging = isDragging
+        if dragging != context.coordinator.isDragging {
+            let coordinator = context.coordinator
+            Task { @MainActor in
+                coordinator.setDragging(dragging)
+            }
+        }
         if let placeholder = textView.viewWithTag(100) as? UILabel {
             placeholder.isHidden = !text.isEmpty
         }
@@ -93,6 +106,8 @@ struct TappableTextField: UIViewRepresentable {
         let onEditingChanged: (Bool, _ shouldCreateNewTask: Bool) -> Void
         let onContentChange: ((String) -> Void)?
         var returnKeyPressed: Bool = false
+        weak var textView: UITextView?
+        private(set) var isDragging = false
 
         init(
             text: Binding<String>,
@@ -104,6 +119,21 @@ struct TappableTextField: UIViewRepresentable {
             self.onContentChange = onContentChange
         }
 
+        func setDragging(_ dragging: Bool) {
+            guard dragging != isDragging else { return }
+            isDragging = dragging
+            guard let textView else { return }
+            if dragging {
+                textView.isEditable = false
+                textView.isSelectable = false
+            } else {
+                // Restore based on current completion state — updateUIView
+                // will also set these on the next SwiftUI evaluation pass.
+                textView.isEditable = true
+                textView.isSelectable = true
+            }
+        }
+
         func textViewDidChange(_ textView: UITextView) {
             text = textView.text
             onContentChange?(textView.text)

diff --git a/ListlessiOS/Helpers/TaskRowDragGesture.swift b/ListlessiOS/Helpers/TaskRowDragGesture.swift

@@ -119,5 +119,16 @@ private struct SimultaneousDragGesture: UIGestureRecognizerRepresentable {
         ) -> Bool {
             true
         }
+
+        func gestureRecognizer(
+            _ gestureRecognizer: UIGestureRecognizer,
+            shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer
+        ) -> Bool {
+            // Make text view gestures (loupe, selection) wait for the drag
+            // gesture to fail before they can fire. If the drag succeeds,
+            // the text view gesture is cancelled — preventing the loupe
+            // from appearing during drag.
+            otherGestureRecognizer.view is UITextView
+        }
     }
 }

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

@@ -3,6 +3,7 @@ import SwiftUI
 extension View {
     func taskSwipeGesture(
         isDragging: Binding<Bool>,
+        isEditing: Bool,
         isSwiping: Binding<Bool>,
         swipeOffset: Binding<CGFloat>,
         swipeDirection: Binding<TaskRowSwipeGesture.SwipeDirection>,
@@ -14,6 +15,7 @@ extension View {
         self.modifier(
             TaskRowSwipeGesture(
                 isDragging: isDragging,
+                isEditing: isEditing,
                 isSwiping: isSwiping,
                 swipeOffset: swipeOffset,
                 swipeDirection: swipeDirection,
@@ -27,6 +29,7 @@ extension View {
 
 struct TaskRowSwipeGesture: ViewModifier {
     @Binding var isDragging: Bool
+    let isEditing: Bool
     @Binding var isSwiping: Bool
     @Binding var swipeOffset: CGFloat
     @Binding var swipeDirection: SwipeDirection
@@ -68,9 +71,9 @@ struct TaskRowSwipeGesture: ViewModifier {
                 .contentShape(Rectangle())
         }
         .applySwipeGesture(
-            isDragging: isDragging,
+            isDisabled: isDragging || isEditing,
             onChanged: { translation in
-                guard !isDragging else { return }
+                guard !isDragging, !isEditing else { return }
                 updateActiveGestureAxis(
                     horizontalTranslation: translation.width,
                     verticalTranslation: abs(translation.height)
@@ -201,18 +204,18 @@ struct TaskRowSwipeGesture: ViewModifier {
 /// `shouldRecognizeSimultaneouslyWith: true` so scrolling is preserved.
 private extension View {
     func applySwipeGesture(
-        isDragging: Bool,
+        isDisabled: Bool,
         onChanged: @escaping (CGSize) -> Void,
         onEnded: @escaping () -> Void
     ) -> some View {
         self.gesture(
             SimultaneousSwipeGesture(
                 onChanged: { _, translation in
-                    guard !isDragging else { return }
+                    guard !isDisabled else { return }
                     onChanged(translation)
                 },
                 onEnded: { _, _ in
-                    guard !isDragging else { return }
+                    guard !isDisabled else { return }
                     onEnded()
                 }
             )

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

@@ -2,23 +2,28 @@ import SwiftUI
 import UIKit
 
 struct TaskListView: View, TaskListViewProtocol {
+    class LayoutStorage {
+        var rowFrames: [UUID: CGRect] = [:]
+        var contentBottomY: CGFloat = 0
+    }
+
     struct InteractionStateData {
         var dragState: DragState = .idle
-        var pullToCreate = PullToCreateState()
-        var pullUpOffset: CGFloat = 0
-        var isDragging: Bool = false
         var isShowingSyncDiagnostics = false
         var isShowingSettings = false
         var clearingTaskIDs: Set<UUID> = []
-        var rowFrames: [UUID: CGRect] = [:]
         var undoToast: UndoToastData? = nil
         var isSwiping: Bool = false
         var draftPlacement: DraftTaskPlacement?
         var draftTitle: String = ""
-        var contentBottomY: CGFloat = 0
         var fetchWorkaround: Int = 0
     }
 
+    struct PullStateData {
+        var pullToCreate = PullToCreateState()
+        var pullUpOffset: CGFloat = 0
+    }
+
     @AppStorage("headingText") var headingText = "Items"
     @AppStorage("colorTheme") private var colorThemeRaw = 0
     private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .original }
@@ -35,6 +40,9 @@ struct TaskListView: View, TaskListViewProtocol {
     @FocusState private var focusedFieldBinding: FocusField?
     @State var fState = FocusStateData()
     @State var iState = InteractionStateData()
+    @State var pState = PullStateData()
+    @State var isDragging = false
+    @State var layoutStorage = LayoutStorage()
 
     var focusedField: FocusField? {
         get { fState.focusedField }
@@ -75,23 +83,20 @@ struct TaskListView: View, TaskListViewProtocol {
     }
 
     private var isDraggingStateBinding: Binding<Bool> {
-        Binding(
-            get: { iState.isDragging },
-            set: { iState.isDragging = $0 }
-        )
+        $isDragging
     }
 
     private var pullToCreateStateBinding: Binding<PullToCreateState> {
         Binding(
-            get: { iState.pullToCreate },
-            set: { iState.pullToCreate = $0 }
+            get: { pState.pullToCreate },
+            set: { pState.pullToCreate = $0 }
         )
     }
 
     private var pullUpOffsetStateBinding: Binding<CGFloat> {
         Binding(
-            get: { iState.pullUpOffset },
-            set: { iState.pullUpOffset = $0 }
+            get: { pState.pullUpOffset },
+            set: { pState.pullUpOffset = $0 }
         )
     }
 
@@ -175,10 +180,10 @@ struct TaskListView: View, TaskListViewProtocol {
 
             guard placement == .prepend else { return }
 
-            var state = iState.pullToCreate
+            var state = pState.pullToCreate
             state.isInsertionPending = false
             state.indicatorOffset = 0
-            iState.pullToCreate = state
+            pState.pullToCreate = state
         }
 
         if placement == .prepend, !hasTitle {
@@ -201,7 +206,7 @@ struct TaskListView: View, TaskListViewProtocol {
     }
 
     func didStartDrag() {
-        iState.isDragging = true
+        isDragging = true
         let generator = UIImpactFeedbackGenerator(style: .medium)
         generator.impactOccurred()
     }
@@ -216,20 +221,20 @@ struct TaskListView: View, TaskListViewProtocol {
 
     private func dragScaleEffect(for taskID: UUID) -> CGFloat {
         let liftPoints: CGFloat = 20
-        guard let width = iState.rowFrames[taskID]?.width, width > 0 else { return 1.05 }
+        guard let width = layoutStorage.rowFrames[taskID]?.width, width > 0 else { return 1.05 }
         return (width + liftPoints) / width
     }
 
     private var pullToCreateRevealHeight: CGFloat {
         min(
-            iState.pullToCreate.indicatorDisplayOffset(threshold: pullCreateThreshold),
+            pState.pullToCreate.indicatorDisplayOffset(threshold: pullCreateThreshold),
             PullToCreateIndicator.indicatorHeight
         )
     }
 
     private var pullToCreateGap: CGFloat {
-        guard iState.pullToCreate.shouldShowIndicator, !isPrependDraftVisible else { return 0 }
-        let exposedPull = iState.pullToCreate.indicatorDisplayOffset(threshold: pullCreateThreshold)
+        guard pState.pullToCreate.shouldShowIndicator, !isPrependDraftVisible else { return 0 }
+        let exposedPull = pState.pullToCreate.indicatorDisplayOffset(threshold: pullCreateThreshold)
         return min(
             vStackSpacing,
             max(0, exposedPull - PullToCreateIndicator.indicatorHeight)
@@ -237,7 +242,7 @@ struct TaskListView: View, TaskListViewProtocol {
     }
 
     private var pullToCreateRowOverlap: CGFloat {
-        guard iState.pullToCreate.shouldShowIndicator, !isPrependDraftVisible else {
+        guard pState.pullToCreate.shouldShowIndicator, !isPrependDraftVisible else {
             return 0
         }
         return PullToCreateIndicator.indicatorHeight - pullToCreateRevealHeight
@@ -247,12 +252,12 @@ struct TaskListView: View, TaskListViewProtocol {
     /// The phantom's UITextView is created while the indicator is visible
     /// (during the pull), so it's ready when the user releases.
     @ViewBuilder var pullToCreateIndicatorRow: some View {
-        let showIndicator = iState.pullToCreate.shouldShowIndicator
+        let showIndicator = pState.pullToCreate.shouldShowIndicator
         let showPhantom = isPrependDraftVisible
         if showIndicator || showPhantom {
             ZStack(alignment: .topLeading) {
                 PullToCreateIndicator(
-                    pullOffset: iState.pullToCreate.indicatorDisplayOffset(
+                    pullOffset: pState.pullToCreate.indicatorDisplayOffset(
                         threshold: pullCreateThreshold
                     ),
                     threshold: pullCreateThreshold,
@@ -366,7 +371,7 @@ struct TaskListView: View, TaskListViewProtocol {
             )
             .zIndex(draggedTaskID == taskID ? 2 : 1)
             .taskDragGesture(
-                isActive: !task.isCompleted,
+                isActive: !task.isCompleted && focusedFieldBinding != .task(taskID),
                 taskID: taskID,
                 onDragStart: { startDrag(taskID: taskID) },
                 onDragChanged: { point in handleIOSDragChanged(taskID: taskID, point: point) },
@@ -375,7 +380,7 @@ struct TaskListView: View, TaskListViewProtocol {
             .onGeometryChange(for: CGRect.self) { proxy in
                 proxy.frame(in: .global)
             } action: { frame in
-                iState.rowFrames[taskID] = frame
+                layoutStorage.rowFrames[taskID] = frame
             }
             .id(taskID)
         }
@@ -406,7 +411,7 @@ struct TaskListView: View, TaskListViewProtocol {
         taskScrollView
             .simultaneousGesture(
                 SpatialTapGesture(coordinateSpace: .global).onEnded { value in
-                    guard value.location.y > iState.contentBottomY else { return }
+                    guard value.location.y > layoutStorage.contentBottomY else { return }
                     handleBackgroundTap()
                 }
             )
@@ -483,7 +488,7 @@ struct TaskListView: View, TaskListViewProtocol {
                 }
                 .padding(
                     .bottom,
-                    (iState.pullToCreate.shouldShowIndicator && !isPrependDraftVisible)
+                    (pState.pullToCreate.shouldShowIndicator && !isPrependDraftVisible)
                         ? (pullToCreateGap - vStackSpacing) : 0
                 )
                 taskRows
@@ -493,11 +498,11 @@ struct TaskListView: View, TaskListViewProtocol {
             .onGeometryChange(for: CGFloat.self) {
                 $0.frame(in: .global).maxY
             } action: {
-                iState.contentBottomY = $0
+                layoutStorage.contentBottomY = $0
             }
             .padding(.trailing, 16)
             .padding(.vertical, 12)
-            .offset(y: -iState.pullToCreate.pullOffset)
+            .offset(y: -pState.pullToCreate.pullOffset)
             .onChange(of: focusedFieldBinding) { oldValue, newValue in
                 fState.focusedField = newValue
                 handleFocusChange(from: oldValue, to: newValue)

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

@@ -155,6 +155,7 @@ struct TaskRowView: View {
         }
         .taskSwipeGesture(
             isDragging: $isDragging,
+            isEditing: focusedField == .task(taskID),
             isSwiping: $isSwiping,
             swipeOffset: $swipeOffset,
             swipeDirection: $swipeDirection,