listless

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

commit c5e368a69436a651a3b06fb5768b59daafeed3b5
parent 9a50bb1fd589366b02341e52bef7c1b29ff46c31
Author: Michael Camilleri <[email protected]>
Date:   Wed, 18 Feb 2026 11:10:31 +0900

Add initial implementation of pull-to-clear

Co-Authored-By: Codex GPT 5.3 <[email protected]>

Diffstat:
MListless.xcodeproj/project.pbxproj | 8++++++++
AListlessiOS/Extensions/TaskListView+PullToClear.swift | 9+++++++++
AListlessiOS/Views/PullToClear.swift | 49+++++++++++++++++++++++++++++++++++++++++++++++++
MListlessiOS/Views/TaskListView.swift | 101++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
4 files changed, 128 insertions(+), 39 deletions(-)

diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj @@ -26,10 +26,12 @@ 763363F6F3C7D2D3C9A63977 /* PlatformScrollIndicatorsModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */; }; 77970A2323A598E830D95301 /* TaskListView+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB43816B8E7F083A2AD07F28 /* TaskListView+Toolbar.swift */; }; 77FE96F070B1F7FE31A9CE51 /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */; }; + 785721EB774EAC6BBA26C038 /* PullToClear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 567DBAC2A39FA2760D006AAB /* PullToClear.swift */; }; 7E366008FF9335A77FE1636D /* TaskRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 199CC2F58DD7CBA3F2229366 /* TaskRowView.swift */; }; 80BCA654FE694D8A77D9BF93 /* TaskListView+PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD8D4C29E09FEE78AE5AB79 /* TaskListView+PullToCreate.swift */; }; 82E75475E13FD847DDDC69D8 /* TaskListView+Drag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FA75CA18C6270D68A755AEB /* TaskListView+Drag.swift */; }; 882695E7AE463C0F39ACFF3C /* HoverCursorModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F5D8B5866362D422A2A331C /* HoverCursorModifier.swift */; }; + 8A8D164CAA7B95B4C7435A7C /* TaskListView+PullToClear.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A6D74EDDD7E3FC150064FB5 /* TaskListView+PullToClear.swift */; }; 91EDF52C7C5C0B35E9D8B51E /* TaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3DEE364304587D280C5672 /* TaskStore.swift */; }; 96617677059FABDBB80D642B /* Listless.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C093494053E6C348F245D4EC /* Listless.xcdatamodeld */; }; 99977BFA37FBAAA49AF6B71E /* TaskStoreEdgeCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B2BBE01D99CDA278BCB9F49 /* TaskStoreEdgeCaseTests.swift */; }; @@ -76,6 +78,8 @@ 4669414A460FC0758D5B49A8 /* KeyboardNavigationModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardNavigationModifier.swift; sourceTree = "<group>"; }; 466F9B0E407DF1F5B4789531 /* PlatformScrollIndicatorsModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformScrollIndicatorsModifier.swift; sourceTree = "<group>"; }; 4FC64B9F9370041BEDBD1E14 /* .gitkeep */ = {isa = PBXFileReference; path = .gitkeep; sourceTree = "<group>"; }; + 567DBAC2A39FA2760D006AAB /* PullToClear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PullToClear.swift; sourceTree = "<group>"; }; + 5A6D74EDDD7E3FC150064FB5 /* TaskListView+PullToClear.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TaskListView+PullToClear.swift"; sourceTree = "<group>"; }; 5B0E22B8F7B2B7283CAF749E /* Listless macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Listless macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 5E88DDD119EEEECCD45F36D2 /* TaskStoreCompletionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStoreCompletionTests.swift; sourceTree = "<group>"; }; 632DA39B24C4CF1528A1A24D /* TaskListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskListView.swift; sourceTree = "<group>"; }; @@ -165,6 +169,7 @@ 58F917D865E0BDF4EF282306 /* Views */ = { isa = PBXGroup; children = ( + 567DBAC2A39FA2760D006AAB /* PullToClear.swift */, BFF7D84B54AE70036D205CA4 /* PullToCreate.swift */, E51B8129962E5CC78ECDDC2B /* TaskListView.swift */, 199CC2F58DD7CBA3F2229366 /* TaskRowView.swift */, @@ -186,6 +191,7 @@ children = ( 3FA75CA18C6270D68A755AEB /* TaskListView+Drag.swift */, 712ADAD0FE66175DAA9A6D50 /* TaskListView+NavigationHeader.swift */, + 5A6D74EDDD7E3FC150064FB5 /* TaskListView+PullToClear.swift */, 9AD8D4C29E09FEE78AE5AB79 /* TaskListView+PullToCreate.swift */, CB43816B8E7F083A2AD07F28 /* TaskListView+Toolbar.swift */, ); @@ -442,12 +448,14 @@ DC73A39A269AB495BCE1AC48 /* PersistenceController.swift in Sources */, 763363F6F3C7D2D3C9A63977 /* PlatformScrollIndicatorsModifier.swift in Sources */, 77FE96F070B1F7FE31A9CE51 /* PlatformTextFieldWidthModifier.swift in Sources */, + 785721EB774EAC6BBA26C038 /* PullToClear.swift in Sources */, E47136CA7428927395D8C7C7 /* PullToCreate.swift in Sources */, 0F12D56A528FCBF8A67864CB /* TappableTextField.swift in Sources */, 0ACA67F6578EFF181EE5C9A7 /* TaskItem.swift in Sources */, 82E75475E13FD847DDDC69D8 /* TaskListView+Drag.swift in Sources */, CAD142ED738A83371DFF8F5B /* TaskListView+Logic.swift in Sources */, 26F609518DE1055DF09B0159 /* TaskListView+NavigationHeader.swift in Sources */, + 8A8D164CAA7B95B4C7435A7C /* TaskListView+PullToClear.swift in Sources */, 80BCA654FE694D8A77D9BF93 /* TaskListView+PullToCreate.swift in Sources */, 77970A2323A598E830D95301 /* TaskListView+Toolbar.swift in Sources */, C169823665158AA347A63990 /* TaskListView.swift in Sources */, diff --git a/ListlessiOS/Extensions/TaskListView+PullToClear.swift b/ListlessiOS/Extensions/TaskListView+PullToClear.swift @@ -0,0 +1,9 @@ +import SwiftUI + +extension TaskListView { + @ViewBuilder var pullToClearIndicatorRow: some View { + if pullUpOffset > 0 && !completedTasks.isEmpty { + PullToClearIndicator(pullOffset: pullUpOffset) + } + } +} diff --git a/ListlessiOS/Views/PullToClear.swift b/ListlessiOS/Views/PullToClear.swift @@ -0,0 +1,49 @@ +import SwiftUI + +/// Pull distance at which the indicator signals readiness and completed task clearing triggers. +let pullClearThreshold: CGFloat = 70 + +struct PullToClearIndicator: View { + let pullOffset: CGFloat + + private var progress: CGFloat { min(1, pullOffset / pullClearThreshold) } + private var isReady: Bool { pullOffset >= pullClearThreshold } + + // Matches the "bottom" gradient stop used for the last active task row + private let accentColor = Color(hue: 0.72, saturation: 0.65, brightness: 0.85) + + var body: some View { + HStack(spacing: 0) { + Rectangle() + .fill(accentColor) + .frame(width: 8) + HStack(spacing: 6) { + Image(systemName: isReady ? "checkmark" : "trash") + .foregroundStyle(.secondary) + .fontWeight(.semibold) + .animation(.easeInOut(duration: 0.15), value: isReady) + Text(isReady ? "Release to clear" : "Clear completed") + .foregroundStyle(.secondary) + .font(.body) + .animation(.easeInOut(duration: 0.15), value: isReady) + Spacer() + } + .padding(.horizontal, 16) + .frame(maxHeight: .infinity) + .background(Color.taskCard) + } + .frame(height: 56) + .clipShape( + UnevenRoundedRectangle( + topLeadingRadius: 0, bottomLeadingRadius: 0, + bottomTrailingRadius: 14, topTrailingRadius: 14 + ) + ) + .padding(.trailing, 16) + // Reveal from the bottom upward as the user pulls + .frame(height: min(pullOffset, 56), alignment: .bottom) + .clipped() + .opacity(Double(progress)) + .allowsHitTesting(false) + } +} diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift @@ -25,6 +25,7 @@ struct TaskListView: View { @State var visualOrder: [UUID]? @State var pendingFocus: FocusField? @State var pullOffset: CGFloat = 0 + @State var pullUpOffset: CGFloat = 0 @State var isDragging: Bool = false @State var rowFrames: [UUID: CGRect] = [:] @@ -41,6 +42,50 @@ struct TaskListView: View { } var body: some View { + taskScrollView + .contentShape(Rectangle()) + .onTapGesture { + handleBackgroundTap() + } + .focusable() + .focused($focusedField, equals: .scrollView) + .focusEffectDisabled() + .accessibilityIdentifier("task-list-scrollview") + .keyboardNavigation([ + ShortcutKey(key: .upArrow): navigateUp, + ShortcutKey(key: .downArrow): navigateDown, + ShortcutKey(key: .space): toggleSelectedTask, + ShortcutKey(key: .return): focusSelectedTask, + ShortcutKey(key: .delete): deleteSelectedTask, + ]) + .onAppear { + if focusedField == nil { + focusedField = .scrollView + } + } + .onChange(of: focusedField) { oldValue, newValue in + handleFocusChange(from: oldValue, to: newValue) + + if newValue == nil { + if let pending = pendingFocus { + print("🟣 onChange resolving pendingFocus: \(pending)") + focusedField = pending + pendingFocus = nil + } else { + print("🟣 onChange repairing nil focus to .scrollView") + focusedField = .scrollView + } + } + } + .onChange(of: undoManager, initial: true) { _, newValue in + managedObjectContext.undoManager = newValue + } + .toolbar { + platformToolbar + } + } + + private var taskScrollView: some View { ScrollView { VStack(alignment: .leading, spacing: vStackSpacing) { navigationHeader @@ -102,62 +147,40 @@ struct TaskListView: View { .offset(y: -pullOffset) } .scrollDisabled(draggedTaskID != nil) + .scrollBounceBehavior(.always) .background { Color.outerBackground.ignoresSafeArea() } + .overlay(alignment: .bottom) { + pullToClearIndicatorRow + } .onScrollGeometryChange(for: CGFloat.self) { geo in max(0, -(geo.contentOffset.y + geo.contentInsets.top)) } action: { _, pullDistance in pullOffset = pullDistance } + .onScrollGeometryChange(for: CGFloat.self) { geo in + let maxOffset = max( + -geo.contentInsets.top, + geo.contentSize.height - geo.bounds.size.height + geo.contentInsets.bottom + ) + return max(0, geo.contentOffset.y - maxOffset) + } action: { _, pullDistance in + pullUpOffset = pullDistance + } .onScrollPhaseChange { oldPhase, newPhase in if oldPhase == .interacting && newPhase != .interacting { if pullOffset >= pullCreateThreshold { createNewTaskAtTop() } + if pullUpOffset >= pullClearThreshold && !completedTasks.isEmpty { clearCompletedTasks() } pullOffset = 0 + pullUpOffset = 0 } } .sensoryFeedback(.impact(weight: .medium), trigger: pullOffset >= pullCreateThreshold) { old, new in !old && new } - .contentShape(Rectangle()) - .onTapGesture { - handleBackgroundTap() - } - .focusable() - .focused($focusedField, equals: .scrollView) - .focusEffectDisabled() - .accessibilityIdentifier("task-list-scrollview") - .keyboardNavigation([ - ShortcutKey(key: .upArrow): navigateUp, - ShortcutKey(key: .downArrow): navigateDown, - ShortcutKey(key: .space): toggleSelectedTask, - ShortcutKey(key: .return): focusSelectedTask, - ShortcutKey(key: .delete): deleteSelectedTask, - ]) - .onAppear { - if focusedField == nil { - focusedField = .scrollView - } - } - .onChange(of: focusedField) { oldValue, newValue in - handleFocusChange(from: oldValue, to: newValue) - - if newValue == nil { - if let pending = pendingFocus { - print("🟣 onChange resolving pendingFocus: \(pending)") - focusedField = pending - pendingFocus = nil - } else { - print("🟣 onChange repairing nil focus to .scrollView") - focusedField = .scrollView - } - } - } - .onChange(of: undoManager, initial: true) { _, newValue in - managedObjectContext.undoManager = newValue - } - .toolbar { - platformToolbar + .sensoryFeedback(.impact(weight: .medium), trigger: pullUpOffset >= pullClearThreshold) { old, new in + !old && new } } }