commit f5b4c349770f2a18b708dc02f2f73bbf2344fdef
parent 9d90ad7f51919dda67cf35e5d4baac80b930a1e8
Author: Michael Camilleri <[email protected]>
Date: Wed, 18 Feb 2026 16:57:03 +0900
Fix pull-to-create animation bug
Co-Authored-By: Codex GPT 5.3 <[email protected]>
Diffstat:
4 files changed, 60 insertions(+), 17 deletions(-)
diff --git a/ListlessiOS/Extensions/TaskListView+PullToCreate.swift b/ListlessiOS/Extensions/TaskListView+PullToCreate.swift
@@ -2,8 +2,10 @@ import SwiftUI
extension TaskListView {
@ViewBuilder var pullToCreateIndicatorRow: some View {
- if pullOffset > 0 {
- PullToCreateIndicator(pullOffset: pullOffset)
+ if createIndicatorOffset > 0 || isCreateInsertionPending {
+ PullToCreateIndicator(
+ pullOffset: isCreateInsertionPending ? pullCreateThreshold : createIndicatorOffset
+ )
}
}
}
diff --git a/ListlessiOS/Views/PullToCreate.swift b/ListlessiOS/Views/PullToCreate.swift
@@ -8,6 +8,8 @@ struct PullToCreateIndicator: View {
private var progress: CGFloat { min(1, pullOffset / pullCreateThreshold) }
private var isReady: Bool { pullOffset >= pullCreateThreshold }
+ private let indicatorHeight: CGFloat = 48
+ private let textSlideDistance: CGFloat = 22
// Matches the "top" gradient stop used for the first active task row
private let accentColor = Color(hue: 0.98, saturation: 0.85, brightness: 1.0)
@@ -17,31 +19,38 @@ struct PullToCreateIndicator: View {
Rectangle()
.fill(accentColor)
.frame(width: 8)
- HStack(spacing: 6) {
- Image(systemName: isReady ? "checkmark" : "plus")
+ HStack(alignment: .center, spacing: 12) {
+ Image(systemName: "circle")
.foregroundStyle(.secondary)
- .fontWeight(.semibold)
- .animation(.easeInOut(duration: 0.15), value: isReady)
- Text(isReady ? "Release to add" : "New task")
- .foregroundStyle(.secondary)
- .font(.body)
- .animation(.easeInOut(duration: 0.15), value: isReady)
+ .font(.system(size: 17))
+ .fontWeight(.thin)
+ ZStack(alignment: .leading) {
+ Text("Release to add")
+ .offset(y: isReady ? 0 : -textSlideDistance)
+ Text("New task")
+ .offset(y: isReady ? textSlideDistance : 0)
+ }
+ .foregroundStyle(.secondary)
+ .font(.body)
+ .frame(height: textSlideDistance, alignment: .topLeading)
+ .clipped()
+ .animation(.easeInOut(duration: 0.18), value: isReady)
Spacer()
}
+ .padding(.vertical, 14)
.padding(.horizontal, 16)
- .frame(maxHeight: .infinity)
+ .frame(maxWidth: .infinity, alignment: .leading)
.background(Color.taskCard)
}
- .frame(height: 56)
+ .frame(maxWidth: .infinity, alignment: .leading)
.clipShape(
UnevenRoundedRectangle(
topLeadingRadius: 0, bottomLeadingRadius: 0,
bottomTrailingRadius: 14, topTrailingRadius: 14
)
)
- .padding(.trailing, 16)
// Reveal from the top downward as the user pulls
- .frame(height: min(pullOffset, 56), alignment: .top)
+ .frame(height: min(pullOffset, indicatorHeight), alignment: .top)
.clipped()
.opacity(Double(progress))
.allowsHitTesting(false)
diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift
@@ -25,8 +25,12 @@ struct TaskListView: View {
@State var visualOrder: [UUID]?
@State var pendingFocus: FocusField?
@State var pullOffset: CGFloat = 0
+ @State var createIndicatorOffset: CGFloat = 0
+ @State var isCreateInsertionPending: Bool = false
+ @State var activeTaskCountBeforeCreate: Int = 0
@State var pullUpOffset: CGFloat = 0
@State var isDragging: Bool = false
+ @State var isScrollInteracting: Bool = false
@State var rowFrames: [UUID: CGRect] = [:]
var vStackSpacing: CGFloat { 12 }
@@ -158,6 +162,9 @@ struct TaskListView: View {
max(0, -(geo.contentOffset.y + geo.contentInsets.top))
} action: { _, pullDistance in
pullOffset = pullDistance
+ if isScrollInteracting {
+ createIndicatorOffset = pullDistance
+ }
}
.onScrollGeometryChange(for: CGFloat.self) { geo in
let maxOffset = max(
@@ -169,13 +176,37 @@ struct TaskListView: View {
pullUpOffset = pullDistance
}
.onScrollPhaseChange { oldPhase, newPhase in
+ isScrollInteracting = (newPhase == .interacting)
+
if oldPhase == .interacting && newPhase != .interacting {
- if pullOffset >= pullCreateThreshold { createNewTaskAtTop() }
+ if pullOffset >= pullCreateThreshold {
+ activeTaskCountBeforeCreate = activeTasks.count
+ isCreateInsertionPending = true
+ var transaction = Transaction(animation: .spring(response: 0.28, dampingFraction: 0.9))
+ transaction.disablesAnimations = false
+ withTransaction(transaction) {
+ createNewTaskAtTop()
+ }
+ } else {
+ isCreateInsertionPending = false
+ withAnimation(.spring(response: 0.22, dampingFraction: 0.95)) {
+ createIndicatorOffset = 0
+ }
+ }
if pullUpOffset >= pullClearThreshold && !completedTasks.isEmpty { clearCompletedTasks() }
- pullOffset = 0
pullUpOffset = 0
}
}
+ .onChange(of: activeTasks.count) { _, newCount in
+ guard isCreateInsertionPending else { return }
+ guard newCount > activeTaskCountBeforeCreate else { return }
+ var transaction = Transaction(animation: nil)
+ transaction.disablesAnimations = true
+ withTransaction(transaction) {
+ isCreateInsertionPending = false
+ createIndicatorOffset = 0
+ }
+ }
.sensoryFeedback(.impact(weight: .medium), trigger: pullOffset >= pullCreateThreshold) { old, new in
!old && new
}
diff --git a/ListlessiOS/Views/TaskRowView.swift b/ListlessiOS/Views/TaskRowView.swift
@@ -81,7 +81,8 @@ struct TaskRowView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.vertical, 14)
- .padding(.horizontal, 16)
+ .padding(.trailing, 16)
+ .padding(.leading, task.isCompleted ? 16 : 24)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {