listless

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

ItemListView.swift (25169B)


      1 import SwiftUI
      2 import UIKit
      3 
      4 struct ItemListView: View, ItemListViewProtocol {
      5     class LayoutStorage {
      6         var draggedRowWidth: CGFloat = 0
      7         var draggedRowFrame: CGRect = .zero
      8         var contentBottomY: CGFloat = 0
      9         var lastAutoScrollTime: CFTimeInterval = 0
     10     }
     11 
     12     struct InteractionStateData {
     13         var dragState: DragState = .idle
     14         var draftCount: Int = 0
     15         var isShowingSyncDiagnostics = false
     16         var isShowingSettings = false
     17         var clearingItemIDs: Set<UUID> = []
     18         var undoToast: UndoToastData? = nil
     19         var isSwiping: Bool = false
     20         var isShowingRenameAlert = false
     21         var isShowingDeleteAllAlert = false
     22         var renameText: String = ""
     23         var draftPlacement: DraftItemPlacement?
     24         var draftTitle: String = ""
     25         var fetchWorkaround: Int = 0
     26 
     27         var isShowingOverlay: Bool {
     28             isShowingSettings || isShowingSyncDiagnostics || isShowingRenameAlert
     29         }
     30     }
     31 
     32     struct PullStateData {
     33         var pullToCreate = PullToCreateState()
     34         var pullUpOffset: CGFloat = 0
     35 
     36         var headerHeight: CGFloat = 60
     37     }
     38 
     39     @AppStorage("listName") var listName = "Items"
     40     @AppStorage("colorTheme") private var colorThemeRaw = 0
     41     @AppStorage("hapticsEnabled") private var hapticsEnabled = true
     42     @AppStorage("showFPSOverlay") private var showFPSOverlay = false
     43     private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .pilbara }
     44     @Environment(\.undoManager) var undoManager
     45     @Environment(\.managedObjectContext) var managedObjectContext
     46 
     47     let store: ItemStore
     48     @ObservedObject var syncMonitor: CloudKitSyncMonitor
     49     @FetchRequest(
     50         sortDescriptors: [],
     51         animation: .default
     52     )
     53     var items: FetchedResults<ItemEntity>
     54     @FocusState private var focusedFieldBinding: FocusField?
     55     @State var fState = FocusStateData()
     56     @State var iState = InteractionStateData()
     57     @State var pState = PullStateData()
     58     @State var isDragging = false
     59     @State var layoutStorage = LayoutStorage()
     60     @State var scrollPosition = ScrollPosition()
     61     @State private var showTutorialHint = false
     62 
     63     var focusedField: FocusField? {
     64         get { fState.focusedField }
     65         nonmutating set {
     66             fState.focusedField = newValue
     67             focusedFieldBinding = newValue
     68         }
     69     }
     70 
     71     var dragState: DragState {
     72         get { iState.dragState }
     73         nonmutating set { iState.dragState = newValue }
     74     }
     75 
     76     var draftPlacement: DraftItemPlacement? {
     77         get { iState.draftPlacement }
     78         nonmutating set {
     79             if newValue != nil, iState.draftPlacement == nil {
     80                 iState.draftCount += 1
     81             }
     82             iState.draftPlacement = newValue
     83         }
     84     }
     85 
     86     var draftTitle: String {
     87         get { iState.draftTitle }
     88         nonmutating set { iState.draftTitle = newValue }
     89     }
     90 
     91     private var isPrependDraftVisible: Bool {
     92         draftPlacement == .prepend
     93     }
     94 
     95     private var isAppendDraftVisible: Bool {
     96         draftPlacement == .append
     97     }
     98 
     99 
    100     private var selectedIndex: Int? {
    101         guard let currentID = fState.selectedItemID else { return nil }
    102         return activeItems.firstIndex(where: { $0.id == currentID })
    103     }
    104 
    105     private var canMoveSelectionUp: Bool {
    106         guard focusedField == .scrollView else { return false }
    107         guard let index = selectedIndex else { return false }
    108         return index > 0
    109     }
    110 
    111     private var canMoveSelectionDown: Bool {
    112         guard focusedField == .scrollView else { return false }
    113         guard let index = selectedIndex else { return false }
    114         return index < activeItems.count - 1
    115     }
    116 
    117     private struct MenuState: Equatable {
    118         let selectedItemID: UUID?
    119         let isScrollViewFocused: Bool
    120         let activeItemCount: Int
    121         let completedItemCount: Int
    122         let selectedIndex: Int?
    123     }
    124 
    125     private var menuCoordinatorTrigger: MenuState {
    126         MenuState(
    127             selectedItemID: fState.selectedItemID,
    128             isScrollViewFocused: focusedField == .scrollView,
    129             activeItemCount: activeItems.count,
    130             completedItemCount: completedItems.count,
    131             selectedIndex: selectedIndex
    132         )
    133     }
    134 
    135     func updateMenuCoordinator() {
    136         let coord = IOSMenuCoordinator.shared
    137         coord.newItem = { createNewItem() }
    138         coord.deleteItem = { _ = deleteSelectedItemWithUndo() }
    139         coord.moveUp = { moveSelectedItemUp() }
    140         coord.moveDown = { moveSelectedItemDown() }
    141         coord.markCompleted = { markSelectedItemCompleted() }
    142         coord.navigatePageUp = { _ = navigatePageUp() }
    143         coord.navigatePageDown = { _ = navigatePageDown() }
    144         coord.navigateToFirst = { _ = navigateToFirst() }
    145         coord.navigateToLast = { _ = navigateToLast() }
    146         let inNavMode = focusedField == .scrollView
    147         coord.canDelete = fState.selectedItemID != nil && inNavMode
    148         coord.canMoveUp = canMoveSelectionUp
    149         coord.canMoveDown = canMoveSelectionDown
    150         coord.canMarkCompleted = fState.selectedItemID != nil && inNavMode
    151         coord.markCompletedTitle = completedItems.contains(where: { $0.id == fState.selectedItemID })
    152             ? "Mark as Incomplete" : "Mark as Complete"
    153     }
    154 
    155     var vStackSpacing: CGFloat { 0 }
    156     var rowGap: CGFloat { 12 }
    157     var pullCreateThreshold: CGFloat { 70 }
    158     var flickThreshold: CGFloat { 500 }
    159     var isCompletelyEmpty: Bool { activeItems.isEmpty && completedItems.isEmpty }
    160 
    161     var onFinishTutorial: (() -> Void)?
    162     var isTutorial: Bool { onFinishTutorial != nil }
    163 
    164     init(store: ItemStore, syncMonitor: CloudKitSyncMonitor, onFinishTutorial: (() -> Void)? = nil) {
    165         self.store = store
    166         self.syncMonitor = syncMonitor
    167         self.onFinishTutorial = onFinishTutorial
    168     }
    169 
    170     func revealDraftItemUI(at placement: DraftItemPlacement, animated: Bool) {
    171         let itemID = draftID(for: placement)
    172         if animated {
    173             withAnimation { draftPlacement = placement }
    174         } else {
    175             draftPlacement = placement
    176         }
    177         var transaction = Transaction()
    178         transaction.disablesAnimations = true
    179         withTransaction(transaction) {
    180             fState.pendingFocus = .item(itemID)
    181             focusedField = .item(itemID)
    182             fState.selectedItemID = itemID
    183         }
    184     }
    185 
    186     func clearDraftItemUI(at placement: DraftItemPlacement, hasTitle: Bool) {
    187         let clear: () -> Void = {
    188             if draftPlacement == placement {
    189                 draftPlacement = nil
    190             }
    191             draftTitle = ""
    192             if fState.selectedItemID == draftID(for: placement) {
    193                 fState.selectedItemID = nil
    194             }
    195 
    196             guard placement == .prepend else { return }
    197 
    198             var state = pState.pullToCreate
    199             state.isInsertionPending = false
    200             state.indicatorOffset = 0
    201             pState.pullToCreate = state
    202         }
    203 
    204         if placement == .prepend, !hasTitle {
    205             withAnimation(.spring(response: 0.24, dampingFraction: 1.0)) {
    206                 clear()
    207             }
    208         } else if placement == .prepend {
    209             var transaction = Transaction(animation: nil)
    210             transaction.disablesAnimations = true
    211             withTransaction(transaction) {
    212                 clear()
    213             }
    214         } else if !hasTitle {
    215             withAnimation { clear() }
    216         } else {
    217             clear()
    218         }
    219 
    220         if placement == .prepend || !hasTitle {
    221             focusedField = nil
    222         }
    223     }
    224 
    225     func didStartDrag() {
    226         isDragging = true
    227         if hapticsEnabled {
    228             let generator = UIImpactFeedbackGenerator(style: .light)
    229             generator.impactOccurred()
    230         }
    231     }
    232 
    233     func showSyncDiagnostics() {
    234         iState.isShowingSyncDiagnostics = true
    235     }
    236 
    237     func showSettings() {
    238         iState.isShowingSettings = true
    239     }
    240 
    241     func showRenameAlert() {
    242         iState.renameText = listName
    243         iState.isShowingRenameAlert = true
    244     }
    245 
    246     private func dragScaleEffect() -> CGFloat {
    247         let liftPoints: CGFloat = 20
    248         let width = layoutStorage.draggedRowWidth
    249         guard width > 0 else { return 1.05 }
    250         return (width + liftPoints) / width
    251     }
    252 
    253     /// Combined indicator and phantom entry row sharing the same VStack slot.
    254     /// The phantom's UITextView is created while the indicator is visible
    255     /// (during the pull), so it's ready when the user releases.
    256     @ViewBuilder var pullToCreateIndicatorRow: some View {
    257         let pullOffset = pState.pullToCreate.pullOffset
    258         let indicatorHeight = PullToCreateIndicator.indicatorHeight
    259         let indicatorDisplayOffset = pState.pullToCreate.indicatorDisplayOffset(
    260             threshold: pullCreateThreshold
    261         )
    262         let frameHeight: CGFloat = isPrependDraftVisible
    263             ? 0
    264             : min(pullOffset, indicatorHeight + rowGap)
    265         let opacity: Double = if isPrependDraftVisible || pullOffset <= 0 {
    266             0
    267         } else if displayActiveItems.isEmpty {
    268             min(1, pullOffset / indicatorHeight)
    269         } else {
    270             1
    271         }
    272         PullToCreateIndicator(
    273             pullOffset: max(0, indicatorDisplayOffset),
    274             threshold: pullCreateThreshold
    275         )
    276         .frame(
    277             height: frameHeight,
    278             alignment: .top
    279         )
    280         .offset(
    281             y: displayActiveItems.isEmpty
    282                 ? -indicatorHeight * (1 - min(1, pullOffset / indicatorHeight))
    283                 : 0
    284         )
    285         .opacity(opacity)
    286     }
    287 
    288     /// The draft row content styled to match a item row. Controlled by the
    289     /// ZStack in ``pullToCreateIndicatorRow`` rather than its own visibility.
    290     @ViewBuilder private var draftPrependRow: some View {
    291         DraftRowView(
    292             accentColor: itemColor(
    293                 forIndex: 0, total: max(1, displayActiveItems.count + 1), theme: colorTheme
    294             ),
    295             isSelected: fState.selectedItemID == draftPrependRowID,
    296             draftID: draftPrependRowID,
    297             title: $iState.draftTitle,
    298             onEditingChanged: { editing, _ in
    299                 DispatchQueue.main.async {
    300                     if editing {
    301                         beginDraftItemEditing(.prepend)
    302                     } else {
    303                         commitDraftItem()
    304                     }
    305                 }
    306             },
    307             returnKeyType: .done,
    308             accessibilityIdentifier: "draft-row-prepend",
    309             focusedField: $focusedFieldBinding
    310         )
    311     }
    312 
    313     @ViewBuilder private var draftAppendRow: some View {
    314         if isAppendDraftVisible {
    315             DraftRowView(
    316                 accentColor: itemColor(
    317                     forIndex: displayActiveItems.count,
    318                     total: max(1, displayActiveItems.count + 1),
    319                     theme: colorTheme
    320                 ),
    321                 isSelected: fState.selectedItemID == draftAppendRowID,
    322                 draftID: draftAppendRowID,
    323                 title: $iState.draftTitle,
    324                 onEditingChanged: { editing, shouldCreateNewItem in
    325                     DispatchQueue.main.async {
    326                         if editing {
    327                             beginDraftItemEditing(.append)
    328                         } else {
    329                             commitDraftItem(
    330                                 shouldCreateNewItem: shouldCreateNewItem
    331                             )
    332                         }
    333                     }
    334                 },
    335                 returnKeyType: draftTitle.trimmingCharacters(
    336                     in: .whitespacesAndNewlines
    337                 ).isEmpty ? .done : .next,
    338                 accessibilityIdentifier: "draft-row-append",
    339                 focusedField: $focusedFieldBinding
    340             )
    341             .padding(.bottom, rowGap)
    342             .id(draftAppendRowID)
    343         }
    344     }
    345 
    346     @ViewBuilder private var itemRows: some View {
    347         let _ = iState.fetchWorkaround
    348         let draftOffset = isPrependDraftVisible ? 1 : 0
    349         let draftTotal = draftPlacement != nil ? 1 : 0
    350         ForEach(Array(displayActiveItems.enumerated()), id: \.element.id) { index, item in
    351             let itemID = item.id
    352             ItemRowView(
    353                 item: item,
    354                 itemID: itemID,
    355                 index: index + draftOffset,
    356                 totalItems: displayActiveItems.count + draftTotal,
    357                 isSelected: fState.selectedItemID == itemID,
    358                 isDragging: $isDragging,
    359                 isSwiping: $iState.isSwiping,
    360                 isLastActiveItem: index == displayActiveItems.count - 1,
    361                 focusedField: $focusedFieldBinding,
    362                 onToggle: { handleSwipeComplete($0); withAnimation { iState.fetchWorkaround &+= 1 } },
    363                 onTitleChange: { updateTitle(itemID: $0, title: $1) },
    364                 onDelete: { deleteItemWithUndo(itemID: $0) },
    365                 onSelect: { selectItem($0) },
    366                 onStartEdit: { startEditing($0) },
    367                 onEndEdit: {
    368                     if fState.selectedItemID == $0 {
    369                         fState.selectedItemID = nil
    370                     }
    371                     endEditing($0, shouldCreateNewItem: $1)
    372                 }
    373             )
    374             .scaleEffect(draggedItemID == itemID ? dragScaleEffect() : 1.0)
    375             .shadow(
    376                 color: draggedItemID == itemID ? .black.opacity(0.3) : .clear,
    377                 radius: 12, y: 4
    378             )
    379             .itemDragGesture(
    380                 isActive: !item.isCompleted && focusedFieldBinding != .item(itemID),
    381                 itemID: itemID,
    382                 onDragStart: { width in
    383                     layoutStorage.draggedRowWidth = width
    384                     startDrag(itemID: itemID)
    385                 },
    386                 onDragChanged: { point in
    387                     handleIOSDragChanged(itemID: itemID, point: point)
    388                 },
    389                 onDragEnded: { commitIOSDrag() }
    390             )
    391             .background {
    392                 if draggedItemID == itemID {
    393                     Color.clear
    394                         .onGeometryChange(for: CGRect.self) { proxy in
    395                             proxy.frame(in: .global)
    396                         } action: { frame in
    397                             layoutStorage.draggedRowFrame = frame
    398                         }
    399                 }
    400             }
    401             .padding(.bottom, rowGap)
    402             .zIndex(draggedItemID == itemID ? 2 : 1)
    403             .id(itemID)
    404         }
    405 
    406         draftAppendRow
    407             .zIndex(3)
    408             .overlay(alignment: .top) {
    409                 if showTutorialHint {
    410                     Text("Submit empty to remove")
    411                         .font(.body)
    412                         .foregroundStyle(.black)
    413                         .padding(.horizontal, 20)
    414                         .padding(.vertical, 14)
    415                         .background(
    416                             RoundedRectangle(cornerRadius: 12)
    417                                 .fill(Color(red: 1.0, green: 0.84, blue: 0.04))
    418                         )
    419                         .shadow(color: .black.opacity(0.15), radius: 8, y: 2)
    420                         .offset(y: -56)
    421                         .transition(.move(edge: .bottom).combined(with: .opacity))
    422                 }
    423             }
    424 
    425         ForEach(completedItems) { item in
    426             let itemID = item.id
    427             let isBeingCleared = iState.clearingItemIDs.contains(itemID)
    428             ItemRowView(
    429                 item: item,
    430                 itemID: itemID,
    431                 isSelected: fState.selectedItemID == itemID,
    432                 isSwiping: $iState.isSwiping,
    433                 focusedField: $focusedFieldBinding,
    434                 onToggle: { handleSwipeComplete($0); withAnimation { iState.fetchWorkaround &+= 1 } },
    435                 onTitleChange: { updateTitle(itemID: $0, title: $1) },
    436                 onDelete: { deleteItemWithUndo(itemID: $0) },
    437                 onSelect: { selectItem($0) }
    438             )
    439             .opacity(isBeingCleared ? 0 : 1)
    440             .offset(y: isBeingCleared ? 40 : 0)
    441             .padding(.bottom, rowGap)
    442             .id(itemID)
    443         }
    444     }
    445 
    446     var body: some View {
    447         itemScrollView
    448             .overlay(alignment: .topLeading) {
    449                 if showFPSOverlay {
    450                     FPSOverlay()
    451                         .padding(.top, -16)
    452                         .padding(.leading, 8)
    453                         .allowsHitTesting(false)
    454                 }
    455             }
    456             .simultaneousGesture(
    457                 SpatialTapGesture(coordinateSpace: .global).onEnded { value in
    458                     guard value.location.y > layoutStorage.contentBottomY else { return }
    459                     handleBackgroundTap()
    460                 }
    461             )
    462             .accessibilityIdentifier("item-list-scrollview")
    463             .background {
    464                 let isEditing = if case .item = focusedFieldBinding { true } else { false }
    465                 KeyCommandBridge(
    466                     isActive: !isEditing && !iState.isShowingOverlay,
    467                     onUp: { _ = navigateUp() },
    468                     onDown: { _ = navigateDown() },
    469                     onSpace: { _ = toggleSelectedItem() },
    470                     onReturn: { _ = focusSelectedItem() },
    471                     onDelete: { _ = deleteSelectedItemWithUndo() },
    472                     onHome: { _ = navigateToFirst() },
    473                     onEnd: { _ = navigateToLast() },
    474                     onPageUp: { _ = navigatePageUp() },
    475                     onPageDown: { _ = navigatePageDown() }
    476                 )
    477             }
    478             .onAppear {
    479                 fState.focusedField = .scrollView
    480                 updateMenuCoordinator()
    481                 if ProcessInfo.processInfo.arguments.contains("SCREENSHOT_SHOW_SETTINGS") {
    482                     iState.isShowingSettings = true
    483                 }
    484             }
    485             .onChange(of: menuCoordinatorTrigger) { _, _ in updateMenuCoordinator() }
    486             .onChange(of: undoManager, initial: true) { _, newValue in
    487                 managedObjectContext.undoManager = newValue
    488             }
    489             .toolbar {
    490                 platformToolbar
    491             }
    492             .safeAreaInset(edge: .bottom) {
    493                 syncErrorBanner
    494             }
    495             .overlay(alignment: .bottom) {
    496                 if let toast = iState.undoToast {
    497                     UndoToastView(
    498                         data: toast,
    499                         onUndo: { performUndo() },
    500                         onDismiss: { dismissUndoToast() }
    501                     )
    502                 }
    503             }
    504             .task(id: iState.undoToast?.id) {
    505                 guard iState.undoToast != nil else { return }
    506                 try? await Task.sleep(for: .seconds(7))
    507                 guard !Task.isCancelled else { return }
    508                 dismissUndoToast()
    509             }
    510             .onChange(of: iState.draftPlacement) { _, newValue in
    511                 if isTutorial, newValue == .append {
    512                     withAnimation {
    513                         showTutorialHint = true
    514                     }
    515                 }
    516             }
    517             .task(id: showTutorialHint) {
    518                 guard showTutorialHint else { return }
    519                 try? await Task.sleep(for: .seconds(4))
    520                 guard !Task.isCancelled else { return }
    521                 withAnimation {
    522                     showTutorialHint = false
    523                 }
    524             }
    525             .sheet(isPresented: $iState.isShowingSyncDiagnostics) {
    526                 NavigationStack {
    527                     SyncDiagnosticsView(syncMonitor: syncMonitor)
    528                         .toolbar {
    529                             ToolbarItem(placement: .topBarLeading) {
    530                                 Button("Close") { iState.isShowingSyncDiagnostics = false }
    531                             }
    532                         }
    533                 }
    534             }
    535             .sheet(isPresented: $iState.isShowingSettings) {
    536                 SettingsView(syncMonitor: syncMonitor)
    537             }
    538             .alert("Rename List", isPresented: $iState.isShowingRenameAlert) {
    539                 TextField("List name", text: $iState.renameText)
    540                 Button("Cancel", role: .cancel) {}
    541                 Button("Rename") {
    542                     let trimmed = iState.renameText
    543                         .trimmingCharacters(in: .whitespacesAndNewlines)
    544                     if !trimmed.isEmpty {
    545                         listName = trimmed
    546                     }
    547                 }
    548                 .keyboardShortcut(.defaultAction)
    549             }
    550             .alert("Delete All", isPresented: $iState.isShowingDeleteAllAlert) {
    551                 Button("Cancel", role: .cancel) {}
    552                 Button("Delete All", role: .destructive) {
    553                     deleteAllItemsWithUndo()
    554                 }
    555             } message: {
    556                 Text("Are you sure you want to delete all items? You can undo this action.")
    557             }
    558     }
    559 
    560     private var itemScrollView: some View {
    561         ZStack(alignment: .top) {
    562             ScrollView {
    563                 VStack(alignment: .leading, spacing: vStackSpacing) {
    564                     navigationHeader
    565                         .padding(.bottom, 12)
    566                     pullToCreateIndicatorRow
    567                     if isPrependDraftVisible {
    568                         draftPrependRow
    569                             .padding(.bottom, rowGap)
    570                     }
    571                     itemRows
    572                 }
    573                 .frame(maxWidth: .infinity, alignment: .topLeading)
    574                 .onGeometryChange(for: CGFloat.self) {
    575                     $0.frame(in: .global).maxY
    576                 } action: {
    577                     layoutStorage.contentBottomY = $0
    578                 }
    579                 .padding(.trailing, 16)
    580                 .padding(.vertical, 12)
    581                 .onChange(of: focusedFieldBinding) { oldValue, newValue in
    582                     fState.focusedField = newValue
    583                     handleFocusChange(from: oldValue, to: newValue)
    584 
    585                     if newValue == nil,
    586                         !iState.isShowingOverlay
    587                     {
    588                         if let pending = fState.pendingFocus {
    589                             focusedFieldBinding = pending
    590                             fState.focusedField = pending
    591                             fState.pendingFocus = nil
    592                         } else {
    593                             focusedFieldBinding = .scrollView
    594                             fState.focusedField = .scrollView
    595                         }
    596                     }
    597 
    598                     if case .item(let id) = (newValue ?? fState.focusedField),
    599                         draggedItemID == nil,
    600                         id != draftPrependRowID
    601                     {
    602                         withAnimation {
    603                             scrollPosition.scrollTo(id: id)
    604                         }
    605                     }
    606                 }
    607                 .onChange(of: fState.selectedItemID) { _, newID in
    608                     if let newID, draggedItemID == nil {
    609                         guard newID != draftPrependRowID else { return }
    610                         withAnimation {
    611                             scrollPosition.scrollTo(id: newID)
    612                         }
    613                     }
    614                 }
    615             }
    616             .scrollPosition($scrollPosition)
    617             .scrollDisabled(draggedItemID != nil || iState.isSwiping)
    618             .scrollBounceBehavior(.always)
    619             .contentMargins(.bottom, 20)
    620             .background {
    621                 Color.outerBackground.ignoresSafeArea()
    622             }
    623             .overlay {
    624                 if isCompletelyEmpty && draftPlacement == nil {
    625                     Text("Pull down to create")
    626                         .font(ItemRowMetrics.hintSUI)
    627                         .foregroundStyle(.secondary)
    628                         .offset(y: pState.pullToCreate.pullOffset)
    629                         .allowsHitTesting(false)
    630                 }
    631             }
    632             .overlay(alignment: .bottom) {
    633                 pullToClearIndicatorRow
    634             }
    635             .pullGestures(
    636                 pullToCreate: $pState.pullToCreate,
    637                 pullUpOffset: $pState.pullUpOffset,
    638                 isEditing: focusedField != nil && focusedField != .scrollView,
    639                 hasCompletedItems: !completedItems.isEmpty,
    640                 pullCreateThreshold: pullCreateThreshold,
    641                 flickThreshold: flickThreshold,
    642                 pullClearThreshold: pullClearThreshold,
    643                 onCreateItemAtTop: { revealPhantomRow() },
    644                 onClearCompleted: {
    645                     let ids = Set(completedItems.map(\.id))
    646                     withAnimation(.easeIn(duration: 0.35)) {
    647                         iState.clearingItemIDs = ids
    648                     } completion: {
    649                         iState.clearingItemIDs = []
    650                         clearCompletedItemsWithUndo()
    651                     }
    652                 }
    653             )
    654             .sensoryFeedback(.impact(weight: .light), trigger: hapticsEnabled ? iState.draftCount : 0)
    655 
    656         }
    657     }
    658 }
    659 
    660