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