← All repositories

listless

A simple list app for Apple platforms

git clone https://code.inqk.net/listless.git


ListlessiOS/Helpers/TappableTextField.swift (8937B)

1import SwiftUI
2import UIKit
3
4/// UITextView that's always present, manages its own editing state, and expands
5/// vertically to fit its content. Mirrors the interface of ClickableTextField (macOS)
6/// so ItemListView can drive both platforms through the same focusedField binding.
7struct TappableTextField: UIViewRepresentable {
8    @Binding var text: String
9    let isCompleted: Bool
10    let isDragging: Bool
11    let onEditingChanged: (Bool, _ shouldCreateNewItem: Bool) -> Void
12    var returnKeyType: UIReturnKeyType = .done
13    var onContentChange: ((String) -> Void)? = nil
14    var uiAccessibilityIdentifier: String? = nil
15    var initialCursorPoint: CGPoint? = nil
16
17    func makeUIView(context: Context) -> UITextView {
18        PerfSampler.shared.measure("TappableTextField.makeUIView") {
19            let textView = UITextView()
20            textView.accessibilityIdentifier = uiAccessibilityIdentifier
21            textView.delegate = context.coordinator
22            textView.font = ItemRowMetrics.bodyUIK
23            textView.backgroundColor = .clear
24            textView.textContainerInset = .zero
25            textView.textContainer.lineFragmentPadding = 0
26            textView.isScrollEnabled = false
27            textView.autocorrectionType = .default
28            textView.autocapitalizationType = .sentences
29            textView.returnKeyType = returnKeyType
30
31            let placeholder = UILabel()
32            placeholder.text = "Enter text"
33            placeholder.font = ItemRowMetrics.bodyUIK
34            placeholder.textColor = .placeholderText
35            placeholder.tag = 100
36            placeholder.translatesAutoresizingMaskIntoConstraints = false
37            textView.addSubview(placeholder)
38            NSLayoutConstraint.activate([
39                placeholder.leadingAnchor.constraint(equalTo: textView.leadingAnchor),
40                placeholder.topAnchor.constraint(equalTo: textView.topAnchor),
41            ])
42
43            context.coordinator.textView = textView
44            return textView
45        }
46    }
47
48    func updateUIView(_ textView: UITextView, context: Context) {
49        PerfSampler.shared.measure("TappableTextField.updateUIView") {
50            updateUIViewBody(textView, context: context)
51        }
52    }
53
54    private func updateUIViewBody(_ textView: UITextView, context: Context) {
55        if !textView.isFirstResponder {
56            applyStyle(to: textView, text: text, isCompleted: isCompleted)
57        } else if text.isEmpty && !textView.text.isEmpty {
58            // External reset (e.g. phantom row chaining) — clear the view
59            // even though it's first responder.
60            textView.text = ""
61            if let placeholder = textView.viewWithTag(100) as? UILabel {
62                placeholder.isHidden = false
63            }
64        }
65        if textView.returnKeyType != returnKeyType {
66            textView.returnKeyType = returnKeyType
67            textView.reloadInputViews()
68        }
69        textView.accessibilityIdentifier = uiAccessibilityIdentifier
70        textView.isEditable = !isCompleted
71        textView.isSelectable = !isCompleted
72        textView.isUserInteractionEnabled = !isCompleted
73        // Defer isDragging updates to break an AttributeGraph cycle: setting
74        // isEditable/isSelectable during updateUIView causes UITextView to
75        // invalidate its intrinsic content size, creating a layout-to-state
76        // backward edge that SwiftUI's dependency graph flags as a cycle.
77        // Deferring moves the UIView mutation outside of the evaluation pass.
78        let dragging = isDragging
79        if dragging != context.coordinator.isDragging {
80            let coordinator = context.coordinator
81            // Task (not DispatchQueue.main.async) since coordinator is Sendable.
82            Task { @MainActor in
83                coordinator.setDragging(dragging)
84            }
85        }
86        if let placeholder = textView.viewWithTag(100) as? UILabel {
87            placeholder.isHidden = !text.isEmpty
88        }
89    }
90
91    func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? {
92        PerfSampler.shared.measure("TappableTextField.sizeThatFits") {
93            let proposedWidth = proposal.width ?? uiView.bounds.width
94            let width = proposedWidth > 0 ? proposedWidth : (uiView.window?.bounds.width ?? 0)
95            guard width > 0 else { return nil }
96            let fitted = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
97            return CGSize(width: width, height: fitted.height)
98        }
99    }
100
101    func makeCoordinator() -> Coordinator {
102        let coordinator = Coordinator(text: $text, onEditingChanged: onEditingChanged, onContentChange: onContentChange)
103        coordinator.initialCursorPoint = initialCursorPoint
104        return coordinator
105    }
106
107    private func applyStyle(to textView: UITextView, text: String, isCompleted: Bool) {
108        var attributes: [NSAttributedString.Key: Any] = [
109            .font: ItemRowMetrics.bodyUIK,
110            .foregroundColor: isCompleted ? UIColor.secondaryLabel : UIColor.label,
111        ]
112        if isCompleted {
113            attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
114            attributes[.strikethroughColor] = UIColor.secondaryLabel
115        }
116        textView.attributedText = NSAttributedString(string: text, attributes: attributes)
117    }
118
119    final class Coordinator: NSObject, UITextViewDelegate {
120        @Binding var text: String
121        let onEditingChanged: (Bool, _ shouldCreateNewItem: Bool) -> Void
122        let onContentChange: ((String) -> Void)?
123        var returnKeyPressed: Bool = false
124        weak var textView: UITextView?
125        private(set) var isDragging = false
126        var initialCursorPoint: CGPoint?
127
128        init(
129            text: Binding<String>,
130            onEditingChanged: @escaping (Bool, _ shouldCreateNewItem: Bool) -> Void,
131            onContentChange: ((String) -> Void)? = nil
132        ) {
133            _text = text
134            self.onEditingChanged = onEditingChanged
135            self.onContentChange = onContentChange
136        }
137
138        func setDragging(_ dragging: Bool) {
139            guard dragging != isDragging else { return }
140            isDragging = dragging
141            guard let textView else { return }
142            if dragging {
143                textView.isEditable = false
144                textView.isSelectable = false
145            } else {
146                // Restore based on current completion state — updateUIView
147                // will also set these on the next SwiftUI evaluation pass.
148                textView.isEditable = true
149                textView.isSelectable = true
150            }
151        }
152
153        func textViewDidChange(_ textView: UITextView) {
154            text = textView.text
155            onContentChange?(textView.text)
156            if let placeholder = textView.viewWithTag(100) as? UILabel {
157                placeholder.isHidden = !textView.text.isEmpty
158            }
159        }
160
161        func textViewDidBeginEditing(_ textView: UITextView) {
162            PerfSampler.shared.record(
163                label: "TappableTextField.didBeginEditing",
164                durationMs: 0
165            )
166            if let point = initialCursorPoint {
167                initialCursorPoint = nil
168                textView.layoutIfNeeded()
169                if let position = textView.closestPosition(to: point) {
170                    textView.selectedTextRange = textView.textRange(from: position, to: position)
171                }
172            }
173            onEditingChanged(true, false)
174        }
175
176        func textViewDidEndEditing(_ textView: UITextView) {
177            if returnKeyPressed {
178                returnKeyPressed = false
179                return
180            }
181            onEditingChanged(false, false)
182        }
183
184        func textView(
185            _ textView: UITextView,
186            shouldChangeTextIn range: NSRange,
187            replacementText text: String
188        ) -> Bool {
189            guard text == "\n" else { return true }
190            // Intercept Return: trigger new-item creation without inserting a newline.
191            returnKeyPressed = true
192            onEditingChanged(false, true)
193            if textView.returnKeyType == .done {
194                // Non-last item (or empty title): resign immediately so SwiftUI's
195                // focus binding update reliably clears the field on iPad, where the
196                // deferred focusedFieldBinding = .scrollView alone doesn't resign
197                // the UITextView through the hardware-keyboard focus system.
198                textView.resignFirstResponder()
199            }
200            // Return false: for .next (last active item with text), the text view
201            // stays first responder so SwiftUI can transfer focus atomically to the
202            // newly created item's text field in the same render pass.
203            return false
204        }
205    }
206}