TappableTextField.swift (8937B)
1 import SwiftUI 2 import 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. 7 struct 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 }