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