Исправление аватарки на экране разблокировки, плавная анимация инпута, онлайн-статус по входящим сообщениям, push-навигация в чат, оптимизация debug-логов
This commit is contained in:
270
Rosetta/DesignSystem/Components/ChatTextInput.swift
Normal file
270
Rosetta/DesignSystem/Components/ChatTextInput.swift
Normal file
@@ -0,0 +1,270 @@
|
||||
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?
|
||||
private var superviewHeightObservation: 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
|
||||
superviewHeightObservation?.invalidate()
|
||||
superviewHeightObservation = nil
|
||||
|
||||
guard let sv = superview else { return }
|
||||
|
||||
observation = sv.observe(\.center, options: [.new]) { [weak self] view, _ in
|
||||
self?.reportHeight(from: view)
|
||||
}
|
||||
superviewHeightObservation = sv.observe(\.bounds, options: [.new]) { [weak self] view, _ in
|
||||
self?.reportHeight(from: view)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
onHeightChange?(keyboardHeight)
|
||||
}
|
||||
|
||||
deinit {
|
||||
observation?.invalidate()
|
||||
superviewHeightObservation?.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)
|
||||
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 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: 6, left: 2, bottom: 8, 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 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()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user