1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
//
// 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()
}
}
}