ListlessiOS/Helpers/TappableTextField.swift (8937B)
1import SwiftUI
2import UIKit
3
4
5
6
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
59
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
74
75
76
77
78 let dragging = isDragging
79 if dragging != context.coordinator.isDragging {
80 let coordinator = context.coordinator
81
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
147
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
191 returnKeyPressed = true
192 onEditingChanged(false, true)
193 if textView.returnKeyType == .done {
194
195
196
197
198 textView.resignFirstResponder()
199 }
200
201
202
203 return false
204 }
205 }
206}