listless

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

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:
MListlessiOS/Extensions/TaskListView+PullToCreate.swift | 6++++--
MListlessiOS/Views/PullToCreate.swift | 33+++++++++++++++++++++------------
MListlessiOS/Views/TaskListView.swift | 35+++++++++++++++++++++++++++++++++--
MListlessiOS/Views/TaskRowView.swift | 3++-
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 {