listless

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

commit 0c68c501e535ec775053ab9f80ab3af32e52514c
parent 50738d801bebeb6ae1d62da12489f8cb1c2ee671
Author: Michael Camilleri <[email protected]>
Date:   Tue, 24 Mar 2026 08:25:04 +0900

Embrace rubber banding in iOS version

It's not possible with SwiftUI to adjust the rubber banding physics. The
way that Listless did it prior to this commit was invariably causing
visual hitches because adjustments were always a frame behind. This
commit stops fighting the overscroll caused by system's rubber banding.

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

Diffstat:
MListlessiOS/Extensions/TaskListView+PullGestures.swift | 9+++++++--
MListlessiOS/Extensions/TaskListView+PullToCreate.swift | 6+-----
MListlessiOS/Helpers/TaskRowSwipeGesture.swift | 3++-
MListlessiOS/Views/SettingsView.swift | 7+++++++
MListlessiOS/Views/TaskListView.swift | 84+++++++++++++++++++++++++++++++++++++++----------------------------------------
5 files changed, 58 insertions(+), 51 deletions(-)

diff --git a/ListlessiOS/Extensions/TaskListView+PullGestures.swift b/ListlessiOS/Extensions/TaskListView+PullGestures.swift @@ -51,12 +51,15 @@ extension TaskListView { let isFlick = pullOffset > 0 && elapsed > 0 && (pullOffset / elapsed) >= flickThreshold + print("[PullToCreate][handlePhaseChange] pullOffset=\(pullOffset) elapsed=\(elapsed) isFlick=\(isFlick) threshold=\(pullThreshold)") if pullOffset >= pullThreshold || isFlick { isInsertionPending = true + print("[PullToCreate][handlePhaseChange] -> createTask") return .createTask } isInsertionPending = false + print("[PullToCreate][handlePhaseChange] -> collapseIndicator") return .collapseIndicator } } @@ -66,6 +69,7 @@ private struct PullGesturesModifier: ViewModifier { @Binding var pullToCreate: TaskListView.PullToCreateState @Binding var pullUpOffset: CGFloat + @AppStorage("hapticsEnabled") private var hapticsEnabled = true @State private var isScrollInteracting = false let isDraftOpen: Bool @@ -108,11 +112,11 @@ private struct PullGesturesModifier: ViewModifier { } .sensoryFeedback( .impact(weight: .light), - trigger: !isDraftOpen && pullToCreate.pullOffset >= pullCreateThreshold + trigger: hapticsEnabled && !isDraftOpen && pullToCreate.pullOffset >= pullCreateThreshold ) { old, new in !old && new } - .sensoryFeedback(.impact(weight: .light), trigger: pullUpOffset >= pullClearThreshold) { old, new in + .sensoryFeedback(.impact(weight: .light), trigger: hapticsEnabled && pullUpOffset >= pullClearThreshold) { old, new in !old && new } } @@ -128,6 +132,7 @@ private struct PullGesturesModifier: ViewModifier { guard oldPhase == .interacting, newPhase != .interacting else { return } + print("[PullToCreate][scrollPhaseAction] action=\(action) pullOffset=\(pullToCreate.pullOffset) indicatorOffset=\(pullToCreate.indicatorOffset)") switch action { case .createTask: var transaction = Transaction(animation: nil) diff --git a/ListlessiOS/Extensions/TaskListView+PullToCreate.swift b/ListlessiOS/Extensions/TaskListView+PullToCreate.swift @@ -6,18 +6,14 @@ extension TaskListView { func revealPhantomRow() -> UUID { let taskID = draftPrependRowID - let maxOffset = PullToCreateIndicator.indicatorHeight + 12 if draftPlacement != .prepend, draftPlacement != nil { commitDraftTask() } clearDragState() draftTitle = "" - pState.frozenOffset = -min(pState.pullToCreate.pullOffset, maxOffset) + print("[PullToCreate][revealPhantomRow] pullOffset=\(pState.pullToCreate.pullOffset)") draftPlacement = .prepend - Task { @MainActor in - pState.frozenOffset = 0 - } fState.selectedTaskID = taskID fState.pendingFocus = .task(taskID) focusedField = .task(taskID) diff --git a/ListlessiOS/Helpers/TaskRowSwipeGesture.swift b/ListlessiOS/Helpers/TaskRowSwipeGesture.swift @@ -38,6 +38,7 @@ struct TaskRowSwipeGesture: ViewModifier { let onComplete: () -> Void let onDelete: () -> Void + @AppStorage("hapticsEnabled") private var hapticsEnabled = true @State private var hapticTrigger = false @State private var activeGestureAxis: ActiveGestureAxis = .undecided @@ -85,7 +86,7 @@ struct TaskRowSwipeGesture: ViewModifier { handleDragEnded() } ) - .sensoryFeedback(.impact(weight: .light), trigger: hapticTrigger) + .sensoryFeedback(.impact(weight: .light), trigger: hapticsEnabled ? hapticTrigger : false) .onDisappear { resetSwipeState() } diff --git a/ListlessiOS/Views/SettingsView.swift b/ListlessiOS/Views/SettingsView.swift @@ -6,6 +6,7 @@ struct SettingsView: View { @AppStorage("headingText") private var headingText = "Items" @AppStorage("appearanceMode") private var appearanceMode = 0 @AppStorage("colorTheme") private var colorThemeRaw = 0 + @AppStorage("hapticsEnabled") private var hapticsEnabled = true var body: some View { NavigationStack { @@ -44,6 +45,12 @@ struct SettingsView: View { } } + if UIDevice.current.userInterfaceIdiom == .phone { + Section("Interactions") { + Toggle("Haptics", isOn: $hapticsEnabled) + } + } + Section("Appearance") { Picker("Appearance", selection: $appearanceMode) { Text("System").tag(0) diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift @@ -23,7 +23,6 @@ struct TaskListView: View, TaskListViewProtocol { struct PullStateData { var pullToCreate = PullToCreateState() var pullUpOffset: CGFloat = 0 - var frozenOffset: CGFloat = 0 var scrollUpAmount: CGFloat = 0 var headerHeight: CGFloat = 60 @@ -31,6 +30,7 @@ struct TaskListView: View, TaskListViewProtocol { @AppStorage("headingText") var headingText = "Items" @AppStorage("colorTheme") private var colorThemeRaw = 0 + @AppStorage("hapticsEnabled") private var hapticsEnabled = true private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .pilbara } @Environment(\.undoManager) var undoManager @Environment(\.managedObjectContext) var managedObjectContext @@ -181,6 +181,7 @@ struct TaskListView: View, TaskListViewProtocol { func clearDraftTaskUI(at placement: DraftTaskPlacement, hasTitle: Bool) { let clear: () -> Void = { + print("[PullToCreate][clearDraftTaskUI] placement=\(placement) hasTitle=\(hasTitle) isInsertionPending=\(pState.pullToCreate.isInsertionPending) indicatorOffset=\(pState.pullToCreate.indicatorOffset)") if draftPlacement == placement { draftPlacement = nil } @@ -191,7 +192,6 @@ struct TaskListView: View, TaskListViewProtocol { guard placement == .prepend else { return } - pState.frozenOffset = 0 var state = pState.pullToCreate state.isInsertionPending = false state.indicatorOffset = 0 @@ -219,8 +219,10 @@ struct TaskListView: View, TaskListViewProtocol { func didStartDrag() { isDragging = true - let generator = UIImpactFeedbackGenerator(style: .light) - generator.impactOccurred() + if hapticsEnabled { + let generator = UIImpactFeedbackGenerator(style: .light) + generator.impactOccurred() + } } func showSyncDiagnostics() { @@ -243,27 +245,34 @@ struct TaskListView: View, TaskListViewProtocol { @ViewBuilder var pullToCreateIndicatorRow: some View { let pullOffset = pState.pullToCreate.pullOffset let indicatorHeight = PullToCreateIndicator.indicatorHeight + let indicatorDisplayOffset = pState.pullToCreate.indicatorDisplayOffset( + threshold: pullCreateThreshold + ) + let frameHeight: CGFloat = isPrependDraftVisible + ? 0 + : min(pullOffset, indicatorHeight + rowGap) + let opacity: Double = isPrependDraftVisible || pullOffset <= 0 ? 0 : 1 + let _ = print("[PullToCreate][Indicator] pullOffset=\(pullOffset) indicatorDisplayOffset=\(indicatorDisplayOffset) frameHeight=\(frameHeight) opacity=\(opacity) isPrependDraftVisible=\(isPrependDraftVisible)") PullToCreateIndicator( - pullOffset: max( - 0, - pState.pullToCreate.indicatorDisplayOffset( - threshold: pullCreateThreshold - ) - ), + pullOffset: max(0, indicatorDisplayOffset), threshold: pullCreateThreshold ) .frame( - height: isPrependDraftVisible - ? 0 - : min(pullOffset, indicatorHeight + rowGap), + height: frameHeight, alignment: .top ) - .opacity(isPrependDraftVisible || pullOffset <= 0 ? 0 : 1) + .opacity(opacity) + .onGeometryChange(for: CGRect.self) { + $0.frame(in: .global) + } action: { frame in + print("[PullToCreate][Indicator][Geo] frame=\(frame)") + } } /// The draft row content styled to match a task row. Controlled by the /// ZStack in ``pullToCreateIndicatorRow`` rather than its own visibility. @ViewBuilder private var draftPrependRow: some View { + let _ = print("[PullToCreate][DraftRow] visible isInsertionPending=\(pState.pullToCreate.isInsertionPending)") DraftRowView( accentColor: taskColor( forIndex: 0, total: max(1, displayActiveTasks.count + 1), theme: colorTheme @@ -364,6 +373,9 @@ struct TaskListView: View, TaskListViewProtocol { proxy.frame(in: .global) } action: { frame in layoutStorage.rowFrames[taskID] = frame + if index == 0 { + print("[PullToCreate][FirstTask][Geo] frame=\(frame)") + } } .padding(.bottom, rowGap) .id(taskID) @@ -467,28 +479,26 @@ struct TaskListView: View, TaskListViewProtocol { ScrollView { ScrollViewReader { scrollProxy in VStack(alignment: .leading, spacing: vStackSpacing) { - VStack(alignment: .leading, spacing: 0) { - Color.clear.frame(height: pState.headerHeight) - pullToCreateIndicatorRow - } + navigationHeader + .padding(.bottom, 12) + pullToCreateIndicatorRow if isPrependDraftVisible { draftPrependRow .padding(.bottom, rowGap) + .onGeometryChange(for: CGRect.self) { + $0.frame(in: .global) + } action: { frame in + print("[PullToCreate][DraftRow][Geo] frame=\(frame)") + } } taskRows } - .offset( - y: isPrependDraftVisible - ? pState.frozenOffset - : -min( - pState.pullToCreate.pullOffset, - PullToCreateIndicator.indicatorHeight + rowGap - ) - ) - .animation( - .spring(response: 0.28, dampingFraction: 1.0), - value: pState.frozenOffset - ) + .offset(y: 0) + .onGeometryChange(for: CGRect.self) { + $0.frame(in: .global) + } action: { frame in + print("[PullToCreate][VStack][Geo] frame=\(frame)") + } .frame(maxWidth: .infinity, alignment: .topLeading) .onGeometryChange(for: CGFloat.self) { $0.frame(in: .global).maxY @@ -576,20 +586,8 @@ struct TaskListView: View, TaskListViewProtocol { } } ) - .sensoryFeedback(.impact(weight: .light), trigger: iState.draftCount) + .sensoryFeedback(.impact(weight: .light), trigger: hapticsEnabled ? iState.draftCount : 0) - navigationHeader - .padding(.top, 12) - .onGeometryChange(for: CGFloat.self) { - $0.size.height - } action: { - pState.headerHeight = $0 - } - .offset(y: -pState.scrollUpAmount) - .background { - Color.outerBackground - .offset(y: -pState.scrollUpAmount) - } } } }