listless

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

commit 80814055da8566a51a54d632bde7253a0959ab95
parent 169d0e78f11c809764421878723a3b5380e522de
Author: Michael Camilleri <[email protected]>
Date:   Mon, 23 Mar 2026 21:24:38 +0900

Make additional fixes for pull-to-create animation

Diffstat:
MListlessiOS/Extensions/TaskListView+PullGestures.swift | 4++--
MListlessiOS/Extensions/TaskListView+PullToCreate.swift | 8+++++++-
MListlessiOS/Views/PullToCreate.swift | 18------------------
MListlessiOS/Views/TaskListView.swift | 54++++++++++++++++++++++++++++--------------------------
4 files changed, 37 insertions(+), 47 deletions(-)

diff --git a/ListlessiOS/Extensions/TaskListView+PullGestures.swift b/ListlessiOS/Extensions/TaskListView+PullGestures.swift @@ -130,8 +130,8 @@ private struct PullGesturesModifier: ViewModifier { switch action { case .createTask: - var transaction = Transaction(animation: .spring(response: 0.28, dampingFraction: 0.9)) - transaction.disablesAnimations = false + var transaction = Transaction(animation: nil) + transaction.disablesAnimations = true withTransaction(transaction) { _ = onCreateTaskAtTop() } diff --git a/ListlessiOS/Extensions/TaskListView+PullToCreate.swift b/ListlessiOS/Extensions/TaskListView+PullToCreate.swift @@ -6,16 +6,22 @@ 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) draftPlacement = .prepend + DispatchQueue.main.async { + pState.frozenOffset = 0 + } fState.selectedTaskID = taskID - pState.draftSettleOffset = -PullToCreateIndicator.indicatorHeight fState.pendingFocus = .task(taskID) focusedField = .task(taskID) + return taskID } diff --git a/ListlessiOS/Views/PullToCreate.swift b/ListlessiOS/Views/PullToCreate.swift @@ -3,13 +3,11 @@ import SwiftUI struct PullToCreateIndicator: View { let pullOffset: CGFloat let threshold: CGFloat - var hasRowsBelow: Bool = true @AppStorage("colorTheme") private var colorThemeRaw = 0 private var colorTheme: ColorTheme { ColorTheme(rawValue: colorThemeRaw) ?? .pilbara } static let indicatorHeight: CGFloat = 50 - private var revealedHeight: CGFloat { min(pullOffset, Self.indicatorHeight) } private var isReady: Bool { pullOffset >= threshold } private let textSlideDistance: CGFloat = 22 @@ -51,22 +49,6 @@ struct PullToCreateIndicator: View { .frame(width: TaskRowMetrics.accentBarWidth) } .frame(height: Self.indicatorHeight, alignment: .top) - .mask(alignment: .top) { - Rectangle() - .frame(height: revealedHeight) - } - .background(alignment: .top) { - if hasRowsBelow { - Color.taskCard - .frame( - height: min( - TaskRowMetrics.trailingCornerRadius, - Self.indicatorHeight - revealedHeight - ) - ) - .offset(y: revealedHeight) - } - } .allowsHitTesting(false) } } diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift @@ -23,7 +23,8 @@ struct TaskListView: View, TaskListViewProtocol { struct PullStateData { var pullToCreate = PullToCreateState() var pullUpOffset: CGFloat = 0 - var draftSettleOffset: CGFloat = 0 + var frozenOffset: CGFloat = 0 + var scrollUpAmount: CGFloat = 0 var headerHeight: CGFloat = 60 } @@ -167,7 +168,8 @@ struct TaskListView: View, TaskListViewProtocol { ? "Mark as Incomplete" : "Mark as Complete" } - var vStackSpacing: CGFloat { 12 } + var vStackSpacing: CGFloat { 0 } + var rowGap: CGFloat { 12 } var pullCreateThreshold: CGFloat { 70 } var flickThreshold: CGFloat { 500 } var isCompletelyEmpty: Bool { activeTasks.isEmpty && completedTasks.isEmpty } @@ -189,7 +191,7 @@ struct TaskListView: View, TaskListViewProtocol { guard placement == .prepend else { return } - pState.draftSettleOffset = 0 + pState.frozenOffset = 0 var state = pState.pullToCreate state.isInsertionPending = false state.indicatorOffset = 0 @@ -246,16 +248,18 @@ struct TaskListView: View, TaskListViewProtocol { 0, pState.pullToCreate.indicatorDisplayOffset( threshold: pullCreateThreshold - ) - vStackSpacing + ) ), - threshold: max(0, pullCreateThreshold - vStackSpacing), - hasRowsBelow: false + threshold: pullCreateThreshold ) - .padding(.bottom, -indicatorHeight) - .opacity(isPrependDraftVisible ? 0 : 1) - .offset( - y: vStackSpacing - min(pullOffset, indicatorHeight + vStackSpacing) + .frame( + height: isPrependDraftVisible + ? 0 + : min(pullOffset, indicatorHeight + rowGap), + alignment: .top ) + .clipped() + .opacity(isPrependDraftVisible ? 0 : 1) } /// The draft row content styled to match a task row. Controlled by the @@ -312,6 +316,7 @@ struct TaskListView: View, TaskListViewProtocol { focusedField: $focusedFieldBinding ) .id(draftAppendRowID) + .padding(.bottom, rowGap) } } @@ -362,6 +367,7 @@ struct TaskListView: View, TaskListViewProtocol { layoutStorage.rowFrames[taskID] = frame } .id(taskID) + .padding(.bottom, rowGap) } draftAppendRow @@ -383,6 +389,7 @@ struct TaskListView: View, TaskListViewProtocol { .opacity(isBeingCleared ? 0 : 1) .offset(y: isBeingCleared ? 40 : 0) .id(taskID) + .padding(.bottom, rowGap) } } @@ -467,15 +474,21 @@ struct TaskListView: View, TaskListViewProtocol { } if isPrependDraftVisible { draftPrependRow - .offset(y: pState.draftSettleOffset) + .padding(.bottom, rowGap) } taskRows } .offset( - y: -min( - pState.pullToCreate.pullOffset, - vStackSpacing - ) + y: isPrependDraftVisible + ? pState.frozenOffset + : -min( + pState.pullToCreate.pullOffset, + PullToCreateIndicator.indicatorHeight + rowGap + ) + ) + .animation( + .spring(response: 0.28, dampingFraction: 0.9), + value: pState.frozenOffset ) .frame(maxWidth: .infinity, alignment: .topLeading) .onGeometryChange(for: CGFloat.self) { @@ -485,17 +498,6 @@ struct TaskListView: View, TaskListViewProtocol { } .padding(.trailing, 16) .padding(.vertical, 12) - .onChange(of: isPrependDraftVisible) { old, new in - if new { - // Animate draftSettleOffset from -50 to 0 with the same - // spring used for the draft reveal transaction. This runs - // one frame after revealPhantomRow() snapped the offset to - // -50, so SwiftUI treats it as a real change to animate. - withAnimation(.spring(response: 0.28, dampingFraction: 0.9)) { - pState.draftSettleOffset = 0 - } - } - } .onChange(of: focusedFieldBinding) { oldValue, newValue in fState.focusedField = newValue handleFocusChange(from: oldValue, to: newValue)