listless

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

commit 0ea68133bf492fe7f8038b08e5073f1b2e2a995c
parent b56a533c2d4dcc3b519c5c5c5140d43d9e13d69b
Author: Michael Camilleri <[email protected]>
Date:   Thu, 26 Feb 2026 06:59:21 +0900

Avoid multiple updates during SwiftUI

Co-Authored-By: Codex GPT 5.3 <[email protected]>

Diffstat:
MListlessMac/Helpers/ClickableTextField.swift | 19+++++++++++++++++--
MListlessMac/Views/TaskRowView.swift | 8++++----
MListlessiOS/Helpers/TappableTextField.swift | 9+++++++--
MListlessiOS/Views/TaskRowView.swift | 8++++----
4 files changed, 32 insertions(+), 12 deletions(-)

diff --git a/ListlessMac/Helpers/ClickableTextField.swift b/ListlessMac/Helpers/ClickableTextField.swift @@ -19,6 +19,7 @@ struct ClickableTextField: NSViewRepresentable { @Binding var text: String let isCompleted: Bool let onEditingChanged: (Bool, _ shouldCreateNewTask: Bool) -> Void + var onContentChange: ((String) -> Void)? = nil func makeNSView(context: Context) -> ClickableNSTextField { let textField = ClickableNSTextField() @@ -86,7 +87,7 @@ struct ClickableTextField: NSViewRepresentable { } func makeCoordinator() -> Coordinator { - Coordinator(text: $text, onEditingChanged: onEditingChanged) + Coordinator(text: $text, onEditingChanged: onEditingChanged, onContentChange: onContentChange) } enum EditEndReason { @@ -129,14 +130,17 @@ struct ClickableTextField: NSViewRepresentable { final class Coordinator: NSObject, NSTextFieldDelegate { @Binding var text: String let onEditingChanged: (Bool, _ shouldCreateNewTask: Bool) -> Void + let onContentChange: ((String) -> Void)? var editEndReason: EditEndReason = .focusLost init( text: Binding<String>, - onEditingChanged: @escaping (Bool, _ shouldCreateNewTask: Bool) -> Void + onEditingChanged: @escaping (Bool, _ shouldCreateNewTask: Bool) -> Void, + onContentChange: ((String) -> Void)? = nil ) { _text = text self.onEditingChanged = onEditingChanged + self.onContentChange = onContentChange } @MainActor @@ -155,11 +159,21 @@ struct ClickableTextField: NSViewRepresentable { string: text, attributes: attributes) } + private var hasNotifiedEditingStarted = false + func handleBecomeFirstResponder() { + hasNotifiedEditingStarted = true + onEditingChanged(true, false) + } + + func controlTextDidBeginEditing(_ obj: Notification) { + guard !hasNotifiedEditingStarted else { return } + hasNotifiedEditingStarted = true onEditingChanged(true, false) } func controlTextDidEndEditing(_ obj: Notification) { + hasNotifiedEditingStarted = false let shouldCreateNewTask = editEndReason == .returnKey editEndReason = .focusLost // Reset for next time onEditingChanged(false, shouldCreateNewTask) @@ -168,6 +182,7 @@ struct ClickableTextField: NSViewRepresentable { func controlTextDidChange(_ obj: Notification) { guard let textField = obj.object as? NSTextField else { return } text = textField.stringValue + onContentChange?(textField.stringValue) } func control( diff --git a/ListlessMac/Views/TaskRowView.swift b/ListlessMac/Views/TaskRowView.swift @@ -90,6 +90,10 @@ struct TaskRowView: View { } else { onEndEdit(taskID, shouldCreateNewTask) } + }, + onContentChange: { newTitle in + guard !task.isCompleted else { return } + onTitleChange(task, newTitle) } ) .focused($focusedField, equals: .task(taskID)) @@ -151,10 +155,6 @@ struct TaskRowView: View { onDelete(task) } } - .onChange(of: editingTitle) { - guard !task.isCompleted else { return } - onTitleChange(task, editingTitle) - } .onChange(of: task.title) { _, newValue in // Keep editingTitle in sync with task.title when not editing if !isCurrentlyEditing { diff --git a/ListlessiOS/Helpers/TappableTextField.swift b/ListlessiOS/Helpers/TappableTextField.swift @@ -8,6 +8,7 @@ struct TappableTextField: UIViewRepresentable { @Binding var text: String let isCompleted: Bool let onEditingChanged: (Bool, _ shouldCreateNewTask: Bool) -> Void + var onContentChange: ((String) -> Void)? = nil func makeUIView(context: Context) -> UITextView { let textView = UITextView() @@ -55,7 +56,7 @@ struct TappableTextField: UIViewRepresentable { } func makeCoordinator() -> Coordinator { - Coordinator(text: $text, onEditingChanged: onEditingChanged) + Coordinator(text: $text, onEditingChanged: onEditingChanged, onContentChange: onContentChange) } private func applyStyle(to textView: UITextView, text: String, isCompleted: Bool) { @@ -73,18 +74,22 @@ struct TappableTextField: UIViewRepresentable { final class Coordinator: NSObject, UITextViewDelegate { @Binding var text: String let onEditingChanged: (Bool, _ shouldCreateNewTask: Bool) -> Void + let onContentChange: ((String) -> Void)? var returnKeyPressed: Bool = false init( text: Binding<String>, - onEditingChanged: @escaping (Bool, _ shouldCreateNewTask: Bool) -> Void + onEditingChanged: @escaping (Bool, _ shouldCreateNewTask: Bool) -> Void, + onContentChange: ((String) -> Void)? = nil ) { _text = text self.onEditingChanged = onEditingChanged + self.onContentChange = onContentChange } func textViewDidChange(_ textView: UITextView) { text = textView.text + onContentChange?(textView.text) if let placeholder = textView.viewWithTag(100) as? UILabel { placeholder.isHidden = !textView.text.isEmpty } diff --git a/ListlessiOS/Views/TaskRowView.swift b/ListlessiOS/Views/TaskRowView.swift @@ -79,6 +79,10 @@ struct TaskRowView: View { if editing { onStartEdit(taskID) } else { onEndEdit(taskID, shouldCreateNewTask) } } + }, + onContentChange: { newTitle in + guard !task.isCompleted else { return } + onTitleChange(task, newTitle) } ) .focused($focusedField, equals: .task(taskID)) @@ -138,10 +142,6 @@ struct TaskRowView: View { editingTitle = task.title cachedAccentColor = computeAccentColor() } - .onChange(of: editingTitle) { - guard !task.isCompleted else { return } - onTitleChange(task, editingTitle) - } .onChange(of: task.title) { _, newValue in if !isCurrentlyEditing { editingTitle = newValue