~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atirc-ios-client

0
1
main
93 lines · 3.6 KB Raw History
//
//  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<String>

        init(text: Binding<String>) {
            self.text = text
        }

        func textViewDidChange(_ textView: UITextView) {
            text.wrappedValue = textView.text
            textView.invalidateIntrinsicContentSize()
        }
    }
}