diff --git a/yobble/Views/Chat/PrivateChatView.swift b/yobble/Views/Chat/PrivateChatView.swift index f3ee18b..e590e65 100644 --- a/yobble/Views/Chat/PrivateChatView.swift +++ b/yobble/Views/Chat/PrivateChatView.swift @@ -1,4 +1,7 @@ import SwiftUI +#if canImport(UIKit) +import UIKit +#endif struct PrivateChatView: View { let chat: PrivateChatListItem @@ -12,6 +15,7 @@ struct PrivateChatView: View { @State private var draftText: String = "" @State private var inputTab: ComposerTab = .chat @State private var isVideoPreferred: Bool = false + @State private var legacyComposerHeight: CGFloat = 40 @FocusState private var isComposerFocused: Bool @EnvironmentObject private var messageCenter: IncomingMessageCenter @@ -242,30 +246,36 @@ struct PrivateChatView: View { .frame(width: 36, height: 36) ZStack(alignment: .bottomTrailing) { - if #available(iOS 16.0, *) { - TextField(inputTab.placeholder, text: $draftText, axis: .vertical) - .lineLimit(1...4) - .focused($isComposerFocused) - .submitLabel(.send) - .disabled(currentUserId == nil) - .onSubmit { sendCurrentMessage() } - .padding(.top, 10) - .padding(.leading, 12) - .padding(.trailing, 44) - .padding(.bottom, 10) - .frame(maxWidth: .infinity, minHeight: 40, alignment: .bottomLeading) - } else { - TextField(inputTab.placeholder, text: $draftText) - .focused($isComposerFocused) - .submitLabel(.send) - .disabled(currentUserId == nil) - .onSubmit { sendCurrentMessage() } - .padding(.top, 10) - .padding(.leading, 12) - .padding(.trailing, 44) - .padding(.bottom, 10) - .frame(maxWidth: .infinity, minHeight: 40, alignment: .bottomLeading) + Group { + if #available(iOS 160.0, *) { + TextField(inputTab.placeholder, text: $draftText, axis: .vertical) + .lineLimit(1...4) + .focused($isComposerFocused) + .submitLabel(.send) + .disabled(currentUserId == nil) + .onSubmit { sendCurrentMessage() } + } else { + LegacyMultilineTextView( + text: $draftText, + placeholder: inputTab.placeholder, + isFocused: Binding( + get: { isComposerFocused }, + set: { isComposerFocused = $0 } + ), + isEnabled: currentUserId != nil, + minHeight: 10, + maxLines: 4, + calculatedHeight: $legacyComposerHeight, + onSubmit: sendCurrentMessage + ) + .frame(height: legacyComposerHeight) + } } + .padding(.top, 10) + .padding(.leading, 12) + .padding(.trailing, 44) + .padding(.bottom, 10) + .frame(maxWidth: .infinity, minHeight: 40, alignment: .bottomLeading) Button(action: { }) { // переключатель на стикеры Image(systemName: "face.smiling") @@ -455,6 +465,155 @@ struct PrivateChatView: View { } } +#if canImport(UIKit) +private struct LegacyMultilineTextView: UIViewRepresentable { + @Binding var text: String + var placeholder: String + @Binding var isFocused: Bool + var isEnabled: Bool + var minHeight: CGFloat + var maxLines: Int + @Binding var calculatedHeight: CGFloat + var onSubmit: (() -> Void)? + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.delegate = context.coordinator + textView.backgroundColor = .clear + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.isScrollEnabled = false + textView.font = UIFont.preferredFont(forTextStyle: .body) + textView.text = text + textView.returnKeyType = .send + textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + let placeholderLabel = context.coordinator.placeholderLabel + placeholderLabel.text = placeholder + placeholderLabel.font = textView.font + placeholderLabel.textColor = UIColor.secondaryLabel + placeholderLabel.numberOfLines = 1 + placeholderLabel.translatesAutoresizingMaskIntoConstraints = false + textView.addSubview(placeholderLabel) + + NSLayoutConstraint.activate([ + placeholderLabel.topAnchor.constraint(equalTo: textView.topAnchor), + placeholderLabel.leadingAnchor.constraint(equalTo: textView.leadingAnchor), + placeholderLabel.trailingAnchor.constraint(lessThanOrEqualTo: textView.trailingAnchor) + ]) + + context.coordinator.updatePlaceholderVisibility(for: textView) + DispatchQueue.main.async { + Self.recalculateHeight(for: textView, result: calculatedHeightBinding, minHeight: minHeight, maxLines: maxLines) + } + + return textView + } + + func updateUIView(_ uiView: UITextView, context: Context) { + context.coordinator.parent = self + + if uiView.text != text { + uiView.text = text + } + + uiView.isEditable = isEnabled + uiView.isSelectable = isEnabled + uiView.textColor = isEnabled ? UIColor.label : UIColor.secondaryLabel + + let placeholderLabel = context.coordinator.placeholderLabel + if placeholderLabel.text != placeholder { + placeholderLabel.text = placeholder + } + placeholderLabel.font = uiView.font + context.coordinator.updatePlaceholderVisibility(for: uiView) + + if isFocused && !uiView.isFirstResponder { + uiView.becomeFirstResponder() + } else if !isFocused && uiView.isFirstResponder { + uiView.resignFirstResponder() + } + + Self.recalculateHeight(for: uiView, result: calculatedHeightBinding, minHeight: minHeight, maxLines: maxLines) + } + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + private var calculatedHeightBinding: Binding { + Binding(get: { calculatedHeight }, set: { calculatedHeight = $0 }) + } + + private static func recalculateHeight(for textView: UITextView, result: Binding, minHeight: CGFloat, maxLines: Int) { + let width = textView.bounds.width + guard width > 0 else { + DispatchQueue.main.async { + recalculateHeight(for: textView, result: result, minHeight: minHeight, maxLines: maxLines) + } + return + } + + let fittingSize = CGSize(width: width, height: .greatestFiniteMagnitude) + let targetSize = textView.sizeThatFits(fittingSize) + let lineHeight = textView.font?.lineHeight ?? UIFont.preferredFont(forTextStyle: .body).lineHeight + let maxHeight = minHeight + lineHeight * CGFloat(max(maxLines - 1, 0)) + let clampedHeight = min(max(targetSize.height, minHeight), maxHeight) + + if abs(result.wrappedValue - clampedHeight) > 0.5 { + result.wrappedValue = clampedHeight + } + + textView.isScrollEnabled = targetSize.height > maxHeight + 0.5 + } + + final class Coordinator: NSObject, UITextViewDelegate { + var parent: LegacyMultilineTextView + let placeholderLabel = UILabel() + + init(parent: LegacyMultilineTextView) { + self.parent = parent + } + + func textViewDidBeginEditing(_ textView: UITextView) { + if !parent.isFocused { + parent.isFocused = true + } + } + + func textViewDidEndEditing(_ textView: UITextView) { + if parent.isFocused { + parent.isFocused = false + } + } + + func textViewDidChange(_ textView: UITextView) { + if parent.text != textView.text { + parent.text = textView.text + } + updatePlaceholderVisibility(for: textView) + LegacyMultilineTextView.recalculateHeight(for: textView, result: parent.calculatedHeightBinding, minHeight: parent.minHeight, maxLines: parent.maxLines) + } + + func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + if text == "\n" { + if let onSubmit = parent.onSubmit { + DispatchQueue.main.async { + onSubmit() + } + return false + } + } + return true + } + + func updatePlaceholderVisibility(for textView: UITextView) { + placeholderLabel.isHidden = !textView.text.isEmpty + } + } +} +#endif + // MARK: - Preview // Previews intentionally omitted - MessageItem has custom decoding-only initializer.