listless

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

commit 04d7e6464d849c87405ac771f569d86f923f2159
parent 594a00c2a65f44275fcf4860fcb5ed21ba51400a
Author: Michael Camilleri <[email protected]>
Date:   Tue, 24 Mar 2026 17:59:00 +0900

Improve scrolling performance in iOS version

During beta testing, the scrolling performance is not smooth enough.
This commit reworks some of the layout logic to improve performance as
well as adding an FPS overlay for use on real devices.

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

Diffstat:
MListless.xcodeproj/project.pbxproj | 4++++
MListlessiOS/Extensions/TaskListView+Drag.swift | 6++++--
AListlessiOS/Helpers/FPSOverlay.swift | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MListlessiOS/Helpers/TaskRowDragGesture.swift | 55+++++++++++++------------------------------------------
MListlessiOS/Views/TaskListView.swift | 44++++++++++++++++++++++++++++----------------
5 files changed, 105 insertions(+), 60 deletions(-)

diff --git a/Listless.xcodeproj/project.pbxproj b/Listless.xcodeproj/project.pbxproj @@ -90,6 +90,7 @@ DEE187A790A4058FE4AFDB2E /* PlatformTextFieldWidthModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8448C5778F75F52719114AF /* PlatformTextFieldWidthModifier.swift */; }; E12C1304464FC7799856B2BA /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75B048B19C5219862BBED2E7 /* TestHelpers.swift */; }; E14F8177232BBC75FEEE1E2C /* TaskStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 967F7ECEB3915CEDCE584872 /* TaskStoreTests.swift */; }; + E2FC6CF95A2C59CA147172EE /* FPSOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA6BD768FB1788D0BC58589F /* FPSOverlay.swift */; }; E429067963379F99DD184FED /* CloudKitSyncMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3E82F6093EEFC94A41FED9 /* CloudKitSyncMonitor.swift */; }; E47136CA7428927395D8C7C7 /* PullToCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFF7D84B54AE70036D205CA4 /* PullToCreate.swift */; }; E4BD761E34CBB84CE80F7F49 /* CloudKitErrorClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A677CC11ACE0BF743AFCE5 /* CloudKitErrorClassifier.swift */; }; @@ -231,6 +232,7 @@ D43D37CE25806380C0B13466 /* BuildNumber.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BuildNumber.xcconfig; sourceTree = "<group>"; }; D57C3CC81C4380EFAE4DB910 /* TaskRowDragGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowDragGesture.swift; sourceTree = "<group>"; }; D640E7D21735C62A30A26DA4 /* ClickableTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickableTextField.swift; sourceTree = "<group>"; }; + DA6BD768FB1788D0BC58589F /* FPSOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FPSOverlay.swift; sourceTree = "<group>"; }; DC3DEE364304587D280C5672 /* TaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskStore.swift; sourceTree = "<group>"; }; E06485DBE35B60868E14202A /* TaskRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskRowView.swift; sourceTree = "<group>"; }; E2174C43654733E9D4023157 /* PlatformTextFieldWidthModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTextFieldWidthModifier.swift; sourceTree = "<group>"; }; @@ -252,6 +254,7 @@ children = ( F1E998119283F784B9ADEE28 /* AppColors.swift */, 46C46FC97E6DB6FF81AC5C22 /* AppCommands.swift */, + DA6BD768FB1788D0BC58589F /* FPSOverlay.swift */, B7588879D0FA1C2A8BCEF14F /* HoverCursorModifier.swift */, E8B5E429B253183F887C5FD6 /* KeyCommandBridge.swift */, FA5D58EC1FBAA96E83A79445 /* PlatformScrollIndicatorsModifier.swift */, @@ -810,6 +813,7 @@ CAB42FAA253E2B347AB0594B /* CloudKitErrorClassifier.swift in Sources */, 889DCB2BB3C01DDA281EA81A /* CloudKitSyncMonitor.swift in Sources */, 0F2E6817C315B947033DA2BE /* DraftRowView.swift in Sources */, + E2FC6CF95A2C59CA147172EE /* FPSOverlay.swift in Sources */, E60F81C7B930AACE67746759 /* HoverCursorModifier.swift in Sources */, 12E43D0CD9124037022E3C38 /* KeyCommandBridge.swift in Sources */, 19699EC4FF57EF0D636B65E3 /* KeyValueSyncBridge.swift in Sources */, diff --git a/ListlessiOS/Extensions/TaskListView+Drag.swift b/ListlessiOS/Extensions/TaskListView+Drag.swift @@ -4,8 +4,10 @@ extension TaskListView { func handleIOSDragChanged(taskID: UUID, point: CGPoint) { guard let draggedID = draggedTaskID, var order = visualOrder, - let currentIndex = order.firstIndex(of: draggedID), - let draggedFrame = layoutStorage.rowFrames[draggedID] else { return } + let currentIndex = order.firstIndex(of: draggedID) else { return } + + let draggedFrame = layoutStorage.draggedRowFrame + guard draggedFrame != .zero else { return } let threshold = draggedFrame.height * 0.2 diff --git a/ListlessiOS/Helpers/FPSOverlay.swift b/ListlessiOS/Helpers/FPSOverlay.swift @@ -0,0 +1,56 @@ +import SwiftUI +import UIKit + +struct FPSOverlay: View { + @State private var fps: Int = 0 + @State private var monitor = FPSMonitor() + + var body: some View { + Text("\(fps) FPS") + .font(.system(size: 12, weight: .bold, design: .monospaced)) + .foregroundStyle(fps >= 55 ? .green : fps >= 45 ? .yellow : .red) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.black.opacity(0.7), in: Capsule()) + .onAppear { + monitor.start { fps = $0 } + } + .onDisappear { + monitor.stop() + } + } +} + +@MainActor +private class FPSMonitor { + private var displayLink: CADisplayLink? + private var lastTimestamp: CFTimeInterval = 0 + private var frameCount: Int = 0 + private var onUpdate: ((Int) -> Void)? + + func start(onUpdate: @escaping (Int) -> Void) { + self.onUpdate = onUpdate + let link = CADisplayLink(target: self, selector: #selector(tick)) + link.add(to: .main, forMode: .common) + displayLink = link + } + + func stop() { + displayLink?.invalidate() + displayLink = nil + } + + @objc private func tick(_ link: CADisplayLink) { + if lastTimestamp == 0 { + lastTimestamp = link.timestamp + return + } + frameCount += 1 + let elapsed = link.timestamp - lastTimestamp + if elapsed >= 1.0 { + onUpdate?(frameCount) + frameCount = 0 + lastTimestamp = link.timestamp + } + } +} diff --git a/ListlessiOS/Helpers/TaskRowDragGesture.swift b/ListlessiOS/Helpers/TaskRowDragGesture.swift @@ -4,7 +4,7 @@ extension View { func taskDragGesture( isActive: Bool, taskID: UUID, - onDragStart: @escaping () -> Void, + onDragStart: @escaping (CGFloat) -> Void, onDragChanged: @escaping (CGPoint) -> Void, onDragEnded: @escaping () -> Void ) -> some View { @@ -22,43 +22,20 @@ extension View { struct TaskRowDragGesture: ViewModifier { let isActive: Bool let taskID: UUID - let onDragStart: () -> Void + let onDragStart: (CGFloat) -> Void let onDragChanged: (CGPoint) -> Void let onDragEnded: () -> Void func body(content: Content) -> some View { - if #available(iOS 18, *) { - content - .gesture( - SimultaneousDragGesture( - isActive: isActive, - onDragStart: onDragStart, - onDragChanged: onDragChanged, - onDragEnded: onDragEnded - ) + content + .gesture( + SimultaneousDragGesture( + isActive: isActive, + onDragStart: onDragStart, + onDragChanged: onDragChanged, + onDragEnded: onDragEnded ) - } else { - content - .simultaneousGesture( - LongPressGesture(minimumDuration: 0.4) - .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .global)) - .onChanged { value in - switch value { - case .second(true, let drag): - onDragStart() - if let drag { - onDragChanged(drag.location) - } - default: - break - } - } - .onEnded { _ in - onDragEnded() - }, - including: isActive ? .all : .none - ) - } + ) } } @@ -68,10 +45,9 @@ struct TaskRowDragGesture: ViewModifier { /// UIGestureRecognizerRepresentable to avoid iOS 26's child-gesture-blocks- /// ancestor issue. The delegate returns shouldRecognizeSimultaneouslyWith:true /// so the ScrollView's pan gesture is preserved. -@available(iOS 18.0, *) private struct SimultaneousDragGesture: UIGestureRecognizerRepresentable { let isActive: Bool - let onDragStart: () -> Void + let onDragStart: (CGFloat) -> Void let onDragChanged: (CGPoint) -> Void let onDragEnded: () -> Void @@ -92,9 +68,8 @@ private struct SimultaneousDragGesture: UIGestureRecognizerRepresentable { ) { switch recognizer.state { case .began: - // Long press completed — fire drag start immediately so the row - // lifts visually before any finger movement. - onDragStart() + let width = recognizer.view?.bounds.width ?? 0 + onDragStart(width) case .changed: let location = recognizer.location(in: recognizer.view?.window) @@ -124,10 +99,6 @@ private struct SimultaneousDragGesture: UIGestureRecognizerRepresentable { _ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer ) -> Bool { - // Make text view gestures (loupe, selection) wait for the drag - // gesture to fail before they can fire. If the drag succeeds, - // the text view gesture is cancelled — preventing the loupe - // from appearing during drag. otherGestureRecognizer.view is UITextView } } diff --git a/ListlessiOS/Views/TaskListView.swift b/ListlessiOS/Views/TaskListView.swift @@ -3,7 +3,8 @@ import UIKit struct TaskListView: View, TaskListViewProtocol { class LayoutStorage { - var rowFrames: [UUID: CGRect] = [:] + var draggedRowWidth: CGFloat = 0 + var draggedRowFrame: CGRect = .zero var contentBottomY: CGFloat = 0 } @@ -24,7 +25,6 @@ struct TaskListView: View, TaskListViewProtocol { var pullToCreate = PullToCreateState() var pullUpOffset: CGFloat = 0 - var scrollUpAmount: CGFloat = 0 var headerHeight: CGFloat = 60 } @@ -232,9 +232,10 @@ struct TaskListView: View, TaskListViewProtocol { iState.isShowingSettings = true } - private func dragScaleEffect(for taskID: UUID) -> CGFloat { + private func dragScaleEffect() -> CGFloat { let liftPoints: CGFloat = 20 - guard let width = layoutStorage.rowFrames[taskID]?.width, width > 0 else { return 1.05 } + let width = layoutStorage.draggedRowWidth + guard width > 0 else { return 1.05 } return (width + liftPoints) / width } @@ -348,7 +349,7 @@ struct TaskListView: View, TaskListViewProtocol { endEditing($0, shouldCreateNewTask: $1) } ) - .scaleEffect(draggedTaskID == taskID ? dragScaleEffect(for: taskID) : 1.0) + .scaleEffect(draggedTaskID == taskID ? dragScaleEffect() : 1.0) .shadow( color: draggedTaskID == taskID ? .black.opacity(0.3) : .clear, radius: 12, y: 4 @@ -356,14 +357,24 @@ struct TaskListView: View, TaskListViewProtocol { .taskDragGesture( isActive: !task.isCompleted && focusedFieldBinding != .task(taskID), taskID: taskID, - onDragStart: { startDrag(taskID: taskID) }, - onDragChanged: { point in handleIOSDragChanged(taskID: taskID, point: point) }, + onDragStart: { width in + layoutStorage.draggedRowWidth = width + 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 - layoutStorage.rowFrames[taskID] = frame + .background { + if draggedTaskID == taskID { + Color.clear + .onGeometryChange(for: CGRect.self) { proxy in + proxy.frame(in: .global) + } action: { frame in + layoutStorage.draggedRowFrame = frame + } + } } .padding(.bottom, rowGap) .zIndex(draggedTaskID == taskID ? 2 : 1) @@ -395,6 +406,12 @@ struct TaskListView: View, TaskListViewProtocol { var body: some View { taskScrollView + .overlay(alignment: .topTrailing) { + FPSOverlay() + .padding(.top, 4) + .padding(.trailing, 8) + .allowsHitTesting(false) + } .simultaneousGesture( SpatialTapGesture(coordinateSpace: .global).onEnded { value in guard value.location.y > layoutStorage.contentBottomY else { return } @@ -525,11 +542,6 @@ struct TaskListView: View, TaskListViewProtocol { .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() }