290 lines
10 KiB
Swift
290 lines
10 KiB
Swift
import SwiftUI
|
|
import UIKit
|
|
|
|
// MARK: - KeyboardTrackingView
|
|
|
|
/// A zero-height UIView used as `inputAccessoryView`.
|
|
/// KVO on `superview.center` gives pixel-perfect keyboard position
|
|
/// during interactive dismiss — the most reliable path for composer sync.
|
|
final class KeyboardTrackingView: UIView {
|
|
|
|
var onHeightChange: ((CGFloat) -> Void)?
|
|
|
|
private var observation: NSKeyValueObservation?
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: .init(x: 0, y: 0, width: frame.width, height: 0))
|
|
autoresizingMask = .flexibleWidth
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError() }
|
|
|
|
override var intrinsicContentSize: CGSize {
|
|
.init(width: UIView.noIntrinsicMetric, height: 0)
|
|
}
|
|
|
|
override func didMoveToSuperview() {
|
|
super.didMoveToSuperview()
|
|
observation?.invalidate()
|
|
observation = nil
|
|
|
|
guard let sv = superview else { return }
|
|
|
|
// Only observe .center — .bounds fires simultaneously for the same
|
|
// position change, doubling KVO callbacks with no new information.
|
|
observation = sv.observe(\.center, options: [.new]) { [weak self] view, _ in
|
|
self?.reportHeight(from: view)
|
|
}
|
|
}
|
|
|
|
private var lastReportedHeight: CGFloat = -1
|
|
|
|
private func reportHeight(from hostView: UIView) {
|
|
guard let window = hostView.window else { return }
|
|
let screenHeight = window.screen.bounds.height
|
|
let hostFrame = hostView.convert(hostView.bounds, to: nil)
|
|
let keyboardHeight = max(0, screenHeight - hostFrame.origin.y)
|
|
// Throttle: only fire callback when rounded height actually changes.
|
|
// Sub-point changes are invisible but still trigger full SwiftUI layout.
|
|
// This halves the number of body evaluations during interactive dismiss.
|
|
let rounded = round(keyboardHeight)
|
|
guard abs(rounded - lastReportedHeight) > 1 else { return }
|
|
lastReportedHeight = rounded
|
|
onHeightChange?(rounded)
|
|
}
|
|
|
|
deinit {
|
|
observation?.invalidate()
|
|
}
|
|
}
|
|
|
|
// MARK: - ChatInputTextView
|
|
|
|
/// UITextView subclass with a placeholder label that stays visible while empty.
|
|
final class ChatInputTextView: UITextView {
|
|
|
|
let trackingView = KeyboardTrackingView(
|
|
frame: .init(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 0)
|
|
)
|
|
|
|
/// Placeholder label — visible when text is empty, even while focused.
|
|
let placeholderLabel: UILabel = {
|
|
let label = UILabel()
|
|
label.translatesAutoresizingMaskIntoConstraints = false
|
|
label.isUserInteractionEnabled = false
|
|
return label
|
|
}()
|
|
|
|
override init(frame: CGRect, textContainer: NSTextContainer?) {
|
|
super.init(frame: frame, textContainer: textContainer)
|
|
// KVO-based tracking for interactive keyboard dismiss.
|
|
// trackingView is zero-height — superview.center KVO gives
|
|
// pixel-perfect keyboard position during swipe-to-dismiss.
|
|
inputAccessoryView = trackingView
|
|
inputAssistantItem.leadingBarButtonGroups = []
|
|
inputAssistantItem.trailingBarButtonGroups = []
|
|
addSubview(placeholderLabel)
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
// Position placeholder to match text position exactly
|
|
let insets = textContainerInset
|
|
let padding = textContainer.lineFragmentPadding
|
|
placeholderLabel.frame.origin = CGPoint(x: insets.left + padding, y: insets.top)
|
|
placeholderLabel.frame.size = CGSize(
|
|
width: bounds.width - insets.left - insets.right - padding * 2,
|
|
height: placeholderLabel.intrinsicContentSize.height
|
|
)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError() }
|
|
}
|
|
|
|
// MARK: - ChatTextInput (UIViewRepresentable)
|
|
|
|
struct ChatTextInput: UIViewRepresentable {
|
|
|
|
@Binding var text: String
|
|
@Binding var isFocused: Bool
|
|
var onKeyboardHeightChange: (CGFloat) -> Void
|
|
var onUserTextInsertion: () -> Void = {}
|
|
var onMultilineChange: (Bool) -> Void = { _ in }
|
|
var font: UIFont = .systemFont(ofSize: 17, weight: .regular)
|
|
var textColor: UIColor = .white
|
|
var placeholderColor: UIColor = UIColor.white.withAlphaComponent(0.35)
|
|
var placeholder: String = "Message"
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(parent: self)
|
|
}
|
|
|
|
func makeUIView(context: Context) -> ChatInputTextView {
|
|
let tv = ChatInputTextView()
|
|
tv.delegate = context.coordinator
|
|
tv.font = font
|
|
tv.textColor = textColor
|
|
tv.backgroundColor = .clear
|
|
tv.tintColor = UIColor(RosettaColors.primaryBlue)
|
|
tv.isScrollEnabled = false
|
|
tv.textContainerInset = UIEdgeInsets(top: 7, left: 2, bottom: 7, right: 0)
|
|
tv.textContainer.lineFragmentPadding = 0
|
|
tv.autocapitalizationType = .sentences
|
|
tv.autocorrectionType = .default
|
|
tv.keyboardAppearance = .dark
|
|
tv.returnKeyType = .default
|
|
|
|
// Placeholder label (stays visible when text is empty, even if focused)
|
|
tv.placeholderLabel.text = placeholder
|
|
tv.placeholderLabel.font = font
|
|
tv.placeholderLabel.textColor = placeholderColor
|
|
tv.placeholderLabel.isHidden = !text.isEmpty
|
|
|
|
tv.trackingView.onHeightChange = { [weak coordinator = context.coordinator] height in
|
|
coordinator?.handleKeyboardHeight(height)
|
|
}
|
|
|
|
// Set initial text
|
|
if !text.isEmpty {
|
|
tv.text = text
|
|
}
|
|
|
|
return tv
|
|
}
|
|
|
|
func updateUIView(_ tv: ChatInputTextView, context: Context) {
|
|
let coord = context.coordinator
|
|
coord.parent = self
|
|
|
|
// Sync text from SwiftUI → UIKit (avoid loop)
|
|
if !coord.isUpdatingText {
|
|
if text != tv.text {
|
|
tv.text = text
|
|
coord.invalidateHeight(tv)
|
|
}
|
|
tv.placeholderLabel.isHidden = !text.isEmpty
|
|
}
|
|
|
|
// Sync focus without replaying stale async requests during interactive dismiss.
|
|
coord.syncFocus(for: tv)
|
|
}
|
|
|
|
func sizeThatFits(_ proposal: ProposedViewSize, uiView tv: ChatInputTextView, context: Context) -> CGSize? {
|
|
let maxWidth = proposal.width ?? UIScreen.main.bounds.width
|
|
let lineHeight = font.lineHeight
|
|
let maxLines: CGFloat = 5
|
|
let insets = tv.textContainerInset
|
|
let maxTextHeight = lineHeight * maxLines
|
|
let maxTotalHeight = maxTextHeight + insets.top + insets.bottom
|
|
|
|
let fittingSize = tv.sizeThatFits(CGSize(width: maxWidth, height: .greatestFiniteMagnitude))
|
|
let clampedHeight = min(fittingSize.height, maxTotalHeight)
|
|
|
|
// Enable scrolling when content exceeds max height
|
|
let shouldScroll = fittingSize.height > maxTotalHeight
|
|
if tv.isScrollEnabled != shouldScroll {
|
|
tv.isScrollEnabled = shouldScroll
|
|
}
|
|
|
|
return CGSize(width: maxWidth, height: max(36, clampedHeight))
|
|
}
|
|
|
|
// MARK: - Coordinator
|
|
|
|
final class Coordinator: NSObject, UITextViewDelegate {
|
|
var parent: ChatTextInput
|
|
var isUpdatingText = false
|
|
private var wasMultiline = false
|
|
private var pendingFocusSync: DispatchWorkItem?
|
|
|
|
init(parent: ChatTextInput) {
|
|
self.parent = parent
|
|
}
|
|
|
|
func handleKeyboardHeight(_ height: CGFloat) {
|
|
parent.onKeyboardHeightChange(height)
|
|
}
|
|
|
|
// MARK: UITextViewDelegate
|
|
|
|
func textViewDidBeginEditing(_ tv: UITextView) {
|
|
pendingFocusSync?.cancel()
|
|
// Placeholder stays visible — only hidden when user types
|
|
if !parent.isFocused {
|
|
parent.isFocused = true
|
|
}
|
|
}
|
|
|
|
func textViewDidEndEditing(_ tv: UITextView) {
|
|
pendingFocusSync?.cancel()
|
|
// Must be synchronous — async causes race condition with .ignoresSafeArea(.keyboard):
|
|
// padding animation triggers updateUIView before isFocused is updated,
|
|
// causing becomeFirstResponder() → keyboard reopens.
|
|
if parent.isFocused {
|
|
parent.isFocused = false
|
|
}
|
|
}
|
|
|
|
func textView(
|
|
_ tv: UITextView,
|
|
shouldChangeTextIn range: NSRange,
|
|
replacementText text: String
|
|
) -> Bool {
|
|
guard !text.isEmpty, text != "\n" else { return true }
|
|
parent.onUserTextInsertion()
|
|
return true
|
|
}
|
|
|
|
func textViewDidChange(_ tv: UITextView) {
|
|
isUpdatingText = true
|
|
parent.text = tv.text ?? ""
|
|
isUpdatingText = false
|
|
// Toggle placeholder based on content
|
|
if let chatTV = tv as? ChatInputTextView {
|
|
chatTV.placeholderLabel.isHidden = !tv.text.isEmpty
|
|
}
|
|
invalidateHeight(tv)
|
|
}
|
|
|
|
func invalidateHeight(_ tv: UITextView) {
|
|
tv.invalidateIntrinsicContentSize()
|
|
checkMultiline(tv)
|
|
}
|
|
|
|
private func checkMultiline(_ tv: UITextView) {
|
|
let lineHeight = tv.font?.lineHeight ?? 20
|
|
let singleLineHeight = lineHeight + tv.textContainerInset.top + tv.textContainerInset.bottom
|
|
let isMultiline = tv.contentSize.height > singleLineHeight + 0.5
|
|
if isMultiline != wasMultiline {
|
|
wasMultiline = isMultiline
|
|
parent.onMultilineChange(isMultiline)
|
|
}
|
|
}
|
|
|
|
func syncFocus(for tv: UITextView) {
|
|
pendingFocusSync?.cancel()
|
|
|
|
let wantsFocus = parent.isFocused
|
|
guard wantsFocus != tv.isFirstResponder else { return }
|
|
|
|
let workItem = DispatchWorkItem { [weak self, weak tv] in
|
|
guard let self, let tv else { return }
|
|
guard self.parent.isFocused == wantsFocus else { return }
|
|
|
|
if wantsFocus {
|
|
guard !tv.isFirstResponder else { return }
|
|
tv.becomeFirstResponder()
|
|
} else {
|
|
guard tv.isFirstResponder else { return }
|
|
tv.resignFirstResponder()
|
|
}
|
|
}
|
|
|
|
pendingFocusSync = workItem
|
|
DispatchQueue.main.async(execute: workItem)
|
|
}
|
|
}
|
|
}
|