← All repositories

listless

A simple list app for Apple platforms

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


commit 5802333d96c38ea85344dc6c68d76b1824f6b8de
parent 9380c94322191d9f818c541c60c719616e9d8356
Author: Michael Camilleri <[email protected]>
Date:   Mon, 23 Mar 2026 08:29:36 +0900

Improve animation of pull-to-create insertion

Previous to this commit, the animation for the pull-to-create gesture
was jerky. This is an attempt to smooth that out.

Co-Authored-By: Claude 4.6 Opus <[email protected]>

Diffstat

MListlessiOS/Extensions/TaskListView+PullToCreate.swift | 13++++++++++++-
MListlessiOS/Views/TaskListView.swift | 266++++++++++++++++++++++++++++++++++++++++++-------------------------------------

2 files changed, 152 insertions(+), 127 deletions(-)

diff --git a/ListlessiOS/Extensions/TaskListView+PullToCreate.swift b/ListlessiOS/Extensions/TaskListView+PullToCreate.swift

@@ -5,7 +5,18 @@ extension TaskListView {
     // MARK: - Pull-to-Create Draft Helpers
 
     func revealPhantomRow() -> UUID {
-        return createNewTaskAtTop()
+        let taskID = draftPrependRowID
+        if draftPlacement != .prepend, draftPlacement != nil {
+            commitDraftTask()
+        }
+        clearDragState()
+        draftTitle = ""
+        draftPlacement = .prepend
+        fState.selectedTaskID = taskID
+        pState.draftSettleOffset = -PullToCreateIndicator.indicatorHeight
+        fState.pendingFocus = .task(taskID)
+        focusedField = .task(taskID)
+        return taskID
     }
 
     func commitPhantomRow() {

diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift

@@ -22,6 +22,9 @@ struct TaskListView: View, TaskListViewProtocol {
     struct PullStateData {
         var pullToCreate = PullToCreateState()
         var pullUpOffset: CGFloat = 0
+        var draftSettleOffset: CGFloat = 0
+        var scrollUpAmount: CGFloat = 0
+        var headerHeight: CGFloat = 60
     }
 
     @AppStorage("headingText") var headingText = "Items"
@@ -180,6 +183,7 @@ struct TaskListView: View, TaskListViewProtocol {
 
             guard placement == .prepend else { return }
 
+            pState.draftSettleOffset = 0
             var state = pState.pullToCreate
             state.isInsertionPending = false
             state.indicatorOffset = 0
@@ -225,58 +229,39 @@ struct TaskListView: View, TaskListViewProtocol {
         return (width + liftPoints) / width
     }
 
-    private var pullToCreateRevealHeight: CGFloat {
-        min(
-            pState.pullToCreate.indicatorDisplayOffset(threshold: pullCreateThreshold),
-            PullToCreateIndicator.indicatorHeight
-        )
-    }
-
-    private var pullToCreateGap: CGFloat {
-        guard pState.pullToCreate.shouldShowIndicator, !isPrependDraftVisible else { return 0 }
-        let exposedPull = pState.pullToCreate.indicatorDisplayOffset(threshold: pullCreateThreshold)
-        return min(
-            vStackSpacing,
-            max(0, exposedPull - PullToCreateIndicator.indicatorHeight)
-        )
-    }
-
-    private var pullToCreateRowOverlap: CGFloat {
-        guard pState.pullToCreate.shouldShowIndicator, !isPrependDraftVisible else {
-            return 0
-        }
-        return PullToCreateIndicator.indicatorHeight - pullToCreateRevealHeight
-    }
-
     /// Combined indicator and phantom entry row sharing the same VStack slot.
     /// The phantom's UITextView is created while the indicator is visible
     /// (during the pull), so it's ready when the user releases.
     @ViewBuilder var pullToCreateIndicatorRow: some View {
-        let showIndicator = pState.pullToCreate.shouldShowIndicator
         let showPhantom = isPrependDraftVisible
-        if showIndicator || showPhantom {
-            ZStack(alignment: .topLeading) {
-                PullToCreateIndicator(
-                    pullOffset: pState.pullToCreate.indicatorDisplayOffset(
+        let pullOffset = pState.pullToCreate.pullOffset
+        let indicatorHeight = PullToCreateIndicator.indicatorHeight
+        ZStack(alignment: .topLeading) {
+            PullToCreateIndicator(
+                pullOffset: max(
+                    0,
+                    pState.pullToCreate.indicatorDisplayOffset(
                         threshold: pullCreateThreshold
-                    ),
-                    threshold: pullCreateThreshold,
-                    hasRowsBelow: !displayActiveTasks.isEmpty
-                )
-                .opacity(showPhantom ? 0 : 1)
-
-                draftPrependRow
-                    .frame(height: showPhantom ? nil : 0)
-                    .opacity(showPhantom ? 1 : 0)
-                    // Instant swap — no animation on height or opacity.
-                    .animation(nil, value: showPhantom)
-            }
-            .frame(
-                height: showPhantom ? nil : PullToCreateIndicator.indicatorHeight,
-                alignment: .top
+                    ) - vStackSpacing
+                ),
+                threshold: max(0, pullCreateThreshold - vStackSpacing),
+                hasRowsBelow: false
             )
-            .animation(nil, value: showPhantom)
+            .padding(.bottom, -indicatorHeight)
+            .opacity(showPhantom ? 0 : 1)
+
+            draftPrependRow
+                .frame(height: showPhantom ? nil : 0)
+                .opacity(showPhantom ? 1 : 0)
+                // Instant swap — no animation on height or opacity.
+                .animation(nil, value: showPhantom)
         }
+        .offset(
+            y: showPhantom
+                ? pState.draftSettleOffset
+                : vStackSpacing - min(pullOffset, indicatorHeight + vStackSpacing)
+        )
+        .animation(nil, value: showPhantom)
     }
 
     /// The draft row content styled to match a task row. Controlled by the
@@ -478,105 +463,134 @@ struct TaskListView: View, TaskListViewProtocol {
     }
 
     private var taskScrollView: some View {
-        ScrollView {
-          ScrollViewReader { scrollProxy in
-            VStack(alignment: .leading, spacing: vStackSpacing) {
-                VStack(alignment: .leading, spacing: 0) {
-                    navigationHeader
-                    pullToCreateIndicatorRow
-                        .padding(.top, vStackSpacing)
+        ZStack(alignment: .top) {
+            ScrollView {
+              ScrollViewReader { scrollProxy in
+                VStack(alignment: .leading, spacing: vStackSpacing) {
+                    VStack(alignment: .leading, spacing: 0) {
+                        Color.clear.frame(height: pState.headerHeight)
+                        pullToCreateIndicatorRow
+                    }
+                    taskRows
                 }
-                .padding(
-                    .bottom,
-                    (pState.pullToCreate.shouldShowIndicator && !isPrependDraftVisible)
-                        ? (pullToCreateGap - vStackSpacing) : 0
+                .offset(
+                    y: -min(
+                        pState.pullToCreate.pullOffset,
+                        vStackSpacing
+                    )
                 )
-                taskRows
-                    .offset(y: -pullToCreateRowOverlap)
-            }
-            .frame(maxWidth: .infinity, alignment: .topLeading)
-            .onGeometryChange(for: CGFloat.self) {
-                $0.frame(in: .global).maxY
-            } action: {
-                layoutStorage.contentBottomY = $0
-            }
-            .padding(.trailing, 16)
-            .padding(.vertical, 12)
-            .offset(y: -pState.pullToCreate.pullOffset)
-            .onChange(of: focusedFieldBinding) { oldValue, newValue in
-                fState.focusedField = newValue
-                handleFocusChange(from: oldValue, to: newValue)
-
-                if newValue == nil,
-                    !iState.isShowingSettings,
-                    !iState.isShowingSyncDiagnostics
-                {
-                    if let pending = fState.pendingFocus {
-                        focusedFieldBinding = pending
-                        fState.focusedField = pending
-                        fState.pendingFocus = nil
-                    } else {
-                        focusedFieldBinding = .scrollView
-                        fState.focusedField = .scrollView
+                .frame(maxWidth: .infinity, alignment: .topLeading)
+                .onGeometryChange(for: CGFloat.self) {
+                    $0.frame(in: .global).maxY
+                } action: {
+                    layoutStorage.contentBottomY = $0
+                }
+                .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)
+
+                    if newValue == nil,
+                        !iState.isShowingSettings,
+                        !iState.isShowingSyncDiagnostics
+                    {
+                        if let pending = fState.pendingFocus {
+                            focusedFieldBinding = pending
+                            fState.focusedField = pending
+                            fState.pendingFocus = nil
+                        } else {
+                            focusedFieldBinding = .scrollView
+                            fState.focusedField = .scrollView
+                        }
+                    }
 
-                if case .task(let id) = (newValue ?? fState.focusedField),
-                    draggedTaskID == nil,
-                    id != draftPrependRowID
-                {
-                    withAnimation {
-                        scrollProxy.scrollTo(id)
+                    if case .task(let id) = (newValue ?? fState.focusedField),
+                        draggedTaskID == nil,
+                        id != draftPrependRowID
+                    {
+                        withAnimation {
+                            scrollProxy.scrollTo(id)
+                        }
                     }
                 }
-            }
-            .onChange(of: fState.selectedTaskID) { _, newID in
-                if let newID, draggedTaskID == nil {
-                    guard newID != draftPrependRowID else { return }
-                    withAnimation {
-                        scrollProxy.scrollTo(newID)
+                .onChange(of: fState.selectedTaskID) { _, newID in
+                    if let newID, draggedTaskID == nil {
+                        guard newID != draftPrependRowID else { return }
+                        withAnimation {
+                            scrollProxy.scrollTo(newID)
+                        }
                     }
                 }
+              }
             }
-          }
-        }
-        .scrollDisabled(draggedTaskID != nil || iState.isSwiping)
-        .scrollBounceBehavior(.always)
-        .contentMargins(.bottom, 20)
-        .background {
-            Color.outerBackground.ignoresSafeArea()
-        }
-        .overlay {
-            if isCompletelyEmpty && draftPlacement == nil {
-                Text("Pull down to create")
-                    .font(TaskRowMetrics.hintSUI)
-                    .foregroundStyle(.secondary)
-                    .padding(.top, 24)
-                    .allowsHitTesting(false)
+            .scrollDisabled(draggedTaskID != nil || iState.isSwiping)
+            .scrollBounceBehavior(.always)
+            .contentMargins(.bottom, 20)
+            .onScrollGeometryChange(for: CGFloat.self) { geo in
+                max(0, geo.contentOffset.y + geo.contentInsets.top)
+            } action: { _, scrollUp in
+                pState.scrollUpAmount = scrollUp
+            }
+            .background {
+                Color.outerBackground.ignoresSafeArea()
+            }
+            .overlay {
+                if isCompletelyEmpty && draftPlacement == nil {
+                    Text("Pull down to create")
+                        .font(TaskRowMetrics.hintSUI)
+                        .foregroundStyle(.secondary)
+                        .padding(.top, 24)
+                        .allowsHitTesting(false)
+                }
+            }
+            .overlay(alignment: .bottom) {
+                pullToClearIndicatorRow
             }
-        }
-        .overlay(alignment: .bottom) {
-            pullToClearIndicatorRow
-        }
             .pullGestures(
                 pullToCreate: pullToCreateStateBinding,
                 pullUpOffset: pullUpOffsetStateBinding,
                 isDraftOpen: draftPlacement != nil,
                 hasCompletedTasks: !completedTasks.isEmpty,
-            pullCreateThreshold: pullCreateThreshold,
-            flickThreshold: flickThreshold,
-            pullClearThreshold: pullClearThreshold,
-            onCreateTaskAtTop: { revealPhantomRow() },
-            onClearCompleted: {
-                let ids = Set(completedTasks.map(\.id))
-                withAnimation(.easeIn(duration: 0.35)) {
-                    iState.clearingTaskIDs = ids
-                } completion: {
-                    iState.clearingTaskIDs = []
-                    clearCompletedTasksWithUndo()
+                pullCreateThreshold: pullCreateThreshold,
+                flickThreshold: flickThreshold,
+                pullClearThreshold: pullClearThreshold,
+                onCreateTaskAtTop: { revealPhantomRow() },
+                onClearCompleted: {
+                    let ids = Set(completedTasks.map(\.id))
+                    withAnimation(.easeIn(duration: 0.35)) {
+                        iState.clearingTaskIDs = ids
+                    } completion: {
+                        iState.clearingTaskIDs = []
+                        clearCompletedTasksWithUndo()
+                    }
                 }
-            }
-        )
+            )
+
+            navigationHeader
+                .padding(.top, 12)
+                .onGeometryChange(for: CGFloat.self) {
+                    $0.size.height
+                } action: {
+                    pState.headerHeight = $0
+                }
+                .offset(y: -pState.scrollUpAmount)
+                .background {
+                    Color.outerBackground
+                        .offset(y: -pState.scrollUpAmount)
+                }
+        }
     }
 }