commit e15625be11894c079eec2e10f477691308327c03
parent f38f29a986b61bcc3fa9dda5588be97137007dd2
Author: Michael Camilleri <[email protected]>
Date: Wed, 11 Mar 2026 15:29:03 +0900
Change visual effect for pull-to-create gesture
Prior to this commit, the visual effect for when a new row is added by
the pull-to-create gesture is for it to be reveal by 'appearing' from
the top of the list. This commit causes it to appear to be revealed by
the existing rows being pulled back (i.e. they sit above it).
Co-Authored-By: Claude 4.6 Opus <[email protected]>
Co-Authored-By: Codex GPT 5.4 <[email protected]>
Diffstat:
4 files changed, 146 insertions(+), 72 deletions(-)
diff --git a/ListlessiOS/Extensions/TaskListView+PullGestures.swift b/ListlessiOS/Extensions/TaskListView+PullGestures.swift
@@ -41,6 +41,9 @@ extension TaskListView {
) -> Action {
if newPhase == .interacting, oldPhase != .interacting {
pullStartTime = CACurrentMediaTime()
+ // Sync in case onScrollGeometryChange fired before this
+ // phase change, leaving indicatorOffset behind pullOffset.
+ indicatorOffset = pullOffset
}
isScrollInteracting = (newPhase == .interacting)
guard oldPhase == .interacting, newPhase != .interacting else { return .none }
diff --git a/ListlessiOS/Extensions/TaskListView+PullToCreate.swift b/ListlessiOS/Extensions/TaskListView+PullToCreate.swift
@@ -19,10 +19,7 @@ extension TaskListView {
func commitPhantomRow() {
let title = phantomTitle.trimmingCharacters(in: .whitespacesAndNewlines)
- // Hide phantom and collapse the indicator slot in one frame.
- var t = Transaction(animation: nil)
- t.disablesAnimations = true
- withTransaction(t) {
+ let collapse: () -> Void = {
phantomRowVisible = false
phantomTitle = ""
selectedTaskID = nil
@@ -32,6 +29,21 @@ extension TaskListView {
state.indicatorOffset = 0
pullToCreate = state
}
+
+ if title.isEmpty {
+ withAnimation(.spring(response: 0.24, dampingFraction: 0.95)) {
+ collapse()
+ }
+ } else {
+ // Keep the successful create swap immediate so focus can move
+ // straight into the real task row without a visible intermediate state.
+ var t = Transaction(animation: nil)
+ t.disablesAnimations = true
+ withTransaction(t) {
+ collapse()
+ }
+ }
+
focusedField = nil
guard !title.isEmpty else { return }
diff --git a/ListlessiOS/Views/PullToCreate.swift b/ListlessiOS/Views/PullToCreate.swift
@@ -3,10 +3,12 @@ import SwiftUI
struct PullToCreateIndicator: View {
let pullOffset: CGFloat
let threshold: CGFloat
+ let topTrailingRadius: CGFloat
- private var progress: CGFloat { min(1, pullOffset / threshold) }
+ static let indicatorHeight: CGFloat = 50
+
+ private var revealedHeight: CGFloat { min(pullOffset, Self.indicatorHeight) }
private var isReady: Bool { pullOffset >= threshold }
- private let indicatorHeight: CGFloat = 50
private let textSlideDistance: CGFloat = 22
var body: some View {
@@ -38,7 +40,7 @@ struct PullToCreateIndicator: View {
UnevenRoundedRectangle(
topLeadingRadius: 0, bottomLeadingRadius: 0,
bottomTrailingRadius: TaskRowMetrics.trailingCornerRadius,
- topTrailingRadius: TaskRowMetrics.trailingCornerRadius
+ topTrailingRadius: topTrailingRadius
)
)
.overlay(alignment: .leading) {
@@ -46,10 +48,21 @@ struct PullToCreateIndicator: View {
.fill(taskColor(forIndex: 0, total: 1))
.frame(width: TaskRowMetrics.accentBarWidth)
}
- // Reveal from the top downward as the user pulls
- .frame(height: min(pullOffset, indicatorHeight), alignment: .top)
- .clipped()
- .opacity(Double(progress))
+ .frame(height: Self.indicatorHeight, alignment: .top)
+ .mask(alignment: .top) {
+ Rectangle()
+ .frame(height: revealedHeight)
+ }
+ .background(alignment: .top) {
+ Color.taskCard
+ .frame(
+ height: min(
+ TaskRowMetrics.trailingCornerRadius,
+ Self.indicatorHeight - revealedHeight
+ )
+ )
+ .offset(y: revealedHeight)
+ }
.allowsHitTesting(false)
}
}
diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift
@@ -223,6 +223,33 @@ struct TaskListView: View, TaskListViewProtocol {
return (width + liftPoints) / width
}
+ private var pullToCreateRevealHeight: CGFloat {
+ min(
+ pullToCreate.indicatorDisplayOffset(threshold: pullCreateThreshold),
+ PullToCreateIndicator.indicatorHeight
+ )
+ }
+
+ private var pullToCreateGap: CGFloat {
+ guard pullToCreate.shouldShowIndicator, !iState.phantomRowVisible else { return 0 }
+ let exposedPull = pullToCreate.indicatorDisplayOffset(threshold: pullCreateThreshold)
+ return min(
+ vStackSpacing,
+ max(0, exposedPull - PullToCreateIndicator.indicatorHeight)
+ )
+ }
+
+ private var pullToCreateRowOverlap: CGFloat {
+ guard pullToCreate.shouldShowIndicator, !iState.phantomRowVisible, !displayActiveTasks.isEmpty else {
+ return 0
+ }
+ return PullToCreateIndicator.indicatorHeight - pullToCreateRevealHeight
+ }
+
+ private var pullToCreateIndicatorTopCornerRadius: CGFloat {
+ TaskRowMetrics.trailingCornerRadius
+ }
+
/// 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.
@@ -235,7 +262,8 @@ struct TaskListView: View, TaskListViewProtocol {
pullOffset: pullToCreate.indicatorDisplayOffset(
threshold: pullCreateThreshold
),
- threshold: pullCreateThreshold
+ threshold: pullCreateThreshold,
+ topTrailingRadius: pullToCreateIndicatorTopCornerRadius
)
.opacity(showPhantom ? 0 : 1)
@@ -245,6 +273,11 @@ struct TaskListView: View, TaskListViewProtocol {
// Instant swap — no animation on height or opacity.
.animation(nil, value: showPhantom)
}
+ .frame(
+ height: showPhantom ? nil : PullToCreateIndicator.indicatorHeight,
+ alignment: .top
+ )
+ .animation(nil, value: showPhantom)
}
}
@@ -298,6 +331,68 @@ struct TaskListView: View, TaskListViewProtocol {
}
}
+ @ViewBuilder private var taskRows: some View {
+ ForEach(Array(displayActiveTasks.enumerated()), id: \.element.id) { index, task in
+ let taskID = task.id
+ TaskRowView(
+ task: task,
+ taskID: taskID,
+ index: index,
+ totalTasks: displayActiveTasks.count,
+ isSelected: selectedTaskID == taskID,
+ isDragging: isDraggingStateBinding,
+ isLastActiveTask: index == displayActiveTasks.count - 1,
+ focusedField: $focusedFieldBinding,
+ onToggle: { toggleCompletion($0) },
+ onTitleChange: { updateTitle($0, $1) },
+ onDelete: { deleteTaskWithUndo($0) },
+ onSelect: { selectTask($0) },
+ onStartEdit: { startEditing($0) },
+ onEndEdit: {
+ selectedTaskID = nil
+ endEditing($0, shouldCreateNewTask: $1)
+ }
+ )
+ .scaleEffect(draggedTaskID == taskID ? dragScaleEffect(for: taskID) : 1.0)
+ .shadow(
+ color: draggedTaskID == taskID ? .black.opacity(0.3) : .clear,
+ radius: 12, y: 4
+ )
+ .zIndex(draggedTaskID == taskID ? 2 : 1)
+ .taskDragGesture(
+ isActive: !task.isCompleted,
+ taskID: taskID,
+ onDragStart: { startDrag(taskID: taskID) },
+ onDragChanged: { point in handleIOSDragChanged(taskID: taskID, point: point) },
+ onDragEnded: { commitIOSDrag() }
+ )
+ .onGeometryChange(for: CGRect.self) { proxy in
+ proxy.frame(in: .global)
+ } action: { frame in
+ rowFrames[taskID] = frame
+ }
+ .id(taskID)
+ }
+
+ ForEach(completedTasks) { task in
+ let taskID = task.id
+ let isBeingCleared = iState.clearingTaskIDs.contains(taskID)
+ TaskRowView(
+ task: task,
+ taskID: taskID,
+ isSelected: selectedTaskID == taskID,
+ focusedField: $focusedFieldBinding,
+ onToggle: { toggleCompletion($0) },
+ onTitleChange: { updateTitle($0, $1) },
+ onDelete: { deleteTaskWithUndo($0) },
+ onSelect: { selectTask($0) }
+ )
+ .opacity(isBeingCleared ? 0 : 1)
+ .offset(y: isBeingCleared ? 40 : 0)
+ .id(taskID)
+ }
+ }
+
var body: some View {
taskScrollView
.contentShape(Rectangle())
@@ -370,67 +465,18 @@ struct TaskListView: View, TaskListViewProtocol {
ScrollView {
ScrollViewReader { scrollProxy in
VStack(alignment: .leading, spacing: vStackSpacing) {
- navigationHeader
- pullToCreateIndicatorRow
- ForEach(Array(displayActiveTasks.enumerated()), id: \.element.id) { index, task in
- let taskID = task.id
- TaskRowView(
- task: task,
- taskID: taskID,
- index: index,
- totalTasks: displayActiveTasks.count,
- isSelected: selectedTaskID == taskID,
- isDragging: isDraggingStateBinding,
- isLastActiveTask: index == displayActiveTasks.count - 1,
- focusedField: $focusedFieldBinding,
- onToggle: { toggleCompletion($0) },
- onTitleChange: { updateTitle($0, $1) },
- onDelete: { deleteTaskWithUndo($0) },
- onSelect: { selectTask($0) },
- onStartEdit: { startEditing($0) },
- onEndEdit: {
- selectedTaskID = nil
- endEditing($0, shouldCreateNewTask: $1)
- }
- )
- .scaleEffect(draggedTaskID == taskID ? dragScaleEffect(for: taskID) : 1.0)
- .shadow(
- color: draggedTaskID == taskID ? .black.opacity(0.3) : .clear,
- radius: 12, y: 4
- )
- .zIndex(draggedTaskID == taskID ? 1 : 0)
- .taskDragGesture(
- isActive: !task.isCompleted,
- taskID: taskID,
- onDragStart: { startDrag(taskID: taskID) },
- onDragChanged: { point in handleIOSDragChanged(taskID: taskID, point: point) },
- onDragEnded: { commitIOSDrag() }
- )
- .onGeometryChange(for: CGRect.self) { proxy in
- proxy.frame(in: .global)
- } action: { frame in
- rowFrames[taskID] = frame
- }
- .id(taskID)
- }
-
- ForEach(completedTasks) { task in
- let taskID = task.id
- let isBeingCleared = iState.clearingTaskIDs.contains(taskID)
- TaskRowView(
- task: task,
- taskID: taskID,
- isSelected: selectedTaskID == taskID,
- focusedField: $focusedFieldBinding,
- onToggle: { toggleCompletion($0) },
- onTitleChange: { updateTitle($0, $1) },
- onDelete: { deleteTaskWithUndo($0) },
- onSelect: { selectTask($0) }
- )
- .opacity(isBeingCleared ? 0 : 1)
- .offset(y: isBeingCleared ? 40 : 0)
- .id(taskID)
+ VStack(alignment: .leading, spacing: 0) {
+ navigationHeader
+ pullToCreateIndicatorRow
+ .padding(.top, vStackSpacing)
}
+ .padding(
+ .bottom,
+ (pullToCreate.shouldShowIndicator && !iState.phantomRowVisible)
+ ? (pullToCreateGap - vStackSpacing) : 0
+ )
+ taskRows
+ .offset(y: -pullToCreateRowOverlap)
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.trailing, 16)