listless

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

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 }