// // ComposerTextView.swift // AT-IRC // // Multi-line message composer backed directly by UITextView. // // SwiftUI's multi-line TextField can ignore programmatic changes to its // binding while it is focused, which leaves an already-sent message visible // in the composer. Writing the bound text to UITextView ourselves in // updateUIView makes clears (and other model-side edits) reliable. // import SwiftUI import UIKit struct ComposerTextView: UIViewRepresentable { @Binding var text: String /// The composer grows up to this many lines, then scrolls internally. private static let maxLines: CGFloat = 5 func makeUIView(context: Context) -> UITextView { let textView = UITextView() textView.delegate = context.coordinator textView.font = UIFont.preferredFont(forTextStyle: .body) textView.adjustsFontForContentSizeCategory = true textView.backgroundColor = .clear textView.textColor = .label textView.textContainerInset = .zero textView.textContainer.lineFragmentPadding = 0 textView.isScrollEnabled = false // Inline predictions can leave stale text behind after the binding is // cleared (the same issue that affects SwiftUI's TextField). textView.inlinePredictionType = .no textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) return textView } func updateUIView(_ textView: UITextView, context: Context) { Self.apply(text, to: textView) } func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? { guard let width = proposal.width, width.isFinite, width > 0 else { return nil } let font = uiView.font ?? UIFont.preferredFont(forTextStyle: .body) let measured = Self.measuredHeight(of: uiView.text, font: font, width: width) let maximum = ceil(font.lineHeight * Self.maxLines) uiView.isScrollEnabled = measured > maximum return CGSize(width: width, height: min(measured, maximum)) } func makeCoordinator() -> Coordinator { Coordinator(text: $text) } // MARK: - Text updates /// Push the model's text into the text view even while it is first /// responder; SwiftUI's own text field does not always do this. static func apply(_ text: String, to textView: UITextView) { guard textView.text != text else { return } textView.text = text let length = (text as NSString).length let location = min(textView.selectedRange.location, length) textView.selectedRange = NSRange(location: location, length: 0) } private static func measuredHeight(of text: String, font: UIFont, width: CGFloat) -> CGFloat { guard !text.isEmpty else { return font.lineHeight } let bounds = (text as NSString).boundingRect( with: CGSize(width: width, height: .greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: font], context: nil ) // A trailing newline adds an empty line that boundingRect omits. let trailingLine = text.hasSuffix("\n") ? font.lineHeight : 0 return max(font.lineHeight, ceil(bounds.height) + trailingLine) } final class Coordinator: NSObject, UITextViewDelegate { private let text: Binding init(text: Binding) { self.text = text } func textViewDidChange(_ textView: UITextView) { text.wrappedValue = textView.text textView.invalidateIntrinsicContentSize() } } }