← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


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 {