// // LNIMChatTextInputView.swift // Lanu // // Created by OneeChan on 2025/12/4. // import Foundation import UIKit import SnapKit protocol LNIMChatTextInputViewDelegate: NSObject { func onVoiceInputClick() } private enum LNIMChatTextInputType { case none case keyboard case emoji } class LNIMChatTextInputView: UIView { private var hideEmojiConstraint: Constraint? private let emojiButton = UIButton() private let emojiPanel = LNIMChatEmojiPanel() private var emojiHeight: Constraint? private let placeholderLabel = UILabel() private let inputField = LNAutoSizeTextView() private let cameraButton = UIButton() private var hideCameraConstraint: Constraint? private let sendButton = UIButton() weak var delegate: LNIMChatTextInputViewDelegate? weak var viewModel: LNIMChatViewModel? override init(frame: CGRect) { super.init(frame: frame) setupViews() adjustViewsForInputTypeChanged(type: .none) LNEventDeliver.addObserver(self) } func hideInput() { adjustViewsForInputTypeChanged(type: .none) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } extension LNIMChatTextInputView { private func showImageSelectPanel() { let handler: (UIImage?, URL?) -> Void = { [weak self] image, _ in guard let self else { return } guard let image else { return } viewModel?.sendImageMessage(image: image) } LNBottomSheetMenu.showImageSelectMenu(view: self, handler: handler) } } extension LNIMChatTextInputView: UITextViewDelegate { func textViewDidBeginEditing(_ textView: UITextView) { if inputField.inputView == nil { adjustViewsForInputTypeChanged(type: .keyboard) } } func textViewDidChange(_ textView: UITextView) { placeholderLabel.isHidden = textView.text.isEmpty != true let inputEmpty = textView.text?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false if inputEmpty { hideCameraConstraint?.update(priority: .low) } else { hideCameraConstraint?.update(priority: .high) } sendButton.setImage(inputEmpty ? .icImChatVoice : .icImChatSend, for: .normal) cameraButton.isEnabled = inputEmpty UIView.animate(withDuration: 0.25) { [weak self] in guard let self else { return } cameraButton.alpha = inputEmpty ? 1.0 : 0.0 layoutIfNeeded() } } func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { let currentText = textField.text ?? "" guard let range = Range(range, in: currentText) else { return false } let newText = currentText.replacingCharacters(in: range, with: string) if newText.count < currentText.count { return true } return newText.count <= LNIMManager.maxMessageInput } } extension LNIMChatTextInputView: LNKeyboardNotify { func onKeyboardWillShow(curInput: UIView?, keyboardHeight: CGFloat) { guard curInput == inputField else { return } emojiHeight?.update(offset: keyboardHeight) } func onKeyboardShow(curInput: UIView?, keyboardHeight: CGFloat) { guard curInput == inputField else { return } layoutIfNeeded() } } extension LNIMChatTextInputView: LNIMChatEmojiPanelDelegate { func onIMChatEmojiPanelDidClickDelete(view: LNIMChatEmojiPanel) { guard inputField.textStorage.length > 0 else { return } inputField.textStorage.deleteCharacters(in: .init(location: inputField.textStorage.length - 1, length: 1)) textViewDidChange(inputField) } func onIMChatEmojiPanel(view: LNIMChatEmojiPanel, didSelectEmoji emoji: LNEmojiData) { let attachment = LNIMChatEmojiAttachment() attachment.font = inputField.font attachment.name = emoji.name attachment.image = LNIMEmojiManager.shared.getFaceFromCache(path: emoji.path) let emojiStr = NSAttributedString(attachment: attachment) let curRange = inputField.selectedRange if curRange.length > 0 { inputField.textStorage.deleteCharacters(in: curRange) } inputField.textStorage.insert(emojiStr, at: inputField.selectedRange.location) inputField.selectedRange = .init(location: inputField.selectedRange.location + 1, length: 0) textViewDidChange(inputField) } } extension LNIMChatTextInputView { private func adjustViewsForInputTypeChanged(type: LNIMChatTextInputType) { switch type { case .none: inputField.inputView = nil inputField.resignFirstResponder() emojiButton.setImage(.icImChatEmoji, for: .normal) hideEmojiConstraint?.update(priority: .high) emojiPanel.isHidden = true emojiPanel.reloadData() // 隐藏输入框便触发刷新 case .keyboard: inputField.becomeFirstResponder() inputField.inputView = nil inputField.reloadInputViews() emojiButton.setImage(.icImChatEmoji, for: .normal) hideEmojiConstraint?.update(priority: .low) emojiPanel.isHidden = true case .emoji: inputField.becomeFirstResponder() inputField.inputView = UIView() inputField.reloadInputViews() emojiButton.setImage(.icImChatKeyboard, for: .normal) hideEmojiConstraint?.update(priority: .low) emojiPanel.isHidden = false } } private func setupViews() { backgroundColor = .fill let inputMenu = buildInputMenu() addSubview(inputMenu) inputMenu.snp.makeConstraints { make in make.top.equalToSuperview().inset(10) make.leading.equalToSuperview().offset(12) hideEmojiConstraint = make.bottom.equalToSuperview().offset(-safeBottomInset).priority(.high).constraint } sendButton.setImage(.icImChatVoice, for: .normal) sendButton.setContentHuggingPriority(.defaultHigh, for: .horizontal) sendButton.addAction(UIAction(handler: { [weak self] _ in guard let self else { return } let text = inputField.textStorage if text.length == 0 { delegate?.onVoiceInputClick() adjustViewsForInputTypeChanged(type: .none) } else { viewModel?.sendTextMessage(text: text.toEmojiContent) inputField.text = nil textViewDidChange(inputField) } }), for: .touchUpInside) addSubview(sendButton) sendButton.snp.makeConstraints { make in make.centerY.equalTo(inputMenu) make.trailing.equalToSuperview().offset(-12) make.leading.equalTo(inputMenu.snp.trailing).offset(10) } let emoji = buildEmojiView() addSubview(emoji) emoji.snp.makeConstraints { make in make.horizontalEdges.equalToSuperview() make.top.equalTo(inputMenu.snp.bottom).offset(10) make.bottom.equalToSuperview().priority(.medium) emojiHeight = make.height.equalTo(336).constraint } } private func buildInputMenu() -> UIView { let container = UIView() container.backgroundColor = .fill_2 container.layer.cornerRadius = 19 container.clipsToBounds = true emojiButton.setImage(.icImChatEmoji, for: .normal) emojiButton.setContentHuggingPriority(.defaultHigh, for: .horizontal) emojiButton.addAction(UIAction(handler: { [weak self] _ in guard let self else { return } if inputField.inputView != nil { adjustViewsForInputTypeChanged(type: .keyboard) } else { adjustViewsForInputTypeChanged(type: .emoji) } inputField.reloadInputViews() }), for: .touchUpInside) container.addSubview(emojiButton) emojiButton.snp.makeConstraints { make in make.bottom.equalToSuperview().offset(-8) make.leading.equalToSuperview().offset(10) } inputField.font = .body_m inputField.textColor = .text_5 inputField.backgroundColor = .clear inputField.delegate = self container.addSubview(inputField) inputField.snp.makeConstraints { make in make.verticalEdges.equalToSuperview().inset(3.5) make.leading.equalTo(emojiButton.snp.trailing).offset(4) hideCameraConstraint = make.trailing.equalToSuperview().offset(-10).priority(.low).constraint } placeholderLabel.text = .init(key: "A00084") placeholderLabel.font = .body_m placeholderLabel.textColor = .text_2 container.insertSubview(placeholderLabel, belowSubview: inputField) placeholderLabel.snp.makeConstraints { make in make.centerY.equalTo(inputField) make.leading.equalTo(inputField).offset(4) } cameraButton.setImage(.icImChatCamera, for: .normal) cameraButton.addAction(UIAction(handler: { [weak self] _ in guard let self else { return } adjustViewsForInputTypeChanged(type: .none) showImageSelectPanel() }), for: .touchUpInside) container.addSubview(cameraButton) cameraButton.snp.makeConstraints { make in make.bottom.equalToSuperview().offset(-8) make.leading.equalTo(inputField.snp.trailing).offset(4) make.trailing.equalToSuperview().offset(-10).priority(.medium) } return container } private func buildEmojiView() -> UIView { emojiPanel.isHidden = true emojiPanel.delegate = self return emojiPanel } } #if DEBUG import SwiftUI struct LNIMChatTextInputViewPreview: UIViewRepresentable { func makeUIView(context: Context) -> some UIView { let container = UIView() container.backgroundColor = .lightGray let view = LNIMChatTextInputView() container.addSubview(view) view.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } return container } func updateUIView(_ uiView: UIViewType, context: Context) { } } #Preview(body: { LNIMChatTextInputViewPreview() }) #endif