Исправление аватарки на экране разблокировки, плавная анимация инпута, онлайн-статус по входящим сообщениям, push-навигация в чат, оптимизация debug-логов

This commit is contained in:
2026-03-13 00:12:30 +05:00
parent 70deaaf7f7
commit c7bea82c3a
30 changed files with 1245 additions and 270 deletions

View 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)
}
}
}