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