listless

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

commit ee71286186f5f7444721b10410d055437707777a
parent 8152a8c0f1b94950ce0d06c48501cc963d1e1731
Author: Michael Camilleri <[email protected]>
Date:   Wed, 11 Mar 2026 06:56:25 +0900

Optimise appearance of rows with pull-to-create

When using pull-to-create to add a row, I perceive a noticeable lag as
the dummy row (the SwiftUI row that appears during the gesture)
transitions to the UIKit row. This commit attempts to optimise for this
by reducing the amount of time spent pulling information from the
database as well as having a UIKit row be already in the view hierarchy.

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

Diffstat:
MListless/Models/TaskStore.swift | 51+++++++++++++++++++++++++++++++++++++--------------
MListlessiOS/Extensions/TaskListView+PullGestures.swift | 2++
MListlessiOS/Extensions/TaskListView+PullToCreate.swift | 47+++++++++++++++++++++++++++++++++++++++++------
MListlessiOS/Views/PullToCreate.swift | 52+++++++++++++++++++++++++++-------------------------
MListlessiOS/Views/TaskListView.swift | 98++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
5 files changed, 204 insertions(+), 46 deletions(-)

diff --git a/Listless/Models/TaskStore.swift b/Listless/Models/TaskStore.swift @@ -50,27 +50,50 @@ final class TaskStore { } func createTask(title: String = "", atBeginning: Bool = false, sortOrder: Int64? = nil) throws -> TaskItem { - let task = TaskItem(context: context) - task.title = title - + // Compute sort order before inserting the new object so we don't need + // processPendingChanges() and the new task can't appear in our own query. + let resolvedSortOrder: Int64 if let sortOrder { - task.sortOrder = sortOrder + resolvedSortOrder = sortOrder + } else if atBeginning { + let minOrder = try minActiveSortOrder() ?? 0 + resolvedSortOrder = minOrder - 1000 } else { - context.processPendingChanges() - let activeTasks = try fetchTasks().filter { !$0.isCompleted } - - if atBeginning { - let minOrder = activeTasks.map(\.sortOrder).min() ?? 0 - task.sortOrder = minOrder - 1000 - } else { - let maxOrder = activeTasks.map(\.sortOrder).max() ?? -1000 - task.sortOrder = maxOrder + 1000 - } + let maxOrder = try maxActiveSortOrder() ?? -1000 + resolvedSortOrder = maxOrder + 1000 } + let task = TaskItem(context: context) + task.title = title + task.sortOrder = resolvedSortOrder + return task } + private func minActiveSortOrder() throws -> Int64? { + let request = TaskItem.fetchRequest() + request.predicate = NSPredicate(format: "completedOrder == 0") + request.sortDescriptors = [NSSortDescriptor(keyPath: \TaskItem.sortOrder, ascending: true)] + request.fetchLimit = 1 + do { + return try context.fetch(request).first?.sortOrder + } catch { + throw TaskStoreError.fetchFailed(error) + } + } + + private func maxActiveSortOrder() throws -> Int64? { + let request = TaskItem.fetchRequest() + request.predicate = NSPredicate(format: "completedOrder == 0") + request.sortDescriptors = [NSSortDescriptor(keyPath: \TaskItem.sortOrder, ascending: false)] + request.fetchLimit = 1 + do { + return try context.fetch(request).first?.sortOrder + } catch { + throw TaskStoreError.fetchFailed(error) + } + } + func complete(taskID: UUID) throws { guard let task = try findTask(id: taskID) else { return } diff --git a/ListlessiOS/Extensions/TaskListView+PullGestures.swift b/ListlessiOS/Extensions/TaskListView+PullGestures.swift @@ -25,6 +25,8 @@ extension TaskListView { } mutating func updatePullDistance(_ distance: CGFloat) { + // Skip duplicate writes to break onScrollGeometryChange re-layout cycles. + guard pullOffset != distance else { return } pullOffset = distance if isScrollInteracting { indicatorOffset = distance diff --git a/ListlessiOS/Extensions/TaskListView+PullToCreate.swift b/ListlessiOS/Extensions/TaskListView+PullToCreate.swift @@ -1,12 +1,47 @@ import SwiftUI extension TaskListView { - @ViewBuilder var pullToCreateIndicatorRow: some View { - if pullToCreate.shouldShowIndicator { - PullToCreateIndicator( - pullOffset: pullToCreate.indicatorDisplayOffset(threshold: pullCreateThreshold), - threshold: pullCreateThreshold - ) + + // MARK: - Phantom Entry Row Helpers + + /// Show the phantom row and focus its text field. + func revealPhantomRow() -> UUID { + phantomTitle = "" + phantomRowVisible = true + pendingFocus = .task(Self.phantomRowID) + focusedField = .task(Self.phantomRowID) + selectedTaskID = Self.phantomRowID + return Self.phantomRowID + } + + /// Commit the phantom row: create a real task if the user typed something, + /// then hide the phantom and reset its state. + 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) { + phantomRowVisible = false + phantomTitle = "" + selectedTaskID = nil + var state = pullToCreate + state.isInsertionPending = false + state.pendingTaskID = nil + state.indicatorOffset = 0 + pullToCreate = state + } + focusedField = nil + + guard !title.isEmpty else { return } + + do { + let task = try store.createTask(title: title, atBeginning: true) + try store.save() + selectedTaskID = task.id + } catch { + presentStoreError(error) } } } diff --git a/ListlessiOS/Views/PullToCreate.swift b/ListlessiOS/Views/PullToCreate.swift @@ -6,37 +6,34 @@ struct PullToCreateIndicator: View { private var progress: CGFloat { min(1, pullOffset / threshold) } private var isReady: Bool { pullOffset >= threshold } - private let indicatorHeight: CGFloat = 48 + private let indicatorHeight: CGFloat = 50 private let textSlideDistance: CGFloat = 22 var body: some View { - HStack(spacing: 0) { - Rectangle() - .fill(taskColor(forIndex: 0, total: 1)) - .frame(width: TaskRowMetrics.accentBarWidth) - HStack(alignment: .center, spacing: TaskRowMetrics.contentSpacing) { - Image(systemName: "circle") - .foregroundStyle(Color.secondary) - .font(.system(size: 17)) - ZStack(alignment: .leading) { - Text("Release to add") - .offset(y: isReady ? 0 : -textSlideDistance) - Text("New item") - .offset(y: isReady ? textSlideDistance : 0) - } - .foregroundStyle(.secondary) - .font(TaskRowMetrics.bodySUI) - .frame(height: textSlideDistance, alignment: .topLeading) - .clipped() - .animation(.easeInOut(duration: 0.18), value: isReady) - Spacer() + HStack(alignment: .center, spacing: TaskRowMetrics.contentSpacing) { + Image(systemName: "circle") + .frame(width: 22, height: 22) + .foregroundStyle(Color.secondary) + .font(.system(size: 17)) + ZStack(alignment: .leading) { + Text("Release to add") + .offset(y: isReady ? 0 : -textSlideDistance) + Text("New item") + .offset(y: isReady ? textSlideDistance : 0) } - .padding(.vertical, TaskRowMetrics.contentVerticalPadding) - .padding(.horizontal, TaskRowMetrics.contentHorizontalPadding) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.taskCard) + .foregroundStyle(.secondary) + .font(TaskRowMetrics.bodySUI) + .frame(height: textSlideDistance, alignment: .topLeading) + .clipped() + .animation(.easeInOut(duration: 0.18), value: isReady) + Spacer() } + .padding(.vertical, TaskRowMetrics.contentVerticalPadding) + .padding(.trailing, TaskRowMetrics.contentHorizontalPadding) + .padding(.leading, TaskRowMetrics.activeLeadingPadding) .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .background(Color.taskCard) .clipShape( UnevenRoundedRectangle( topLeadingRadius: 0, bottomLeadingRadius: 0, @@ -44,6 +41,11 @@ struct PullToCreateIndicator: View { topTrailingRadius: TaskRowMetrics.trailingCornerRadius ) ) + .overlay(alignment: .leading) { + Rectangle() + .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() diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift @@ -18,8 +18,12 @@ struct TaskListView: View, TaskListViewProtocol { var clearingTaskIDs: Set<UUID> = [] var rowFrames: [UUID: CGRect] = [:] var undoToast: UndoToastData? = nil + var phantomRowVisible: Bool = false + var phantomTitle: String = "" } + static let phantomRowID = UUID() + struct TaskStateData { var refreshID = UUID() } @@ -93,6 +97,23 @@ struct TaskListView: View, TaskListViewProtocol { nonmutating set { iState.undoToast = newValue } } + var phantomRowVisible: Bool { + get { iState.phantomRowVisible } + nonmutating set { iState.phantomRowVisible = newValue } + } + + var phantomTitle: String { + get { iState.phantomTitle } + nonmutating set { iState.phantomTitle = newValue } + } + + var phantomTitleBinding: Binding<String> { + Binding( + get: { iState.phantomTitle }, + set: { iState.phantomTitle = $0 } + ) + } + private var isDraggingStateBinding: Binding<Bool> { Binding( get: { iState.isDragging }, @@ -202,6 +223,81 @@ struct TaskListView: View, TaskListViewProtocol { return (width + liftPoints) / width } + /// 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 = pullToCreate.shouldShowIndicator + let showPhantom = iState.phantomRowVisible + if showIndicator || showPhantom { + ZStack(alignment: .topLeading) { + PullToCreateIndicator( + pullOffset: pullToCreate.indicatorDisplayOffset( + threshold: pullCreateThreshold + ), + threshold: pullCreateThreshold + ) + .opacity(showPhantom ? 0 : 1) + + phantomEntryRowContent + .frame(height: showPhantom ? nil : 0) + .opacity(showPhantom ? 1 : 0) + // Instant swap — no animation on height or opacity. + .animation(nil, value: showPhantom) + } + } + } + + /// The phantom row content styled to match a task row. Controlled by the + /// ZStack in ``pullToCreateIndicatorRow`` rather than its own visibility. + @ViewBuilder private var phantomEntryRowContent: some View { + let accentColor = taskColor( + forIndex: 0, total: max(1, displayActiveTasks.count + 1) + ) + HStack(alignment: .center, spacing: TaskRowMetrics.contentSpacing) { + Image(systemName: "circle") + .frame(width: 22, height: 22) + .foregroundStyle(Color.secondary) + .font(.system(size: 17)) + + TappableTextField( + text: phantomTitleBinding, + isCompleted: false, + isDragging: false, + onEditingChanged: { editing, _ in + DispatchQueue.main.async { + if !editing { + commitPhantomRow() + } + } + }, + returnKeyType: .done + ) + .focused($focusedFieldBinding, equals: .task(Self.phantomRowID)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, TaskRowMetrics.contentVerticalPadding) + .padding(.trailing, TaskRowMetrics.contentHorizontalPadding) + .padding(.leading, TaskRowMetrics.activeLeadingPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .background { + Color.taskCard.overlay(accentColor.opacity(0.15)) + } + .clipShape( + UnevenRoundedRectangle( + topLeadingRadius: 0, bottomLeadingRadius: 0, + bottomTrailingRadius: TaskRowMetrics.trailingCornerRadius, + topTrailingRadius: TaskRowMetrics.trailingCornerRadius + ) + ) + .overlay(alignment: .leading) { + Rectangle() + .fill(accentColor) + .frame(width: TaskRowMetrics.accentBarWidth) + } + } + var body: some View { taskScrollView .contentShape(Rectangle()) @@ -402,7 +498,7 @@ struct TaskListView: View, TaskListViewProtocol { pullCreateThreshold: pullCreateThreshold, flickThreshold: flickThreshold, pullClearThreshold: pullClearThreshold, - onCreateTaskAtTop: { createNewTaskAtTop() }, + onCreateTaskAtTop: { revealPhantomRow() }, onClearCompleted: { let ids = Set(completedTasks.map(\.id)) withAnimation(.easeIn(duration: 0.35)) {